Skip to content
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type PromptProps = {
visible?: boolean
disabled?: boolean
onSubmit?: () => void
onBranch?: (initialPrompt?: string) => void
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
Expand Down Expand Up @@ -569,6 +570,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(() => {
Expand Down
63 changes: 57 additions & 6 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,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] ?? [])
})

Expand All @@ -154,7 +155,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
Expand Down Expand Up @@ -221,7 +222,6 @@ export function Session() {
let prompt: PromptRef
const keybind = useKeybind()

// Allow exit when in child session (prompt is hidden)
const exit = useExit()

createEffect(() => {
Expand All @@ -237,7 +237,7 @@ export function Session() {
})

useKeyboard((evt) => {
if (!session()?.parentID) return
if (!isChildSession()) return
if (keybind.match("app_exit", evt)) {
exit()
}
Expand Down Expand Up @@ -422,6 +422,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",
Expand Down Expand Up @@ -1082,7 +1108,7 @@ export function Session() {
<QuestionPrompt request={questions()[0]} />
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
visible={!isChildSession() && permissions().length === 0 && questions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
Expand All @@ -1095,6 +1121,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}
/>
</box>
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,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({
Expand Down
50 changes: 50 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,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()
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ export namespace MessageV2 {
})
export type CompactionPart = z.infer<typeof CompactionPart>

export const BranchPart = PartBase.extend({
type: z.literal("branch"),
sourceSessionID: z.string(),
}).meta({
ref: "BranchPart",
})
export type BranchPart = z.infer<typeof BranchPart>

export const SubtaskPart = PartBase.extend({
type: z.literal("subtask"),
prompt: z.string(),
Expand Down Expand Up @@ -343,6 +351,7 @@ export namespace MessageV2 {
AgentPart,
RetryPart,
CompactionPart,
BranchPart,
])
.meta({
ref: "Part",
Expand Down
29 changes: 22 additions & 7 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,15 +281,17 @@ 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
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
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)
}
Expand All @@ -306,17 +308,17 @@ 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,
providerID: lastUser.model.providerID,
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") {
Expand Down Expand Up @@ -487,7 +489,6 @@ export namespace SessionPrompt {
continue
}

// pending compaction
if (task?.type === "compaction") {
const result = await SessionCompaction.process({
messages: msgs,
Expand All @@ -500,6 +501,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 &&
Expand Down
43 changes: 43 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import type {
QuestionReplyResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionBranchResponses,
SessionChildrenErrors,
SessionChildrenResponses,
SessionCommandErrors,
Expand Down Expand Up @@ -1259,6 +1260,48 @@ export class Session extends HeyApiClient {
})
}

/**
* Branch session
*
* Create a new child session with compacted context from the current session.
*/
public branch<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
model?: {
providerID: string
modelID: string
}
agent?: string
},
options?: Options<never, ThrowOnError>,
) {
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<SessionBranchResponses, unknown, ThrowOnError>({
url: "/session/{sessionID}/branch",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}

/**
* Abort session
*
Expand Down
Loading
Loading