From fbce48f59766de6312ac7a6c58bca7997f5f114e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 15:33:40 +0530 Subject: [PATCH 1/7] fix(session): restore compact slash arguments --- .../components/prompt-input/submit.test.ts | 36 ++++++++++- .../app/src/components/prompt-input/submit.ts | 61 +++++++++++++++++-- .../cli/cmd/tui/component/prompt/index.tsx | 28 +++++++++ .../routes/instance/httpapi/groups/session.ts | 1 + .../instance/httpapi/handlers/session.ts | 1 + packages/opencode/src/session/compaction.ts | 14 ++++- packages/opencode/src/session/message-v2.ts | 1 + .../opencode/test/session/compaction.test.ts | 28 ++++++++- 8 files changed, 160 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 83b6212dcc..d99516e591 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -18,6 +18,7 @@ const optimistic: Array<{ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] +const sentSummaries: Array<{ sessionID: string; instructions?: string }> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] @@ -25,7 +26,7 @@ let params: { id?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined -const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const clientFor = (directory: string) => { createdClients.push(directory) @@ -47,6 +48,10 @@ const clientFor = (directory: string) => { prompt: async () => ({ data: undefined }), promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), + summarize: async (input: { sessionID: string; instructions?: string }) => { + sentSummaries.push({ sessionID: input.sessionID, instructions: input.instructions }) + return { data: undefined } + }, abort: async () => ({ data: undefined }), }, worktree: { @@ -208,6 +213,8 @@ beforeEach(() => { optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] + sentSummaries.length = 0 params = {} sentShell.length = 0 syncedDirectories.length = 0 @@ -342,4 +349,31 @@ describe("prompt submit worktree selection", () => { expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) + + test("submits typed compact slash arguments as summarize instructions", async () => { + params = { id: "session-1" } + promptValue = [{ type: "text", content: "/compact keep unresolved TODOs", start: 0, end: 30 }] + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(sentSummaries).toEqual([{ sessionID: "session-1", instructions: "keep unresolved TODOs" }]) + expect(optimistic).toHaveLength(0) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05f0a3ed2c..d127fccbe0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -50,6 +50,21 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") +const slashCommand = (text: string) => { + if (!text.startsWith("/")) return undefined + return text.split("\n")[0].split(" ")[0].slice(1) +} + +const slashArguments = (text: string) => { + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") +} + +const isCompactSlash = (command: string | undefined) => command === "compact" || command === "summarize" + export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) @@ -71,8 +86,27 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return true } - const [head, ...tail] = text.split(" ") - const cmd = head?.startsWith("/") ? head.slice(1) : undefined + const cmd = slashCommand(text) + if (isCompactSlash(cmd)) { + setBusy() + try { + if (!(await wait())) { + setIdle() + return false + } + + const instructions = slashArguments(text).trim() + const payload = instructions + ? { sessionID: input.draft.sessionID, ...input.draft.model, instructions } + : { sessionID: input.draft.sessionID, ...input.draft.model } + await input.client.session.summarize(payload) + return true + } catch (err) { + setIdle() + throw err + } + } + if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { setBusy() try { @@ -84,7 +118,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { await input.client.session.command({ sessionID: input.draft.sessionID, command: cmd, - arguments: tail.join(" "), + arguments: slashArguments(text), agent: input.draft.agent, model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, variant: input.draft.variant, @@ -453,8 +487,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) + const commandName = slashCommand(text) ?? "" const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() @@ -462,7 +495,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .command({ sessionID: session.id, command: commandName, - arguments: args.join(" "), + arguments: slashArguments(text), agent, model: `${model.providerID}/${model.modelID}`, variant, @@ -483,6 +516,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) return } + + if (isCompactSlash(commandName)) { + clearInput() + const instructions = slashArguments(text).trim() + const payload = instructions + ? { sessionID: session.id, ...model, instructions } + : { sessionID: session.id, ...model } + client.session.summarize(payload).catch((err) => { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } } const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) 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 f4b11fa465..b7f911ecbd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1143,6 +1143,21 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if (isCompactSlash(inputText)) { + const instructions = slashArguments(inputText).trim() + const payload = instructions + ? { + sessionID, + modelID: selectedModel.modelID, + providerID: selectedModel.providerID, + instructions, + } + : { + sessionID, + modelID: selectedModel.modelID, + providerID: selectedModel.providerID, + } + void sdk.client.session.summarize(payload) } else if ( inputText.startsWith("/") && iife(() => { @@ -1220,6 +1235,19 @@ export function Prompt(props: PromptProps) { input.clear() return true } + + function isCompactSlash(text: string) { + const command = text.split("\n")[0].split(" ")[0].slice(1) + return text.startsWith("/") && (command === "compact" || command === "summarize") + } + + function slashArguments(text: string) { + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + } const exit = useExit() function pasteText(text: string, virtualText: string) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index b8c8a142be..314aaba6b2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -62,6 +62,7 @@ export const SummarizePayload = Schema.Struct({ providerID: ProviderID, modelID: ModelID, auto: Schema.optional(Schema.Boolean), + instructions: Schema.optional(Schema.String), }) export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 2e3617d146..8096dcb470 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -277,6 +277,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", modelID: ctx.payload.modelID, }, auto: ctx.payload.auto ?? false, + instructions: ctx.payload.instructions, }) yield* promptSvc.loop({ sessionID: ctx.params.sessionID }) return true diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ef007fe74d..ae5382e7b9 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -133,6 +133,12 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) { return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") } +function withInstructions(prompt: string, instructions: string | undefined) { + const trimmed = instructions?.trim() + if (!trimmed) return prompt + return [prompt, "Additional user instructions for this compaction:", trimmed].join("\n\n") +} + function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -202,6 +208,7 @@ export interface Interface { model: { providerID: ProviderID; modelID: ModelID } auto: boolean overflow?: boolean + instructions?: string }) => Effect.Effect } @@ -400,7 +407,10 @@ export const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const nextPrompt = withInstructions( + compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }), + compactionPart?.instructions, + ) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -587,6 +597,7 @@ export const layer = Layer.effect( model: { providerID: ProviderID; modelID: ModelID } auto: boolean overflow?: boolean + instructions?: string }) { const msg = yield* session.updateMessage({ id: MessageID.ascending(), @@ -603,6 +614,7 @@ export const layer = Layer.effect( type: "compaction", auto: input.auto, overflow: input.overflow, + instructions: input.instructions, }) if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2745ff4f45..827d761967 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -186,6 +186,7 @@ export const CompactionPart = Schema.Struct({ type: Schema.Literal("compaction"), auto: Schema.Boolean, overflow: Schema.optional(Schema.Boolean), + instructions: Schema.optional(Schema.String), tail_start_id: Schema.optional(MessageID), }).annotate({ identifier: "CompactionPart" }) export type CompactionPart = Types.DeepMutable> diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 55ddc621ca..310712df4a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -287,8 +287,8 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { ) } -function createSummaryCompaction(sessionID: SessionID) { - return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) +function createSummaryCompaction(sessionID: SessionID, instructions?: string) { + return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false, instructions }) } function readCompactionPart(sessionID: SessionID) { @@ -959,6 +959,30 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) + itCompaction.instance( + "appends stored instructions to compaction prompt", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createSummaryCompaction(session.id, "focus on unresolved TODOs") + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toContain("Additional user instructions for this compaction") + expect(captured).toContain("focus on unresolved TODOs") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + itCompaction.instance( "falls back to full summary when even one recent turn exceeds preserve token budget", () => { From 25f92bba8d8f2ea8b84b75494be0461bf6f252b3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 15:45:20 +0530 Subject: [PATCH 2/7] fix(tui): keep compact slash editable --- .../components/prompt-input/submit.test.ts | 35 +---------- .../app/src/components/prompt-input/submit.ts | 61 ++----------------- .../cmd/tui/component/prompt/autocomplete.tsx | 15 ++++- 3 files changed, 21 insertions(+), 90 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index d99516e591..50168562f0 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -18,7 +18,6 @@ const optimistic: Array<{ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] -const sentSummaries: Array<{ sessionID: string; instructions?: string }> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] @@ -26,7 +25,7 @@ let params: { id?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined -let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const clientFor = (directory: string) => { createdClients.push(directory) @@ -48,10 +47,6 @@ const clientFor = (directory: string) => { prompt: async () => ({ data: undefined }), promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), - summarize: async (input: { sessionID: string; instructions?: string }) => { - sentSummaries.push({ sessionID: input.sessionID, instructions: input.instructions }) - return { data: undefined } - }, abort: async () => ({ data: undefined }), }, worktree: { @@ -213,8 +208,6 @@ beforeEach(() => { optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 - promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] - sentSummaries.length = 0 params = {} sentShell.length = 0 syncedDirectories.length = 0 @@ -350,30 +343,4 @@ describe("prompt submit worktree selection", () => { expect(optimisticSeeded).toEqual([true]) }) - test("submits typed compact slash arguments as summarize instructions", async () => { - params = { id: "session-1" } - promptValue = [{ type: "text", content: "/compact keep unresolved TODOs", start: 0, end: 30 }] - - const submit = createPromptSubmit({ - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(sentSummaries).toEqual([{ sessionID: "session-1", instructions: "keep unresolved TODOs" }]) - expect(optimistic).toHaveLength(0) - }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index d127fccbe0..f870117c87 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -50,21 +50,6 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") -const slashCommand = (text: string) => { - if (!text.startsWith("/")) return undefined - return text.split("\n")[0].split(" ")[0].slice(1) -} - -const slashArguments = (text: string) => { - const firstLineEnd = text.indexOf("\n") - const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) - const [, ...firstLineArgs] = firstLine.split(" ") - const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) - return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") -} - -const isCompactSlash = (command: string | undefined) => command === "compact" || command === "summarize" - export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) @@ -86,27 +71,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return true } - const cmd = slashCommand(text) - if (isCompactSlash(cmd)) { - setBusy() - try { - if (!(await wait())) { - setIdle() - return false - } - - const instructions = slashArguments(text).trim() - const payload = instructions - ? { sessionID: input.draft.sessionID, ...input.draft.model, instructions } - : { sessionID: input.draft.sessionID, ...input.draft.model } - await input.client.session.summarize(payload) - return true - } catch (err) { - setIdle() - throw err - } - } - + const [head, ...tail] = text.split(" ") + const cmd = head?.startsWith("/") ? head.slice(1) : undefined if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { setBusy() try { @@ -118,7 +84,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { await input.client.session.command({ sessionID: input.draft.sessionID, command: cmd, - arguments: slashArguments(text), + arguments: tail.join(" "), agent: input.draft.agent, model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, variant: input.draft.variant, @@ -487,7 +453,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (text.startsWith("/")) { - const commandName = slashCommand(text) ?? "" + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() @@ -495,7 +462,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .command({ sessionID: session.id, command: commandName, - arguments: slashArguments(text), + arguments: args.join(" "), agent, model: `${model.providerID}/${model.modelID}`, variant, @@ -513,22 +480,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: formatServerError(err, language.t, language.t("common.requestFailed")), }) restoreInput() - }) - return - } - - if (isCompactSlash(commandName)) { - clearInput() - const instructions = slashArguments(text).trim() - const payload = instructions - ? { sessionID: session.id, ...model, instructions } - : { sessionID: session.id, ...model } - client.session.summarize(payload).catch((err) => { - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() }) return } 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 2bda73cff7..88d3e58938 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -544,7 +544,20 @@ export function Autocomplete(props: { ) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...slashes()] + const results: AutocompleteOption[] = slashes().map((command) => { + const name = command.display.trim().slice(1) + if (name !== "compact") return command + return { + ...command, + onSelect: () => { + const newText = `/${name} ` + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + }, + } + }) for (const serverCommand of sync.data.command) { if (serverCommand.source === "skill") continue From cfcc3d973b5a9ba085813cce2e47d0e81f41a0e2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 15:45:54 +0530 Subject: [PATCH 3/7] chore(app): remove compact slash changes --- packages/app/src/components/prompt-input/submit.test.ts | 1 - packages/app/src/components/prompt-input/submit.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 50168562f0..83b6212dcc 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -342,5 +342,4 @@ describe("prompt submit worktree selection", () => { expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) - }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index f870117c87..05f0a3ed2c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -480,7 +480,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: formatServerError(err, language.t, language.t("common.requestFailed")), }) restoreInput() - }) + }) return } } From 4f191187b3a0e14fcd5b926114c9aae3af77a0f2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 15:58:02 +0530 Subject: [PATCH 4/7] fix(tui): send compact instructions in request body --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b7f911ecbd..b991c15db2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1145,12 +1145,12 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if (isCompactSlash(inputText)) { const instructions = slashArguments(inputText).trim() - const payload = instructions + const payload: Parameters[0] & { $body_instructions?: string } = instructions ? { sessionID, modelID: selectedModel.modelID, providerID: selectedModel.providerID, - instructions, + $body_instructions: instructions, } : { sessionID, From b5c6763d115fceb880dbae17f11ba518f7db9894 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 16:33:59 +0530 Subject: [PATCH 5/7] refactor(tui): simplify compact slash handling --- .../cmd/tui/component/prompt/autocomplete.tsx | 24 ++++++++----------- .../cli/cmd/tui/component/prompt/index.tsx | 18 +++++--------- packages/opencode/src/session/compaction.ts | 4 ++-- 3 files changed, 18 insertions(+), 28 deletions(-) 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 88d3e58938..fad7f84984 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -543,19 +543,21 @@ export function Autocomplete(props: { ), ) + function insertSlashCommand(name: string) { + const newText = `/${name} ` + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + } + const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = slashes().map((command) => { const name = command.display.trim().slice(1) if (name !== "compact") return command return { ...command, - onSelect: () => { - const newText = `/${name} ` - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlashCommand(name), } }) @@ -565,13 +567,7 @@ export function Autocomplete(props: { results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, - onSelect: () => { - const newText = "/" + serverCommand.name + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlashCommand(serverCommand.name), }) } 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 b991c15db2..9764439ec0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1145,18 +1145,12 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if (isCompactSlash(inputText)) { const instructions = slashArguments(inputText).trim() - const payload: Parameters[0] & { $body_instructions?: string } = instructions - ? { - sessionID, - modelID: selectedModel.modelID, - providerID: selectedModel.providerID, - $body_instructions: instructions, - } - : { - sessionID, - modelID: selectedModel.modelID, - providerID: selectedModel.providerID, - } + const payload: Parameters[0] & { $body_instructions?: string } = { + sessionID, + modelID: selectedModel.modelID, + providerID: selectedModel.providerID, + ...(instructions ? { $body_instructions: instructions } : {}), + } void sdk.client.session.summarize(payload) } else if ( inputText.startsWith("/") && diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ae5382e7b9..b6028e8770 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -133,7 +133,7 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) { return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") } -function withInstructions(prompt: string, instructions: string | undefined) { +function appendInstructions(prompt: string, instructions: string | undefined) { const trimmed = instructions?.trim() if (!trimmed) return prompt return [prompt, "Additional user instructions for this compaction:", trimmed].join("\n\n") @@ -407,7 +407,7 @@ export const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = withInstructions( + const nextPrompt = appendInstructions( compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }), compactionPart?.instructions, ) From c7a17aea4b807d037bf317e5a1c34292ac7c8c5a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 17:12:57 +0530 Subject: [PATCH 6/7] refactor(tui): model editable slash commands --- .../cmd/tui/component/prompt/autocomplete.tsx | 5 +-- .../cli/cmd/tui/component/prompt/index.tsx | 45 +++++++------------ packages/opencode/src/cli/cmd/tui/keymap.tsx | 4 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 2 + 4 files changed, 24 insertions(+), 32 deletions(-) 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 fad7f84984..476b52a5e0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -553,11 +553,10 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = slashes().map((command) => { - const name = command.display.trim().slice(1) - if (name !== "compact") return command + if (!command.input) return command return { ...command, - onSelect: () => insertSlashCommand(name), + onSelect: () => insertSlashCommand(command.name), } }) 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 9764439ec0..05caf1482b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1132,6 +1132,8 @@ export function Prompt(props: PromptProps) { ] : [] + const slash = parseSlashCommand(inputText) + if (store.mode === "shell") { void sdk.client.session.shell({ sessionID, @@ -1143,34 +1145,20 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if (isCompactSlash(inputText)) { - const instructions = slashArguments(inputText).trim() - const payload: Parameters[0] & { $body_instructions?: string } = { + } else if (slash && (slash.name === "compact" || slash.name === "summarize")) { + const instructions = slash.arguments.trim() + const payload = { sessionID, modelID: selectedModel.modelID, providerID: selectedModel.providerID, ...(instructions ? { $body_instructions: instructions } : {}), - } + } satisfies Parameters[0] & { $body_instructions?: string } void sdk.client.session.summarize(payload) - } 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) - }) - ) { - // 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 : "") - + } else if (slash && sync.data.command.some((x) => x.name === slash.name)) { void sdk.client.session.command({ sessionID, - command: command.slice(1), - arguments: args, + command: slash.name, + arguments: slash.arguments, agent: agent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, @@ -1230,17 +1218,16 @@ export function Prompt(props: PromptProps) { return true } - function isCompactSlash(text: string) { - const command = text.split("\n")[0].split(" ")[0].slice(1) - return text.startsWith("/") && (command === "compact" || command === "summarize") - } - - function slashArguments(text: string) { + function parseSlashCommand(text: string) { + if (!text.startsWith("/")) return const firstLineEnd = text.indexOf("\n") const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) - const [, ...firstLineArgs] = firstLine.split(" ") + const [command, ...firstLineArgs] = firstLine.split(" ") const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) - return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + return { + name: command.slice(1), + arguments: firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : ""), + } } const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index d8489fd2f7..eea0ff74cb 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -25,9 +25,11 @@ export { useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType type OpencodeModeStack = ReturnType type CommandSlashEntry = { + name: string display: string description?: string aliases?: string[] + input?: boolean onSelect: () => void } type Command = ReturnType[number] @@ -256,6 +258,7 @@ export function useCommandSlashes(): Accessor { if (typeof slashName !== "string" || !slashName) return [] const slashAliases = entry.command.slashAliases return { + name: slashName, display: `/${slashName}`, description: typeof entry.command.desc === "string" @@ -266,6 +269,7 @@ export function useCommandSlashes(): Accessor { aliases: Array.isArray(slashAliases) ? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`) : undefined, + input: entry.command.slashInput === true, onSelect: () => keymap.dispatchCommand(entry.command.name), } }), 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 245a7296b5..857e9c0868 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -546,6 +546,7 @@ export function Session() { slash: { name: "compact", aliases: ["summarize"], + input: true, }, run: () => { const selectedModel = local.model.current() @@ -1046,6 +1047,7 @@ export function Session() { desc: "description" in command ? command.description : undefined, slashName: "slash" in command ? command.slash?.name : undefined, slashAliases: "slash" in command ? command.slash?.aliases : undefined, + slashInput: "slash" in command ? command.slash?.input : undefined, ...command, })), ) From 80cbafa3c3aee0f2bea5adeb6ab37ed415408ba4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 17:45:13 +0530 Subject: [PATCH 7/7] refactor(tui): dispatch editable slash commands --- .../cli/cmd/tui/component/prompt/index.tsx | 25 +++++++++++-------- .../src/cli/cmd/tui/routes/session/index.tsx | 18 +++++++++++-- 2 files changed, 31 insertions(+), 12 deletions(-) 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 05caf1482b..4461c3a893 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -59,7 +59,14 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" +import { + OPENCODE_BASE_MODE, + useBindings, + useCommandShortcut, + useCommandSlashes, + useLeaderActive, + useOpencodeKeymap, +} from "../../keymap" import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { @@ -151,6 +158,7 @@ export function Prompt(props: PromptProps) { const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() + const slashCommands = useCommandSlashes() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() @@ -1145,15 +1153,8 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if (slash && (slash.name === "compact" || slash.name === "summarize")) { - const instructions = slash.arguments.trim() - const payload = { - sessionID, - modelID: selectedModel.modelID, - providerID: selectedModel.providerID, - ...(instructions ? { $body_instructions: instructions } : {}), - } satisfies Parameters[0] & { $body_instructions?: string } - void sdk.client.session.summarize(payload) + } else if (slash && slashCommand(slash.name)?.input) { + slashCommand(slash.name)?.onSelect() } else if (slash && sync.data.command.some((x) => x.name === slash.name)) { void sdk.client.session.command({ sessionID, @@ -1229,6 +1230,10 @@ export function Prompt(props: PromptProps) { arguments: firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : ""), } } + + function slashCommand(name: string) { + return slashCommands().find((item) => item.name === name || item.aliases?.includes(`/${name}`)) + } const exit = useExit() function pasteText(text: string, virtualText: string) { 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 857e9c0868..260bd5fe1b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -314,6 +314,17 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() + function slashArguments(name: string) { + const input = prompt?.current.input + if (!input?.startsWith("/")) return + const firstLineEnd = input.indexOf("\n") + const firstLine = firstLineEnd === -1 ? input : input.slice(0, firstLineEnd) + const [command, ...firstLineArgs] = firstLine.split(" ") + if (command !== `/${name}`) return + const restOfInput = firstLineEnd === -1 ? "" : input.slice(firstLineEnd + 1) + return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + } + event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return @@ -558,11 +569,14 @@ export function Session() { }) return } - void sdk.client.session.summarize({ + const instructions = slashArguments("compact") ?? slashArguments("summarize") + const payload = { sessionID: route.sessionID, modelID: selectedModel.modelID, providerID: selectedModel.providerID, - }) + ...(instructions?.trim() ? { $body_instructions: instructions.trim() } : {}), + } satisfies Parameters[0] & { $body_instructions?: string } + void sdk.client.session.summarize(payload) dialog.clear() }, },