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 730da20c265..4f18ab039e2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -37,6 +37,7 @@ export type PromptProps = { visible?: boolean disabled?: boolean onSubmit?: () => void + onBranch?: (initialPrompt?: string) => void ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean @@ -543,6 +544,9 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if (inputText.startsWith("/branch")) { + const args = inputText.slice("/branch".length).trim() + props.onBranch?.(args || undefined) } else if ( inputText.startsWith("/") && iife(() => { 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 1294ab849e9..f036e7074d6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -119,12 +119,13 @@ export function Session() { .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const isChildSession = () => !!session()?.parentID const permissions = createMemo(() => { - if (session()?.parentID) return [] + if (isChildSession()) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) }) const questions = createMemo(() => { - if (session()?.parentID) return [] + if (isChildSession()) return [] return children().flatMap((x) => sync.data.question[x.id] ?? []) }) @@ -150,7 +151,7 @@ export function Session() { const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { - if (session()?.parentID) return false + if (isChildSession()) return false if (sidebarOpen()) return true if (sidebar() === "auto" && wide()) return true return false @@ -217,10 +218,9 @@ export function Session() { let prompt: PromptRef const keybind = useKeybind() - // Allow exit when in child session (prompt is hidden) const exit = useExit() useKeyboard((evt) => { - if (!session()?.parentID) return + if (!isChildSession()) return if (keybind.match("app_exit", evt)) { exit() } @@ -404,6 +404,32 @@ export function Session() { dialog.clear() }, }, + + { + title: "Branch session", + value: "session.branch", + category: "Session", + onSelect: async (dialog) => { + const selectedModel = local.model.current() + if (!selectedModel) { + toast.show({ message: "No model selected", variant: "warning" }) + return + } + if (!messages().length) { + toast.show({ message: "Cannot branch an empty session", variant: "warning" }) + return + } + dialog.clear() + const result = await sdk.client.session.branch({ + sessionID: route.sessionID, + model: { modelID: selectedModel.modelID, providerID: selectedModel.providerID }, + agent: local.agent.current().name, + }) + if (result.data) { + navigate({ sessionID: result.data.id, type: "session" }) + } + }, + }, { title: "Unshare session", value: "session.unshare", @@ -1085,7 +1111,7 @@ export function Session() { { prompt = r promptRef.set(r) @@ -1098,6 +1124,31 @@ export function Session() { onSubmit={() => { toBottom() }} + onBranch={async (initialPrompt) => { + const selectedModel = local.model.current() + if (!selectedModel) { + toast.show({ message: "No model selected", variant: "warning" }) + return + } + if (!messages().length) { + toast.show({ message: "Cannot branch an empty session", variant: "warning" }) + return + } + const result = await sdk.client.session.branch({ + sessionID: route.sessionID, + model: { modelID: selectedModel.modelID, providerID: selectedModel.providerID }, + agent: local.agent.current().name, + }) + if (!result.data) { + toast.show({ message: "Failed to branch session", variant: "error" }) + return + } + navigate({ + sessionID: result.data.id, + type: "session", + initialPrompt: initialPrompt ? { input: initialPrompt, parts: [] } : undefined, + }) + }} sessionID={route.sessionID} /> diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index a98624dfae2..6a2faff63a0 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -350,6 +350,38 @@ export const SessionRoutes = lazy(() => return c.json(result) }, ) + .post( + "/:sessionID/branch", + describeRoute({ + summary: "Branch session", + description: "Create a new child session with compacted context from the current session.", + operationId: "session.branch", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + sessionID: Session.branch.schema.shape.sourceSessionID, + }), + ), + validator("json", Session.branch.schema.omit({ sourceSessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const result = await Session.branch({ ...body, sourceSessionID: sessionID }) + SessionPrompt.loop(result.id) + return c.json(result) + }, + ) .post( "/:sessionID/abort", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 3fcdab5238c..6c3e41482bc 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -183,6 +183,56 @@ export namespace Session { }, ) + export const branch = fn( + z.object({ + sourceSessionID: Identifier.schema("session"), + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }), + agent: z.string(), + }), + async (input) => { + log.info("branch", { sourceSessionID: input.sourceSessionID }) + + const sourceSession = await get(input.sourceSessionID) + const msgs = await messages({ sessionID: input.sourceSessionID }) + if (msgs.length === 0) { + throw new Error("Cannot branch from empty session") + } + + const title = isDefaultTitle(sourceSession.title) + ? "Branch - " + new Date().toISOString() + : "Branch: " + sourceSession.title + + const newSession = await createNext({ + directory: Instance.directory, + title, + }) + + const userMsg = await updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: newSession.id, + model: input.model, + agent: input.agent, + time: { + created: Date.now(), + }, + }) + + await updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: newSession.id, + type: "branch", + sourceSessionID: input.sourceSessionID, + }) + + return newSession + }, + ) + export const touch = fn(Identifier.schema("session"), async (sessionID) => { await update(sessionID, (draft) => { draft.time.updated = Date.now() diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d326976f1ae..f2f2a4ccbf4 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -163,6 +163,14 @@ export namespace MessageV2 { }) export type CompactionPart = z.infer + export const BranchPart = PartBase.extend({ + type: z.literal("branch"), + sourceSessionID: z.string(), + }).meta({ + ref: "BranchPart", + }) + export type BranchPart = z.infer + export const SubtaskPart = PartBase.extend({ type: z.literal("subtask"), prompt: z.string(), @@ -340,6 +348,7 @@ export namespace MessageV2 { AgentPart, RetryPart, CompactionPart, + BranchPart, ]) .meta({ ref: "Part", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9dbca30d8b3..1d6a670446c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -277,7 +277,7 @@ export namespace SessionPrompt { let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined let lastFinished: MessageV2.Assistant | undefined - let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] + let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart | MessageV2.BranchPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User @@ -285,7 +285,9 @@ export namespace SessionPrompt { if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break - const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") + const task = msg.parts.filter( + (part) => part.type === "compaction" || part.type === "subtask" || part.type === "branch", + ) if (task && !lastFinished) { tasks.push(...task) } @@ -302,7 +304,10 @@ export namespace SessionPrompt { } step++ - if (step === 1) + const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + const task = tasks.pop() + + if (step === 1 && task?.type !== "branch") ensureTitle({ session, modelID: lastUser.model.modelID, @@ -310,9 +315,6 @@ export namespace SessionPrompt { history: msgs, }) - const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) - const task = tasks.pop() - // pending subtask // TODO: centralize "invoke tool" logic if (task?.type === "subtask") { @@ -480,7 +482,6 @@ export namespace SessionPrompt { continue } - // pending compaction if (task?.type === "compaction") { const result = await SessionCompaction.process({ messages: msgs, @@ -493,6 +494,20 @@ export namespace SessionPrompt { continue } + if (task?.type === "branch") { + const sourceMsgs = await Session.messages({ sessionID: task.sourceSessionID }) + const currentUserMsg = msgs.find((m) => m.info.id === lastUser.id)! + const result = await SessionCompaction.process({ + messages: [...sourceMsgs, currentUserMsg], + parentID: lastUser.id, + abort, + sessionID, + auto: false, + }) + if (result === "stop") break + continue + } + // context overflow, needs compaction if ( lastFinished && diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 706d0f9c227..4717d66d012 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -93,6 +93,7 @@ import type { QuestionReplyResponses, SessionAbortErrors, SessionAbortResponses, + SessionBranchResponses, SessionChildrenErrors, SessionChildrenResponses, SessionCommandErrors, @@ -1148,6 +1149,48 @@ export class Session extends HeyApiClient { }) } + /** + * Branch session + * + * Create a new child session with compacted context from the current session. + */ + public branch( + parameters: { + sessionID: string + directory?: string + model?: { + providerID: string + modelID: string + } + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "body", key: "model" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/branch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Abort session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b7e72fbad8f..1a66880a019 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -427,6 +427,14 @@ export type CompactionPart = { auto: boolean } +export type BranchPart = { + id: string + sessionID: string + messageID: string + type: "branch" + sourceSessionID: string +} + export type Part = | TextPart | { @@ -453,6 +461,7 @@ export type Part = | AgentPart | RetryPart | CompactionPart + | BranchPart export type EventMessagePartUpdated = { type: "message.part.updated" @@ -3016,6 +3025,32 @@ export type SessionForkResponses = { export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] +export type SessionBranchData = { + body?: { + model: { + providerID: string + modelID: string + } + agent: string + } + path: { + sessionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/branch" +} + +export type SessionBranchResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionBranchResponse = SessionBranchResponses[keyof SessionBranchResponses] + export type SessionAbortData = { body?: never path: { diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index 8b3d3a9c824..34da48fe524 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -293,6 +293,18 @@ changes. --- +### Branch sessions + +When your context window starts filling up during a long task, you can branch to a new session. + +```bash frame="none" +/branch +``` + +This creates a new session with a summary of your conversation. You keep the important context while getting a fresh context window to continue working. + +--- + ### Undo changes Let's say you ask OpenCode to make some changes. diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 085eb6169f8..5a712e1f539 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -79,6 +79,18 @@ Add a provider to OpenCode. Allows you to select from available providers and ad --- +### branch + +Create a new session with compacted context from the current conversation. Useful when the context window is filling up but you want to continue the same task. + +```bash frame="none" +/branch +``` + +The new session starts with a summary of what was discussed, preserving important context while giving you a fresh context window. + +--- + ### compact Compact the current session. _Alias_: `/summarize`