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..476b52a5e0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -543,8 +543,22 @@ 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()] + const results: AutocompleteOption[] = slashes().map((command) => { + if (!command.input) return command + return { + ...command, + onSelect: () => insertSlashCommand(command.name), + } + }) for (const serverCommand of sync.data.command) { if (serverCommand.source === "skill") continue @@ -552,13 +566,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 f4b11fa465..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() @@ -1132,6 +1140,8 @@ export function Prompt(props: PromptProps) { ] : [] + const slash = parseSlashCommand(inputText) + if (store.mode === "shell") { void sdk.client.session.shell({ sessionID, @@ -1143,25 +1153,13 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } 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 && 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, - command: command.slice(1), - arguments: args, + command: slash.name, + arguments: slash.arguments, agent: agent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, @@ -1220,6 +1218,22 @@ export function Prompt(props: PromptProps) { input.clear() return true } + + function parseSlashCommand(text: string) { + if (!text.startsWith("/")) return + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [command, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return { + name: command.slice(1), + 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/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..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 @@ -546,6 +557,7 @@ export function Session() { slash: { name: "compact", aliases: ["summarize"], + input: true, }, run: () => { const selectedModel = local.model.current() @@ -557,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() }, }, @@ -1046,6 +1061,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, })), ) 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..b6028e8770 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 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") +} + 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 = appendInstructions( + 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", () => {