Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,11 @@ export function Autocomplete(props: {
description: "fork from message",
onSelect: () => command.trigger("session.fork"),
},
{
display: "/branch",
description: "branch with compacted context",
onSelect: () => command.trigger("session.branch"),
},
{
display: "/thinking",
description: "toggle thinking visibility",
Expand Down
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 @@ -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
Expand Down Expand Up @@ -539,6 +540,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 @@ -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] ?? [])
})

Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -392,6 +392,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 @@ -1030,7 +1056,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 @@ -1043,6 +1069,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/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,38 @@ export namespace Server {
return c.json(result)
},
)
.post(
"/session/: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(
"/session/: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 @@ -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()
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 @@ -170,6 +170,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 @@ -347,6 +355,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 @@ -276,15 +276,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 @@ -301,17 +303,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 @@ -479,7 +481,6 @@ export namespace SessionPrompt {
continue
}

// pending compaction
if (task?.type === "compaction") {
const result = await SessionCompaction.process({
messages: msgs,
Expand All @@ -492,6 +493,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 @@ -92,6 +92,7 @@ import type {
QuestionReplyResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionBranchResponses,
SessionChildrenErrors,
SessionChildrenResponses,
SessionCommandErrors,
Expand Down Expand Up @@ -1106,6 +1107,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