From 42e447400e387f2748d4b6594a540705bce0f2bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 17:48:41 -0400 Subject: [PATCH 1/4] fix(tui): preserve command display text --- .../components/prompt-input/submit.test.ts | 1 - .../app/src/components/prompt-input/submit.ts | 2 +- packages/app/src/utils/comment-note.ts | 4 +-- packages/app/src/utils/prompt.test.ts | 14 ++++++++ packages/app/src/utils/prompt.ts | 2 +- .../app/src/utils/session-message.test.ts | 12 +++++++ packages/app/src/utils/session-message.ts | 2 +- packages/client/src/effect/api/api.ts | 1 + .../client/src/effect/generated/client.ts | 1 + .../client/src/promise/generated/client.ts | 1 + .../client/src/promise/generated/types.ts | 36 +++++++++++++++++++ packages/core/src/session.ts | 16 +++++---- packages/core/src/session/projector.ts | 20 +++++------ packages/core/test/session-projector.test.ts | 13 +++++-- packages/core/test/session-prompt.test.ts | 4 ++- packages/schema/src/prompt-input.ts | 1 + packages/schema/src/prompt.ts | 4 ++- packages/schema/src/session-message.ts | 1 + packages/server/src/handlers/session.ts | 1 + packages/tui/src/mini/session.shared.ts | 2 +- packages/tui/src/mini/stream-v2.transport.ts | 10 ++++-- packages/tui/src/routes/session/index.tsx | 4 +-- packages/tui/test/mini/session.shared.test.ts | 8 +++++ .../tui/test/mini/stream-v2.transport.test.ts | 4 +-- 24 files changed, 130 insertions(+), 34 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 5397816afcba..94da6fea125f 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -408,7 +408,6 @@ describe("prompt submit worktree selection", () => { files: [], agents: [], metadata: { - displayText: "ls", comments: [], agent: "agent", model: { providerID: "provider", modelID: "model", variant: "high" }, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ecb9c2bfc82f..799ef5047ae5 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -136,10 +136,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { sessionID: input.draft.sessionID, id: messageID, text: request.text, + ...(request.displayText === request.text ? {} : { displayText: request.displayText }), files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), agents: request.agents, metadata: { - displayText: request.displayText, comments: request.comments, agent: input.draft.agent, model: { diff --git a/packages/app/src/utils/comment-note.ts b/packages/app/src/utils/comment-note.ts index 72d47af1b29c..0c05e4908e3d 100644 --- a/packages/app/src/utils/comment-note.ts +++ b/packages/app/src/utils/comment-note.ts @@ -57,9 +57,9 @@ export function readPromptPresentation(value: unknown) { if (!value || typeof value !== "object") return const displayText = (value as { displayText?: unknown }).displayText const comments = (value as { comments?: unknown }).comments - if (typeof displayText !== "string" || !Array.isArray(comments)) return + if (!Array.isArray(comments)) return return { - displayText, + displayText: typeof displayText === "string" ? displayText : undefined, comments: comments.flatMap((item): PromptComment[] => { if (!item || typeof item !== "object") return [] const path = (item as { path?: unknown }).path diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index b6e61a6508e1..f96c4bc4dd8d 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -105,4 +105,18 @@ describe("extractPromptFromMessage", () => { expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" }) }) + + test("restores explicit presentation text", () => { + const message = { + id: "msg_1", + type: "user", + text: "expanded command template", + displayText: "/command input", + metadata: { comments: [{ path: "src/app.ts", comment: "check this" }] }, + time: { created: 1 }, + } satisfies SessionMessageUser + + expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" }) + expect(extractPromptComments(message)).toEqual([{ path: "src/app.ts", comment: "check this" }]) + }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index f7d4d2b66f8b..d7f9f4db5ccb 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -44,7 +44,7 @@ export function extractPromptFromMessage( message: SessionMessageUser, opts?: { directory?: string; attachmentName?: string }, ): Prompt { - const text = readPromptPresentation(message.metadata)?.displayText ?? message.text + const text = message.displayText ?? 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..611996eb3078 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 explicit presentation text", () => { + const message = { + id: "msg_user", + type: "user", + text: "expanded command template", + displayText: "/command 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..8243bb611406 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -57,7 +57,7 @@ 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.displayText ?? presentation?.displayText ?? message.text return [ ...(text ? [textPart(sessionID, message.id, 0, text)] : []), ...(message.files ?? []).map( diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index dbcc071fc2cb..a3346e70fa5a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -172,6 +172,7 @@ export type Endpoint5_12Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string + readonly displayText?: string | undefined readonly files?: ReadonlyArray | undefined readonly agents?: ReadonlyArray | undefined readonly skills?: ReadonlyArray | undefined diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index dccea27fa090..32ca8a312853 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -397,6 +397,7 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I payload: { id: input["id"], text: input["text"], + displayText: input["displayText"], files: input["files"], agents: input["agents"], skills: input["skills"], diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3d5b02b21e03..5954e2ad23f0 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -593,6 +593,7 @@ export function make(options: ClientOptions) { body: { id: input["id"], text: input["text"], + displayText: input["displayText"], files: input["files"], agents: input["agents"], skills: input["skills"], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0d54ac34f990..78aadfc617e9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1684,6 +1684,7 @@ export type SessionMessageUser = { metadata?: { [x: string]: JsonValue } time: { created: number } text: string + displayText?: string files?: Array agents?: Array skills?: Array @@ -1692,6 +1693,7 @@ export type SessionMessageUser = { export type SessionInboxUserPayload = { text: string + displayText?: string files?: Array agents?: Array skills?: Array @@ -1700,6 +1702,7 @@ export type SessionInboxUserPayload = { export type SessionInboxUserPayload1 = { text: string + displayText?: string files?: Array agents?: Array skills?: Array @@ -2552,6 +2555,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -2821,6 +2825,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -3090,6 +3095,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -3365,6 +3371,7 @@ export type SessionPromptInput = { readonly id?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3386,6 +3393,7 @@ export type SessionPromptInput = { readonly text: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3404,9 +3412,32 @@ export type SessionPromptInput = { readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null }["text"] + readonly displayText?: { + readonly id?: string | null + readonly text: string + readonly displayText?: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly skills?: ReadonlyArray<{ + readonly id: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: ("steer" | "queue") | null + readonly resume?: boolean | null + }["displayText"] readonly files?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3428,6 +3459,7 @@ export type SessionPromptInput = { readonly agents?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3449,6 +3481,7 @@ export type SessionPromptInput = { readonly skills?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3470,6 +3503,7 @@ export type SessionPromptInput = { readonly metadata?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3491,6 +3525,7 @@ export type SessionPromptInput = { readonly delivery?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3512,6 +3547,7 @@ export type SessionPromptInput = { readonly resume?: { readonly id?: string | null readonly text: string + readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 9fcaa94b7f01..4e8e56b8a794 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 + displayText?: string 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, + displayText: `/${input.command}${input.arguments ? ` ${input.arguments}` : ""}`, files: input.files, agents: input.agents, skills: input.skills, @@ -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.make({ + text: input.text, + displayText: input.displayText === input.text ? undefined : input.displayText, + 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..7c3be1dc9a22 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", displayText: "/command input" }, + delivery: "steer", + }, }) if (!admitted) return yield* Effect.die("Prompt admission failed") @@ -337,7 +341,12 @@ 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", displayText: "/command input" }, + }) }), ) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b55b7ee5c511..0ba3981e6f30 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", + displayText: "/fix tests", resume: false, }) expect(message.payload.text).toBe("Fix the failing tests") + expect(message.payload.displayText).toBe("/fix 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", displayText: "/fix tests" }, delivery: "steer", }) }), diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts index c25adbf2daef..99943bba4759 100644 --- a/packages/schema/src/prompt-input.ts +++ b/packages/schema/src/prompt-input.ts @@ -28,6 +28,7 @@ export const SkillAttachment = Schema.Struct({ export const Prompt = Schema.Struct({ text: Schema.String, + displayText: Schema.String.pipe(optional), files: Schema.Array(FileAttachment).pipe(optional), agents: Schema.Array(AgentAttachment).pipe(optional), skills: Schema.Array(SkillAttachment).pipe(optional), diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index 19a05449b4d8..e547b9fd3d03 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -64,6 +64,7 @@ export const SkillAttachment = Schema.Struct({ export interface Prompt extends Schema.Schema.Type {} export const Prompt = Schema.Struct({ text: Schema.String, + displayText: Schema.String.pipe(optional), files: Schema.Array(FileAttachment).pipe(optional), agents: Schema.Array(AgentAttachment).pipe(optional), skills: Schema.Array(SkillAttachment).pipe(optional), @@ -72,9 +73,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.displayText === undefined ? {} : { displayText: input.displayText }), ...(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..a3cc40734b8b 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -73,6 +73,7 @@ export interface User extends Schema.Schema.Type {} export const User = Schema.Struct({ ...Base, text: Prompt.fields.text, + displayText: Prompt.fields.displayText, files: Prompt.fields.files, agents: Prompt.fields.agents, skills: Prompt.fields.skills, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e5e71eef3125..d4038854f356 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -328,6 +328,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl sessionID: ctx.params.sessionID, id: ctx.payload.id, text: ctx.payload.text, + displayText: ctx.payload.displayText, files: ctx.payload.files, agents: ctx.payload.agents, skills: ctx.payload.skills, diff --git a/packages/tui/src/mini/session.shared.ts b/packages/tui/src/mini/session.shared.ts index 33cbcd2f484c..e53eb4627a9f 100644 --- a/packages/tui/src/mini/session.shared.ts +++ b/packages/tui/src/mini/session.shared.ts @@ -22,7 +22,7 @@ export type RunSession = { function messagePrompt(message: SessionMessageUser): RunPrompt { return { - text: message.text, + text: message.displayText ?? 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..52fae27d600b 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -186,7 +186,7 @@ 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.displayText ?? item.payload.text, parts: [] }, delivery: item.delivery, } } @@ -657,7 +657,13 @@ export async function createSessionTransport(input: StreamInput): Promise 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.displayText ?? message.text, + phase: "start", + messageID: message.id, + }, ]) return } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 357df25780d6..c1f58833e9ec 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2178,7 +2178,7 @@ function UserMessage(props: { message: SessionMessageUser }) { backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default} flexShrink={0} > - {props.message.text} + {props.message.displayText ?? props.message.text} @@ -3612,7 +3612,7 @@ 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.displayText ?? 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/test/mini/session.shared.test.ts b/packages/tui/test/mini/session.shared.test.ts index e29a307730af..287c7d9931ef 100644 --- a/packages/tui/test/mini/session.shared.test.ts +++ b/packages/tui/test/mini/session.shared.test.ts @@ -103,6 +103,14 @@ describe("run session shared", () => { }) }) + test("uses presentation text for command history", () => { + const out = createSession([ + userMessage("msg-user-1", "expanded command template", { displayText: "/command 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..3f7dc1eee4d2 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -667,7 +667,7 @@ describe("V2 mini transport", () => { sessionID: "ses_1", timeCreated: 1, type: "user", - payload: { text: "follow up" }, + payload: { text: "expanded command template", displayText: "/command input" }, delivery: "queue", }, { @@ -707,7 +707,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: "user", messageID: "msg_queued", text: "/command input" }), ) expect(pending()).toEqual([["msg_cancelled", "queue"]]) events.push({ From f5a37698673cc7c9dfd86b5cf3bfcc7d34d0eb75 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:04:18 -0400 Subject: [PATCH 2/4] fix(tui): render commands as attachments --- .../components/prompt-input/submit.test.ts | 1 + .../app/src/components/prompt-input/submit.ts | 2 +- packages/app/src/utils/comment-note.ts | 4 +- packages/app/src/utils/prompt.test.ts | 6 +- packages/app/src/utils/prompt.ts | 4 +- .../app/src/utils/session-message.test.ts | 4 +- packages/app/src/utils/session-message.ts | 4 +- packages/client/src/effect/api/api.ts | 1 - .../client/src/effect/generated/client.ts | 1 - .../client/src/promise/generated/client.ts | 1 - .../client/src/promise/generated/types.ts | 44 +++---------- packages/core/src/session.ts | 10 +-- packages/core/test/session-projector.test.ts | 7 ++- packages/core/test/session-prompt.test.ts | 6 +- packages/schema/src/prompt-input.ts | 1 - packages/schema/src/prompt.ts | 12 +++- packages/schema/src/session-message.ts | 6 +- packages/server/src/handlers/session.ts | 1 - packages/tui/src/mini/command.shared.ts | 13 ++++ packages/tui/src/mini/runtime.queue.ts | 18 +++--- packages/tui/src/mini/runtime.ts | 23 ++++--- packages/tui/src/mini/session.shared.ts | 3 +- packages/tui/src/mini/stream-v2.transport.ts | 62 +++++++++++++------ packages/tui/src/routes/session/index.tsx | 36 ++++++++++- packages/tui/src/util/command.ts | 3 + packages/tui/test/mini/session.shared.test.ts | 4 +- .../tui/test/mini/stream-v2.transport.test.ts | 7 ++- 27 files changed, 170 insertions(+), 114 deletions(-) create mode 100644 packages/tui/src/mini/command.shared.ts create mode 100644 packages/tui/src/util/command.ts diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 94da6fea125f..5397816afcba 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -408,6 +408,7 @@ describe("prompt submit worktree selection", () => { files: [], agents: [], metadata: { + displayText: "ls", comments: [], agent: "agent", model: { providerID: "provider", modelID: "model", variant: "high" }, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 799ef5047ae5..ecb9c2bfc82f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -136,10 +136,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { sessionID: input.draft.sessionID, id: messageID, text: request.text, - ...(request.displayText === request.text ? {} : { displayText: request.displayText }), files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), agents: request.agents, metadata: { + displayText: request.displayText, comments: request.comments, agent: input.draft.agent, model: { diff --git a/packages/app/src/utils/comment-note.ts b/packages/app/src/utils/comment-note.ts index 0c05e4908e3d..72d47af1b29c 100644 --- a/packages/app/src/utils/comment-note.ts +++ b/packages/app/src/utils/comment-note.ts @@ -57,9 +57,9 @@ export function readPromptPresentation(value: unknown) { if (!value || typeof value !== "object") return const displayText = (value as { displayText?: unknown }).displayText const comments = (value as { comments?: unknown }).comments - if (!Array.isArray(comments)) return + if (typeof displayText !== "string" || !Array.isArray(comments)) return return { - displayText: typeof displayText === "string" ? displayText : undefined, + displayText, comments: comments.flatMap((item): PromptComment[] => { if (!item || typeof item !== "object") return [] const path = (item as { path?: unknown }).path diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index f96c4bc4dd8d..ce281aa4d2a8 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -106,17 +106,15 @@ describe("extractPromptFromMessage", () => { expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" }) }) - test("restores explicit presentation text", () => { + test("restores command invocation text", () => { const message = { id: "msg_1", type: "user", text: "expanded command template", - displayText: "/command input", - metadata: { comments: [{ path: "src/app.ts", comment: "check this" }] }, + command: { name: "command", arguments: "input" }, time: { created: 1 }, } satisfies SessionMessageUser expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" }) - expect(extractPromptComments(message)).toEqual([{ path: "src/app.ts", comment: "check this" }]) }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index d7f9f4db5ccb..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 = message.displayText ?? 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 611996eb3078..2e5930641cff 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -50,12 +50,12 @@ describe("session message presentation", () => { }) }) - test("projects explicit presentation text", () => { + test("projects command invocation text", () => { const message = { id: "msg_user", type: "user", text: "expanded command template", - displayText: "/command input", + command: { name: "command", arguments: "input" }, time: { created: 1 }, } satisfies SessionMessageUser diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index 8243bb611406..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 = message.displayText ?? 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/effect/api/api.ts b/packages/client/src/effect/api/api.ts index a3346e70fa5a..dbcc071fc2cb 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -172,7 +172,6 @@ export type Endpoint5_12Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string - readonly displayText?: string | undefined readonly files?: ReadonlyArray | undefined readonly agents?: ReadonlyArray | undefined readonly skills?: ReadonlyArray | undefined diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 32ca8a312853..dccea27fa090 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -397,7 +397,6 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I payload: { id: input["id"], text: input["text"], - displayText: input["displayText"], files: input["files"], agents: input["agents"], skills: input["skills"], diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 5954e2ad23f0..3d5b02b21e03 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -593,7 +593,6 @@ export function make(options: ClientOptions) { body: { id: input["id"], text: input["text"], - displayText: input["displayText"], files: input["files"], agents: input["agents"], skills: input["skills"], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 78aadfc617e9..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,7 +1686,7 @@ export type SessionMessageUser = { metadata?: { [x: string]: JsonValue } time: { created: number } text: string - displayText?: string + command?: PromptCommandInvocation files?: Array agents?: Array skills?: Array @@ -1693,7 +1695,7 @@ export type SessionMessageUser = { export type SessionInboxUserPayload = { text: string - displayText?: string + command?: PromptCommandInvocation files?: Array agents?: Array skills?: Array @@ -1702,7 +1704,7 @@ export type SessionInboxUserPayload = { export type SessionInboxUserPayload1 = { text: string - displayText?: string + command?: PromptCommandInvocation files?: Array agents?: Array skills?: Array @@ -2555,7 +2557,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string - readonly displayText?: string + readonly command?: { readonly name: string; readonly arguments: string } readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -2825,7 +2827,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string - readonly displayText?: string + readonly command?: { readonly name: string; readonly arguments: string } readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -3095,7 +3097,7 @@ export type SessionImportInput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string - readonly displayText?: string + readonly command?: { readonly name: string; readonly arguments: string } readonly files?: ReadonlyArray<{ readonly data: string readonly mime: string @@ -3371,7 +3373,6 @@ export type SessionPromptInput = { readonly id?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3393,7 +3394,6 @@ export type SessionPromptInput = { readonly text: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3412,32 +3412,9 @@ export type SessionPromptInput = { readonly delivery?: ("steer" | "queue") | null readonly resume?: boolean | null }["text"] - readonly displayText?: { - readonly id?: string | null - readonly text: string - readonly displayText?: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly skills?: ReadonlyArray<{ - readonly id: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly metadata?: { readonly [x: string]: JsonValue } - readonly delivery?: ("steer" | "queue") | null - readonly resume?: boolean | null - }["displayText"] readonly files?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3459,7 +3436,6 @@ export type SessionPromptInput = { readonly agents?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3481,7 +3457,6 @@ export type SessionPromptInput = { readonly skills?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3503,7 +3478,6 @@ export type SessionPromptInput = { readonly metadata?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3525,7 +3499,6 @@ export type SessionPromptInput = { readonly delivery?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string @@ -3547,7 +3520,6 @@ export type SessionPromptInput = { readonly resume?: { readonly id?: string | null readonly text: string - readonly displayText?: string readonly files?: ReadonlyArray<{ readonly uri: string readonly name?: string diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4e8e56b8a794..a963d4152190 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -222,7 +222,7 @@ export interface Interface { id?: SessionMessage.ID sessionID: SessionSchema.ID text: string - displayText?: string + command?: Prompt["command"] files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] skills?: PromptInput.Prompt["skills"] @@ -654,7 +654,7 @@ const layer = Layer.effect( id: input.id, sessionID: input.sessionID, text: evaluated.text, - displayText: `/${input.command}${input.arguments ? ` ${input.arguments}` : ""}`, + command: { name: input.command, arguments: input.arguments ?? "" }, files: input.files, agents: input.agents, skills: input.skills, @@ -962,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, ) { @@ -985,9 +985,9 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* ( }) }) }) - return Prompt.make({ + return Prompt.fromUserMessage({ text: input.text, - displayText: input.displayText === input.text ? undefined : input.displayText, + command: input.command, agents: input.agents, files, skills: selected?.length ? selected : undefined, diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 7c3be1dc9a22..4ac606f451dd 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -325,7 +325,7 @@ describe("SessionProjector", () => { sessionID, item: { type: "user", - payload: { text: "expanded command template", displayText: "/command input" }, + payload: { text: "expanded command template", command: { name: "command", arguments: "input" } }, delivery: "steer", }, }) @@ -345,7 +345,10 @@ describe("SessionProjector", () => { session_id: sessionID, type: "user", seq: event.durable?.seq, - data: { text: "expanded command template", displayText: "/command input" }, + 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 0ba3981e6f30..1e2ff06bd7c9 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -235,18 +235,18 @@ describe("Session.prompt", () => { const message = yield* session.prompt({ sessionID, text: "Fix the failing tests", - displayText: "/fix tests", + command: { name: "fix", arguments: "tests" }, resume: false, }) expect(message.payload.text).toBe("Fix the failing tests") - expect(message.payload.displayText).toBe("/fix 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", displayText: "/fix tests" }, + payload: { text: "Fix the failing tests", command: { name: "fix", arguments: "tests" } }, delivery: "steer", }) }), diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts index 99943bba4759..c25adbf2daef 100644 --- a/packages/schema/src/prompt-input.ts +++ b/packages/schema/src/prompt-input.ts @@ -28,7 +28,6 @@ export const SkillAttachment = Schema.Struct({ export const Prompt = Schema.Struct({ text: Schema.String, - displayText: Schema.String.pipe(optional), files: Schema.Array(FileAttachment).pipe(optional), agents: Schema.Array(AgentAttachment).pipe(optional), skills: Schema.Array(SkillAttachment).pipe(optional), diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index e547b9fd3d03..3d8e216cf2ed 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -61,10 +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, - displayText: Schema.String.pipe(optional), + command: CommandInvocation.pipe(optional), files: Schema.Array(FileAttachment).pipe(optional), agents: Schema.Array(AgentAttachment).pipe(optional), skills: Schema.Array(SkillAttachment).pipe(optional), @@ -73,10 +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.displayText === undefined ? {} : { displayText: input.displayText }), + ...(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 a3cc40734b8b..a70c12e95674 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -72,11 +72,7 @@ export const LocationSwitched = Schema.Struct({ export interface User extends Schema.Schema.Type {} export const User = Schema.Struct({ ...Base, - text: Prompt.fields.text, - displayText: Prompt.fields.displayText, - 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/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index d4038854f356..e5e71eef3125 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -328,7 +328,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl sessionID: ctx.params.sessionID, id: ctx.payload.id, text: ctx.payload.text, - displayText: ctx.payload.displayText, files: ctx.payload.files, agents: ctx.payload.agents, skills: ctx.payload.skills, 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 e53eb4627a9f..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.displayText ?? 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 52fae27d600b..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.displayText ?? 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,12 +662,19 @@ 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.displayText ?? message.text, + text: message.text, phase: "start", messageID: message.id, }, @@ -953,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.displayText ?? 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.displayText ?? 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 287c7d9931ef..6c1e3efdb020 100644 --- a/packages/tui/test/mini/session.shared.test.ts +++ b/packages/tui/test/mini/session.shared.test.ts @@ -105,7 +105,9 @@ describe("run session shared", () => { test("uses presentation text for command history", () => { const out = createSession([ - userMessage("msg-user-1", "expanded command template", { displayText: "/command input" }), + userMessage("msg-user-1", "expanded command template", { + command: { name: "command", arguments: "input" }, + }), ]) expect(out.turns[0]?.prompt.text).toBe("/command input") diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 3f7dc1eee4d2..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: "expanded command template", displayText: "/command input" }, + 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: "/command input" }), + expect.objectContaining({ kind: "system", messageID: "msg_queued", text: '→ Command "/command input"' }), ) expect(pending()).toEqual([["msg_cancelled", "queue"]]) events.push({ From 5f8f439b005cafea3d5f868f4b08da600e113af3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:10:17 -0400 Subject: [PATCH 3/4] fix(tui): collapse command-only messages --- packages/tui/src/routes/session/index.tsx | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b6b023f6da42..4956fc1ad0e6 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1556,10 +1556,20 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { } function SessionMessageView(props: { message: SessionMessageInfo }) { + const ctx = use() + const commandOnly = () => { + if (props.message.type !== "user" || !props.message.command) return false + return !props.message.files?.length && !props.message.skills?.length && !ctx.pendingDelivery(props.message.id) + } return ( - + } + > + + } /> @@ -1933,6 +1943,19 @@ function SessionSkillMessage(props: { message: Extract + {(command) => ( + + Command {commandText(command())} + + )} + + ) +} + function CompactionMessage(props: { message: Extract }) { const ctx = use() const theme = useTheme() From 530af4394f3812c3d0c2d541cfc3d02fff834870 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 18:14:31 -0400 Subject: [PATCH 4/4] fix(tui): match skill-only attachments --- packages/tui/src/routes/session/index.tsx | 25 +---------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4956fc1ad0e6..b6b023f6da42 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1556,20 +1556,10 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { } function SessionMessageView(props: { message: SessionMessageInfo }) { - const ctx = use() - const commandOnly = () => { - if (props.message.type !== "user" || !props.message.command) return false - return !props.message.files?.length && !props.message.skills?.length && !ctx.pendingDelivery(props.message.id) - } return ( - } - > - - + } /> @@ -1943,19 +1933,6 @@ function SessionSkillMessage(props: { message: Extract - {(command) => ( - - Command {commandText(command())} - - )} - - ) -} - function CompactionMessage(props: { message: Extract }) { const ctx = use() const theme = useTheme()