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`