diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index b6e61a6508e1..ce281aa4d2a8 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -105,4 +105,16 @@ describe("extractPromptFromMessage", () => { expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" }) }) + + test("restores command invocation text", () => { + const message = { + id: "msg_1", + type: "user", + text: "expanded command template", + command: { name: "command", arguments: "input" }, + time: { created: 1 }, + } satisfies SessionMessageUser + + expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" }) + }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index f7d4d2b66f8b..36f74a79f308 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -44,7 +44,9 @@ export function extractPromptFromMessage( message: SessionMessageUser, opts?: { directory?: string; attachmentName?: string }, ): Prompt { - const text = readPromptPresentation(message.metadata)?.displayText ?? message.text + const text = message.command + ? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}` + : (readPromptPresentation(message.metadata)?.displayText ?? message.text) const directory = opts?.directory const attachmentName = opts?.attachmentName ?? "attachment" const toRelative = (path: string) => { diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index d7ca76b50586..2e5930641cff 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -50,6 +50,18 @@ describe("session message presentation", () => { }) }) + test("projects command invocation text", () => { + const message = { + id: "msg_user", + type: "user", + text: "expanded command template", + command: { name: "command", arguments: "input" }, + time: { created: 1 }, + } satisfies SessionMessageUser + + expect(presentUserParts("ses_1", message)[0]).toMatchObject({ type: "text", text: "/command input" }) + }) + test("projects current assistant content for existing DOM tools", () => { const message = { id: "msg_assistant", diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index e1a1a1529b15..d30159037648 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -57,7 +57,9 @@ export function presentUserMessage( export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] { const presentation = readPromptPresentation(message.metadata) - const text = presentation?.displayText ?? message.text + const text = message.command + ? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}` + : (presentation?.displayText ?? message.text) return [ ...(text ? [textPart(sessionID, message.id, 0, text)] : []), ...(message.files ?? []).map( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0d54ac34f990..f7f568664c4d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -30,6 +30,8 @@ export type FileDiffInfo = { status: "added" | "deleted" | "modified" } +export type PromptCommandInvocation = { name: string; arguments: string } + export type PromptBase64 = string export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string } @@ -1684,6 +1686,7 @@ export type SessionMessageUser = { metadata?: { [x: string]: JsonValue } time: { created: number } text: string + command?: PromptCommandInvocation files?: Array agents?: Array skills?: Array @@ -1692,6 +1695,7 @@ export type SessionMessageUser = { export type SessionInboxUserPayload = { text: string + command?: PromptCommandInvocation files?: Array agents?: Array skills?: Array @@ -1700,6 +1704,7 @@ export type SessionInboxUserPayload = { export type SessionInboxUserPayload1 = { text: string + command?: PromptCommandInvocation files?: Array agents?: Array skills?: Array @@ -2552,6 +2557,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string + readonly command?: { readonly name: string; readonly arguments: string } readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -2821,6 +2827,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string + readonly command?: { readonly name: string; readonly arguments: string } readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -3090,6 +3097,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string + readonly command?: { readonly name: string; readonly arguments: string } readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 9fcaa94b7f01..a963d4152190 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -222,6 +222,7 @@ export interface Interface { id?: SessionMessage.ID sessionID: SessionSchema.ID text: string + command?: Prompt["command"] files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] skills?: PromptInput.Prompt["skills"] @@ -586,11 +587,7 @@ const layer = Layer.effect( return yield* Image.Service }).pipe(Effect.provide(locations.get(session.location))) const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location))) - const prompt = yield* resolvePrompt( - { text: input.text, files: input.files, agents: input.agents, skills: input.skills }, - image, - skills, - ).pipe(Effect.provideService(FSUtil.Service, fs)) + const prompt = yield* resolvePrompt(input, image, skills).pipe(Effect.provideService(FSUtil.Service, fs)) const messageID = input.id ?? SessionMessage.ID.create() const admittedInput = SessionInbox.Item.make({ type: "user", @@ -657,6 +654,7 @@ const layer = Layer.effect( id: input.id, sessionID: input.sessionID, text: evaluated.text, + command: { name: input.command, arguments: input.arguments ?? "" }, files: input.files, agents: input.agents, skills: input.skills, @@ -964,7 +962,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf } const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* ( - input: PromptInput.Prompt, + input: PromptInput.Prompt & Pick, image: Effect.Effect, skills: Effect.Effect, ) { @@ -987,7 +985,13 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* ( }) }) }) - return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined }) + return Prompt.fromUserMessage({ + text: input.text, + command: input.command, + agents: input.agents, + files, + skills: selected?.length ? selected : undefined, + }) }) const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index e2cf0173fff8..a2b01166412b 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Money } from "@opencode-ai/schema/money" import { Worktree } from "@opencode-ai/schema/worktree" import { Project } from "@opencode-ai/schema/project" +import { Prompt } from "@opencode-ai/schema/prompt" import { AbsolutePath, RelativePath } from "../schema.js" import type { SessionSchema } from "./schema.js" @@ -526,17 +527,14 @@ const layer = Layer.effectDiscard( yield* insertMessage( db, event, - input.type === "user" - ? { - id: input.id, - type: "user", - metadata: input.payload.metadata, - text: input.payload.text, - files: input.payload.files, - agents: input.payload.agents, - skills: input.payload.skills, - time: { created: DateTime.makeUnsafe(event.created) }, - } + input.type === "user" + ? { + ...Prompt.fromUserMessage(input.payload), + id: input.id, + type: "user", + metadata: input.payload.metadata, + time: { created: DateTime.makeUnsafe(event.created) }, + } : { id: input.id, type: "synthetic", diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1aed7f170b9c..4ac606f451dd 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -323,7 +323,11 @@ describe("SessionProjector", () => { const admitted = yield* SessionInbox.admit(db, bus, { id, sessionID, - item: { type: "user", payload: { text: "promote me" }, delivery: "steer" }, + item: { + type: "user", + payload: { text: "expanded command template", command: { name: "command", arguments: "input" } }, + delivery: "steer", + }, }) if (!admitted) return yield* Effect.die("Prompt admission failed") @@ -337,7 +341,15 @@ describe("SessionProjector", () => { ).toBeUndefined() expect( yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq }) + ).toMatchObject({ + session_id: sessionID, + type: "user", + seq: event.durable?.seq, + data: { + text: "expanded command template", + command: { name: "command", arguments: "input" }, + }, + }) }), ) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b55b7ee5c511..1e2ff06bd7c9 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -235,16 +235,18 @@ describe("Session.prompt", () => { const message = yield* session.prompt({ sessionID, text: "Fix the failing tests", + command: { name: "fix", arguments: "tests" }, resume: false, }) expect(message.payload.text).toBe("Fix the failing tests") + expect(message.payload.command).toEqual({ name: "fix", arguments: "tests" }) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admitted(message.id)).toMatchObject({ id: message.id, sessionID, type: "user", - payload: { text: "Fix the failing tests" }, + payload: { text: "Fix the failing tests", command: { name: "fix", arguments: "tests" } }, delivery: "steer", }) }), diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index 19a05449b4d8..3d8e216cf2ed 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -61,9 +61,16 @@ export const SkillAttachment = Schema.Struct({ mention: PromptMention.pipe(optional), }).annotate({ identifier: "Prompt.SkillAttachment" }) +export interface CommandInvocation extends Schema.Schema.Type {} +export const CommandInvocation = Schema.Struct({ + name: Schema.String, + arguments: Schema.String, +}).annotate({ identifier: "Prompt.CommandInvocation" }) + export interface Prompt extends Schema.Schema.Type {} export const Prompt = Schema.Struct({ text: Schema.String, + command: CommandInvocation.pipe(optional), files: Schema.Array(FileAttachment).pipe(optional), agents: Schema.Array(AgentAttachment).pipe(optional), skills: Schema.Array(SkillAttachment).pipe(optional), @@ -72,9 +79,10 @@ export const Prompt = Schema.Struct({ .pipe( statics((schema) => ({ equivalence: Schema.toEquivalence(schema), - fromUserMessage: (input: Pick) => + fromUserMessage: (input: Pick) => schema.make({ text: input.text, + ...(input.command === undefined ? {} : { command: input.command }), ...(input.files === undefined ? {} : { files: input.files }), ...(input.agents === undefined ? {} : { agents: input.agents }), ...(input.skills === undefined ? {} : { skills: input.skills }), diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 9323ca58d654..a70c12e95674 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -72,10 +72,7 @@ export const LocationSwitched = Schema.Struct({ export interface User extends Schema.Schema.Type {} export const User = Schema.Struct({ ...Base, - text: Prompt.fields.text, - files: Prompt.fields.files, - agents: Prompt.fields.agents, - skills: Prompt.fields.skills, + ...Prompt.fields, type: Schema.tag("user"), }).annotate({ identifier: "Session.Message.User" }) diff --git a/packages/tui/src/mini/command.shared.ts b/packages/tui/src/mini/command.shared.ts new file mode 100644 index 000000000000..2c7f2f9cadf1 --- /dev/null +++ b/packages/tui/src/mini/command.shared.ts @@ -0,0 +1,13 @@ +import type { StreamCommit } from "./types" +import { commandText } from "../util/command" + +export function commandCommit(messageID: string | undefined, command: { name: string; arguments: string }): StreamCommit { + return { + kind: "system", + source: "system", + messageID, + partID: "command", + text: `→ Command "${commandText(command)}"`, + phase: "start", + } +} diff --git a/packages/tui/src/mini/runtime.queue.ts b/packages/tui/src/mini/runtime.queue.ts index 31e1f8c0c55f..b1fbcb5c3e7b 100644 --- a/packages/tui/src/mini/runtime.queue.ts +++ b/packages/tui/src/mini/runtime.queue.ts @@ -12,6 +12,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import { Locale } from "../util/locale" import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared" import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types" +import { commandCommit } from "./command.shared" type Trace = { write(type: string, data?: unknown): void @@ -173,13 +174,16 @@ export async function runPromptQueue(input: QueueInput): Promise { } if (sent.mode !== "shell") { - const commit = { - kind: "user", - text: sent.text, - phase: "start", - source: "system", - messageID: sent.messageID, - } as const + const commit = + sent.command && sent.command.source !== "skill" + ? commandCommit(sent.messageID, sent.command) + : ({ + kind: "user", + text: sent.text, + phase: "start", + source: "system", + messageID: sent.messageID, + } as const) input.trace?.write("ui.commit", commit) input.footer.append(commit) } diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index 0a4dcf31c2fa..0745f9979b20 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -21,6 +21,7 @@ import { resolveSessionInfo, } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" +import { commandCommit } from "./command.shared" import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared" import type { LocalReplayRow, @@ -903,13 +904,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep state.shown = true state.history.push({ ...prompt, delivery: undefined }) if (prompt.mode !== "shell" && delivery === "steer") { - rememberLocal({ - kind: "user", - text: prompt.text, - phase: "start", - source: "system", - messageID: prompt.messageID, - }) + rememberLocal( + prompt.command && prompt.command.source !== "skill" + ? commandCommit(prompt.messageID, prompt.command) + : { + kind: "user", + text: prompt.text, + phase: "start", + source: "system", + messageID: prompt.messageID, + }, + ) } }, admit: async (prompt, delivery, signal) => { @@ -1044,9 +1049,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep admitted, ) if (prompt.messageID) { - state.localRows = state.localRows.filter( - (row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID, - ) + state.localRows = state.localRows.filter((row) => row.commit.messageID !== prompt.messageID) } // Shell and skill turns never send CLI file attachments; keep them // pending for the next prompt-shaped turn. diff --git a/packages/tui/src/mini/session.shared.ts b/packages/tui/src/mini/session.shared.ts index 33cbcd2f484c..229aaf23a2ee 100644 --- a/packages/tui/src/mini/session.shared.ts +++ b/packages/tui/src/mini/session.shared.ts @@ -1,6 +1,7 @@ import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise" import { promptCopy, promptSame } from "./prompt.shared" import type { RunInput, RunPrompt } from "./types" +import { commandText } from "../util/command" const LIMIT = 200 @@ -22,7 +23,7 @@ export type RunSession = { function messagePrompt(message: SessionMessageUser): RunPrompt { return { - text: message.text, + text: message.command ? commandText(message.command) : message.text, parts: [ ...(message.files ?? []).map((file) => ({ type: "file" as const, diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 8756bd054570..30ad0a7004e3 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -17,6 +17,8 @@ import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from " import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent" import { normalizeTool, toolOutputText } from "./tool" import { toolDisplayContent } from "../util/tool-display" +import { commandCommit } from "./command.shared" +import { commandText } from "../util/command" import type { FooterApi, FooterView, @@ -186,7 +188,12 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined { if (item.type !== "user") return undefined return { messageID: item.id, - prompt: { messageID: item.id, text: item.payload.text, parts: [] }, + prompt: { + messageID: item.id, + text: item.payload.command ? commandText(item.payload.command) : item.payload.text, + parts: [], + command: item.payload.command, + }, delivery: item.delivery, } } @@ -655,9 +662,22 @@ export async function createSessionTransport(input: StreamInput): Promise skillCommit(message.id, skill.name, skill.id)), + ]) + return + } write([ ...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)), - { kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }, + { + kind: "user", + source: "system", + text: message.text, + phase: "start", + messageID: message.id, + }, ]) return } @@ -947,15 +967,19 @@ export async function createSessionTransport(input: StreamInput): Promise new Map(pendingUsers().map((item) => [item.id, item.delivery]))) const queuedPrompts = createMemo(() => - pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])), + pendingUsers().flatMap((item) => + item.delivery === "queue" + ? [{ id: item.id, text: item.payload.command ? commandText(item.payload.command) : item.payload.text }] + : [], + ), ) const [composer, setComposer] = createStore({ open: false, @@ -2178,7 +2183,29 @@ function UserMessage(props: { message: SessionMessageUser }) { backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default} flexShrink={0} > - {props.message.text} + + {props.message.text} + + + {(command) => ( + + + + {" command "} + + + {` ${commandText(command())} `} + + + + )} + @@ -3612,7 +3639,10 @@ function recordValue(value: unknown): Record | undefined { function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) { const body = messages.flatMap((message) => { - if (message.type === "user") return [`## User\n\n${message.text}`] + if (message.type === "user") + return [ + `## User\n\n${message.command ? commandText(message.command) : message.text}`, + ] if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] diff --git a/packages/tui/src/util/command.ts b/packages/tui/src/util/command.ts new file mode 100644 index 000000000000..4dc9f799b072 --- /dev/null +++ b/packages/tui/src/util/command.ts @@ -0,0 +1,3 @@ +export function commandText(command: { name: string; arguments: string }) { + return `/${command.name}${command.arguments ? ` ${command.arguments}` : ""}` +} diff --git a/packages/tui/test/mini/session.shared.test.ts b/packages/tui/test/mini/session.shared.test.ts index e29a307730af..6c1e3efdb020 100644 --- a/packages/tui/test/mini/session.shared.test.ts +++ b/packages/tui/test/mini/session.shared.test.ts @@ -103,6 +103,16 @@ describe("run session shared", () => { }) }) + test("uses presentation text for command history", () => { + const out = createSession([ + userMessage("msg-user-1", "expanded command template", { + command: { name: "command", arguments: "input" }, + }), + ]) + + expect(out.turns[0]?.prompt.text).toBe("/command input") + }) + test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => { const parts = [ { diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 70381edae489..fad0d42f0ee6 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -667,7 +667,10 @@ describe("V2 mini transport", () => { sessionID: "ses_1", timeCreated: 1, type: "user", - payload: { text: "follow up" }, + payload: { + text: "expanded command template", + command: { name: "command", arguments: "input" }, + }, delivery: "queue", }, { @@ -707,7 +710,7 @@ describe("V2 mini transport", () => { while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0) expect(ui.commits).toContainEqual( - expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }), + expect.objectContaining({ kind: "system", messageID: "msg_queued", text: '→ Command "/command input"' }), ) expect(pending()).toEqual([["msg_cancelled", "queue"]]) events.push({