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
28 changes: 28 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,34 @@ export namespace Server {
return c.json(true)
},
)
.post(
"/session/:id/fork",
describeRoute({
description: "Fork a session and all its data",
operationId: "session.fork",
responses: {
200: {
description: "Successfully forked session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").id
const newSession = await Session.fork(sessionID)
return c.json(newSession)
},
)
.patch(
"/session/:id",
describeRoute({
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,47 @@ export namespace Session {
}
}

export async function fork(sessionID: string) {
const originalSession = await get(sessionID)

// Create a new session
const newSession = await create(originalSession.parentID)

// Update the new session's title to indicate it's a fork
const newTitle = "Fork of " + originalSession.title
await update(newSession.id, (draft) => {
draft.title = newTitle
})

// Copy all messages from the original session to the new session
const sessionMessages = await messages(sessionID)
for (const message of sessionMessages) {
// Copy message info
const newMessageInfo = {
...message.info,
id: Identifier.ascending("message"), // Generate new message ID
sessionID: newSession.id, // Update session ID
}
await Storage.writeJSON(`session/message/${newSession.id}/${newMessageInfo.id}`, newMessageInfo)

// Copy all parts for this message
for (const part of message.parts) {
const newPart = {
...part,
id: Identifier.ascending("part"), // Generate new part ID
messageID: newMessageInfo.id, // Update message ID
sessionID: newSession.id, // Update session ID
}
await Storage.writeJSON(`session/part/${newSession.id}/${newMessageInfo.id}/${newPart.id}`, newPart)
}
}

// Refresh the session state
state().sessions.set(newSession.id, await get(newSession.id))

return await get(newSession.id)
}

async function updateMessage(msg: MessageV2.Info) {
await Storage.writeJSON("session/message/" + msg.sessionID + "/" + msg.id, msg)
Bus.publish(MessageV2.Event.Updated, {
Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ func (r *SessionService) Delete(ctx context.Context, id string, opts ...option.R
return
}

// Fork a session and all its data
func (r *SessionService) Fork(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) {
opts = append(r.Options[:], opts...)
if id == "" {
err = errors.New("missing required id parameter")
return
}
path := fmt.Sprintf("session/%s/fork", id)
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...)
return
}

// Abort a session
func (r *SessionService) Abort(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) {
opts = append(r.Options[:], opts...)
Expand Down
82 changes: 55 additions & 27 deletions packages/tui/internal/components/chat/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,12 @@ func renderToolDetails(
}
}
case "bash":
command := toolInputMap["command"].(string)
var command string
if cmd, ok := toolInputMap["command"].(string); ok {
command = cmd
} else {
command = ""
}
body = fmt.Sprintf("```console\n$ %s\n", command)
output := metadata["output"]
if output != nil {
Expand All @@ -611,7 +616,13 @@ func renderToolDetails(
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
var format string
if f, ok := toolInputMap["format"].(string); ok {
format = f
} else {
format = ""
}
if format != "" && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
Expand All @@ -621,38 +632,55 @@ func renderToolDetails(
case "todowrite":
todos := metadata["todos"]
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
// strike through cancelled todo
body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
if todoList, ok := todos.([]any); ok {
for _, item := range todoList {
if todo, ok := item.(map[string]any); ok {
var content string
if c, ok := todo["content"].(string); ok {
content = c
} else {
content = ""
}

var status string
if s, ok := todo["status"].(string); ok {
status = s
} else {
status = ""
}

switch status {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
// strike through cancelled todo
body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata["summary"]
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, item := range toolcalls {
data, _ := json.Marshal(item)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width-2)
step = "∟ " + step
steps = append(steps, step)
if toolcalls, ok := summary.([]any); ok {
steps := []string{}
for _, item := range toolcalls {
data, _ := json.Marshal(item)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width-2)
step = "∟ " + step
steps = append(steps, step)
}
body = strings.Join(steps, "\n")
}
body = strings.Join(steps, "\n")
}
body = defaultStyle(body)
default:
Expand Down
33 changes: 32 additions & 1 deletion packages/tui/internal/components/dialog/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.updateListItems()
return s, textinput.Blink
}
case "f":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
originalSession := s.sessions[idx]
return s, s.forkSession(originalSession)
}
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
Expand Down Expand Up @@ -248,7 +253,7 @@ func (s *sessionDialog) Render(background string) string {
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render

leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename")
leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename") + " " + keyStyle("f") + mutedStyle(" fork")
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")

bgColor := t.BackgroundPanel()
Expand Down Expand Up @@ -325,6 +330,32 @@ func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
}
}

func (s *sessionDialog) forkSession(originalSession opencode.Session) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()

// Call the new fork API endpoint
_, err := s.app.Client.Session.Fork(ctx, originalSession.ID)
if err != nil {
return toast.NewErrorToast("Failed to fork session: " + err.Error())()
}

// Refresh the session list to include the new session
sessions, _ := s.app.ListSessions(ctx)
var filteredSessions []opencode.Session
for _, sess := range sessions {
if sess.ParentID != "" {
continue
}
filteredSessions = append(filteredSessions, sess)
}
s.sessions = filteredSessions
s.updateListItems()

return toast.NewSuccessToast("Session forked successfully")()
}
}

// ReopenSessionModalMsg is emitted when the session modal should be reopened
type ReopenSessionModalMsg struct{}

Expand Down