diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b625dfd4..1b30bc6b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -218,6 +218,12 @@ export namespace Config { session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), + session_child_cycle: z.string().optional().default("ctrl+right").describe("Cycle to next child session"), + session_child_cycle_reverse: z + .string() + .optional() + .default("ctrl+left") + .describe("Cycle to previous child session"), messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"), messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 0b1f9167..e661471a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -293,8 +293,18 @@ export namespace Server { }, }, }), + zValidator( + "json", + z + .object({ + parentID: z.string().optional(), + title: z.string().optional(), + }) + .optional(), + ), async (c) => { - const session = await Session.create() + const body = c.req.valid("json") ?? {} + const session = await Session.create(body.parentID, body.title) return c.json(session) }, ) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 843b1f02..1173de0d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -163,12 +163,12 @@ export namespace Session { }, ) - export async function create(parentID?: string) { + export async function create(parentID?: string, title?: string) { const result: Info = { id: Identifier.descending("session"), version: Installation.VERSION, parentID, - title: createDefaultTitle(!!parentID), + title: title ?? createDefaultTitle(!!parentID), time: { created: Date.now(), updated: Date.now(), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 0b518b2b..a959611e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -23,11 +23,11 @@ export const TaskTool = Tool.define("task", async () => { subagent_type: z.string().describe("The type of specialized agent to use for this task"), }), async execute(params, ctx) { - const session = await Session.create(ctx.sessionID) - const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) - if (msg.info.role !== "assistant") throw new Error("Not an assistant message") const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + const session = await Session.create(ctx.sessionID, params.description + ` (@${agent.name} subagent)`) + const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) + if (msg.info.role !== "assistant") throw new Error("Not an assistant message") const messageID = Identifier.ascending("message") const parts: Record = {} const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 64a78acf..70a267a5 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -107,39 +107,41 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command { } const ( - AppHelpCommand CommandName = "app_help" - AppExitCommand CommandName = "app_exit" - ThemeListCommand CommandName = "theme_list" - ProjectInitCommand CommandName = "project_init" - EditorOpenCommand CommandName = "editor_open" - ToolDetailsCommand CommandName = "tool_details" - ThinkingBlocksCommand CommandName = "thinking_blocks" - SessionNewCommand CommandName = "session_new" - SessionListCommand CommandName = "session_list" - SessionShareCommand CommandName = "session_share" - SessionUnshareCommand CommandName = "session_unshare" - SessionInterruptCommand CommandName = "session_interrupt" - SessionCompactCommand CommandName = "session_compact" - SessionExportCommand CommandName = "session_export" - MessagesPageUpCommand CommandName = "messages_page_up" - MessagesPageDownCommand CommandName = "messages_page_down" - MessagesHalfPageUpCommand CommandName = "messages_half_page_up" - MessagesHalfPageDownCommand CommandName = "messages_half_page_down" - MessagesFirstCommand CommandName = "messages_first" - MessagesLastCommand CommandName = "messages_last" - MessagesCopyCommand CommandName = "messages_copy" - MessagesUndoCommand CommandName = "messages_undo" - MessagesRedoCommand CommandName = "messages_redo" - ModelListCommand CommandName = "model_list" - ModelCycleRecentCommand CommandName = "model_cycle_recent" - ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" - AgentListCommand CommandName = "agent_list" - AgentCycleCommand CommandName = "agent_cycle" - AgentCycleReverseCommand CommandName = "agent_cycle_reverse" - InputClearCommand CommandName = "input_clear" - InputPasteCommand CommandName = "input_paste" - InputSubmitCommand CommandName = "input_submit" - InputNewlineCommand CommandName = "input_newline" + AppHelpCommand CommandName = "app_help" + AppExitCommand CommandName = "app_exit" + ThemeListCommand CommandName = "theme_list" + ProjectInitCommand CommandName = "project_init" + EditorOpenCommand CommandName = "editor_open" + ToolDetailsCommand CommandName = "tool_details" + ThinkingBlocksCommand CommandName = "thinking_blocks" + SessionNewCommand CommandName = "session_new" + SessionListCommand CommandName = "session_list" + SessionShareCommand CommandName = "session_share" + SessionUnshareCommand CommandName = "session_unshare" + SessionInterruptCommand CommandName = "session_interrupt" + SessionCompactCommand CommandName = "session_compact" + SessionExportCommand CommandName = "session_export" + SessionChildCycleCommand CommandName = "session_child_cycle" + SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse" + MessagesPageUpCommand CommandName = "messages_page_up" + MessagesPageDownCommand CommandName = "messages_page_down" + MessagesHalfPageUpCommand CommandName = "messages_half_page_up" + MessagesHalfPageDownCommand CommandName = "messages_half_page_down" + MessagesFirstCommand CommandName = "messages_first" + MessagesLastCommand CommandName = "messages_last" + MessagesCopyCommand CommandName = "messages_copy" + MessagesUndoCommand CommandName = "messages_undo" + MessagesRedoCommand CommandName = "messages_redo" + ModelListCommand CommandName = "model_list" + ModelCycleRecentCommand CommandName = "model_cycle_recent" + ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" + AgentListCommand CommandName = "agent_list" + AgentCycleCommand CommandName = "agent_cycle" + AgentCycleReverseCommand CommandName = "agent_cycle_reverse" + InputClearCommand CommandName = "input_clear" + InputPasteCommand CommandName = "input_paste" + InputSubmitCommand CommandName = "input_submit" + InputNewlineCommand CommandName = "input_newline" ) func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { @@ -224,6 +226,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("c"), Trigger: []string{"compact", "summarize"}, }, + { + Name: SessionChildCycleCommand, + Description: "cycle to next child session", + Keybindings: parseBindings("ctrl+right"), + }, + { + Name: SessionChildCycleReverseCommand, + Description: "cycle to previous child session", + Keybindings: parseBindings("ctrl+left"), + }, { Name: ToolDetailsCommand, Description: "toggle tool details", diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 46dd02d1..eecfe261 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -14,6 +14,7 @@ import ( "github.com/muesli/reflow/truncate" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" @@ -479,6 +480,8 @@ func renderToolDetails( backgroundColor := t.BackgroundPanel() borderColor := t.BackgroundPanel() defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render + baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render + mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render permissionContent := "" if permission.ID != "" { @@ -602,14 +605,15 @@ func renderToolDetails( } } case "bash": - command := toolInputMap["command"].(string) - body = fmt.Sprintf("```console\n$ %s\n", command) - output := metadata["output"] - if output != nil { - body += ansi.Strip(fmt.Sprintf("%s", output)) + if command, ok := toolInputMap["command"].(string); ok { + body = fmt.Sprintf("```console\n$ %s\n", command) + output := metadata["output"] + if output != nil { + body += ansi.Strip(fmt.Sprintf("%s", output)) + } + body += "```" + body = util.ToMarkdown(body, width, backgroundColor) } - body += "```" - body = util.ToMarkdown(body, width, backgroundColor) case "webfetch": if format, ok := toolInputMap["format"].(string); ok && result != nil { body = *result @@ -653,6 +657,12 @@ func renderToolDetails( steps = append(steps, step) } body = strings.Join(steps, "\n") + + body += "\n\n" + body += baseStyle(app.Keybind(commands.SessionChildCycleCommand)) + + mutedStyle(", ") + + baseStyle(app.Keybind(commands.SessionChildCycleReverseCommand)) + + mutedStyle(" navigate child sessions") } body = defaultStyle(body) default: diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 1c6bce90..109a734a 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -180,6 +180,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tail = true return m, m.renderView() } + case app.SessionSelectedMsg: + m.viewport.GotoBottom() case app.MessageRevertedMsg: if msg.Session.ID == m.app.Session.ID { m.cache.Clear() @@ -782,8 +784,17 @@ func (m *messagesComponent) renderHeader() string { headerWidth := m.width t := theme.CurrentTheme() - base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render - muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render + bgColor := t.Background() + borderColor := t.BackgroundElement() + + isChildSession := m.app.Session.ParentID != "" + if isChildSession { + bgColor = t.BackgroundElement() + borderColor = t.Accent() + } + + base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render + muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render sessionInfo := "" tokens := float64(0) @@ -815,20 +826,44 @@ func (m *messagesComponent) renderHeader() string { sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel) sessionInfo = styles.NewStyle(). Foreground(t.TextMuted()). - Background(t.Background()). + Background(bgColor). Render(sessionInfoText) shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled + + navHint := "" + if isChildSession { + navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back") + } + headerTextWidth := headerWidth - if !shareEnabled { - // +1 is to ensure there is always at least one space between header and session info - headerTextWidth -= len(sessionInfoText) + 1 + if isChildSession { + headerTextWidth -= lipgloss.Width(navHint) + } else if !shareEnabled { + headerTextWidth -= lipgloss.Width(sessionInfoText) } headerText := util.ToMarkdown( "# "+m.app.Session.Title, headerTextWidth, - t.Background(), + bgColor, ) + if isChildSession { + headerText = layout.Render( + layout.FlexOptions{ + Background: &bgColor, + Direction: layout.Row, + Justify: layout.JustifySpaceBetween, + Align: layout.AlignStretch, + Width: headerTextWidth, + }, + layout.FlexItem{ + View: headerText, + }, + layout.FlexItem{ + View: navHint, + }, + ) + } var items []layout.FlexItem if shareEnabled { @@ -841,10 +876,9 @@ func (m *messagesComponent) renderHeader() string { items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}} } - background := t.Background() headerRow := layout.Render( layout.FlexOptions{ - Background: &background, + Background: &bgColor, Direction: layout.Row, Justify: layout.JustifySpaceBetween, Align: layout.AlignStretch, @@ -860,14 +894,14 @@ func (m *messagesComponent) renderHeader() string { header := strings.Join(headerLines, "\n") header = styles.NewStyle(). - Background(t.Background()). + Background(bgColor). Width(headerWidth). PaddingLeft(2). PaddingRight(2). BorderLeft(true). BorderRight(true). BorderBackground(t.Background()). - BorderForeground(t.BackgroundElement()). + BorderForeground(borderColor). BorderStyle(lipgloss.ThickBorder()). Render(header) @@ -914,7 +948,7 @@ func formatTokensAndCost( formattedCost := fmt.Sprintf("$%.2f", cost) return fmt.Sprintf( - "%s/%d%% (%s)", + " %s/%d%% (%s)", formattedTokens, int(percentage), formattedCost, @@ -923,20 +957,22 @@ func formatTokensAndCost( func (m *messagesComponent) View() string { t := theme.CurrentTheme() + bgColor := t.Background() + if m.loading { return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, - styles.NewStyle().Background(t.Background()).Render(""), - styles.WhitespaceStyle(t.Background()), + styles.NewStyle().Background(bgColor).Render(""), + styles.WhitespaceStyle(bgColor), ) } viewport := m.viewport.View() return styles.NewStyle(). - Background(t.Background()). + Background(bgColor). Render(m.header + "\n" + viewport) } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 97e5527b..3b543fc5 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -391,11 +391,41 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast(msg.Error()) case app.SendPrompt: a.showCompletionDialog = false - a.app, cmd = a.app.SendPrompt(context.Background(), msg) - cmds = append(cmds, cmd) + // If we're in a child session, switch back to parent before sending prompt + if a.app.Session.ParentID != "" { + parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID) + if err != nil { + slog.Error("Failed to get parent session", "error", err) + return a, toast.NewErrorToast("Failed to get parent session") + } + a.app.Session = parentSession + a.app, cmd = a.app.SendPrompt(context.Background(), msg) + cmds = append(cmds, tea.Sequence( + util.CmdHandler(app.SessionSelectedMsg(parentSession)), + cmd, + )) + } else { + a.app, cmd = a.app.SendPrompt(context.Background(), msg) + cmds = append(cmds, cmd) + } case app.SendShell: - a.app, cmd = a.app.SendShell(context.Background(), msg.Command) - cmds = append(cmds, cmd) + // If we're in a child session, switch back to parent before sending prompt + if a.app.Session.ParentID != "" { + parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID) + if err != nil { + slog.Error("Failed to get parent session", "error", err) + return a, toast.NewErrorToast("Failed to get parent session") + } + a.app.Session = parentSession + a.app, cmd = a.app.SendShell(context.Background(), msg.Command) + cmds = append(cmds, tea.Sequence( + util.CmdHandler(app.SessionSelectedMsg(parentSession)), + cmd, + )) + } else { + a.app, cmd = a.app.SendShell(context.Background(), msg.Command) + cmds = append(cmds, cmd) + } case app.SetEditorContentMsg: // Set the editor content without sending a.editor.SetValueWithAttachments(msg.Text) @@ -1111,6 +1141,122 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { } // TODO: block until compaction is complete a.app.CompactSession(context.Background()) + case commands.SessionChildCycleCommand: + if a.app.Session.ID == "" { + return a, nil + } + cmds = append(cmds, func() tea.Msg { + parentSessionID := a.app.Session.ID + var parentSession *opencode.Session + if a.app.Session.ParentID != "" { + parentSessionID = a.app.Session.ParentID + session, err := a.app.Client.Session.Get(context.Background(), parentSessionID) + if err != nil { + slog.Error("Failed to get parent session", "error", err) + return toast.NewErrorToast("Failed to get parent session") + } + parentSession = session + } else { + parentSession = a.app.Session + } + + children, err := a.app.Client.Session.Children(context.Background(), parentSessionID) + if err != nil { + slog.Error("Failed to get session children", "error", err) + return toast.NewErrorToast("Failed to get session children") + } + + // Reverse sort the children (newest first) + slices.Reverse(*children) + + // Create combined array: [parent, child1, child2, ...] + sessions := []*opencode.Session{parentSession} + for i := range *children { + sessions = append(sessions, &(*children)[i]) + } + + if len(sessions) == 1 { + return toast.NewInfoToast("No child sessions available") + } + + // Find current session index in combined array + currentIndex := -1 + for i, session := range sessions { + if session.ID == a.app.Session.ID { + currentIndex = i + break + } + } + + // If session not found, default to parent (shouldn't happen) + if currentIndex == -1 { + currentIndex = 0 + } + + // Cycle to next session (parent or child) + nextIndex := (currentIndex + 1) % len(sessions) + nextSession := sessions[nextIndex] + + return app.SessionSelectedMsg(nextSession) + }) + case commands.SessionChildCycleReverseCommand: + if a.app.Session.ID == "" { + return a, nil + } + cmds = append(cmds, func() tea.Msg { + parentSessionID := a.app.Session.ID + var parentSession *opencode.Session + if a.app.Session.ParentID != "" { + parentSessionID = a.app.Session.ParentID + session, err := a.app.Client.Session.Get(context.Background(), parentSessionID) + if err != nil { + slog.Error("Failed to get parent session", "error", err) + return toast.NewErrorToast("Failed to get parent session") + } + parentSession = session + } else { + parentSession = a.app.Session + } + + children, err := a.app.Client.Session.Children(context.Background(), parentSessionID) + if err != nil { + slog.Error("Failed to get session children", "error", err) + return toast.NewErrorToast("Failed to get session children") + } + + // Reverse sort the children (newest first) + slices.Reverse(*children) + + // Create combined array: [parent, child1, child2, ...] + sessions := []*opencode.Session{parentSession} + for i := range *children { + sessions = append(sessions, &(*children)[i]) + } + + if len(sessions) == 1 { + return toast.NewInfoToast("No child sessions available") + } + + // Find current session index in combined array + currentIndex := -1 + for i, session := range sessions { + if session.ID == a.app.Session.ID { + currentIndex = i + break + } + } + + // If session not found, default to parent (shouldn't happen) + if currentIndex == -1 { + currentIndex = 0 + } + + // Cycle to previous session (parent or child) + nextIndex := (currentIndex - 1 + len(sessions)) % len(sessions) + nextSession := sessions[nextIndex] + + return app.SessionSelectedMsg(nextSession) + }) case commands.SessionExportCommand: if a.app.Session.ID == "" { return a, toast.NewErrorToast("No active session to export.") diff --git a/packages/web/src/content/docs/docs/agents.mdx b/packages/web/src/content/docs/docs/agents.mdx index beb1b29a..ce1b885f 100644 --- a/packages/web/src/content/docs/docs/agents.mdx +++ b/packages/web/src/content/docs/docs/agents.mdx @@ -90,6 +90,13 @@ A general-purpose agent for researching complex questions, searching for code, a @general help me search for this function ``` +3. **Navigation between sessions**: When subagents create their own child sessions, you can navigate between the parent session and all child sessions using: + + - **Ctrl+Right** (or your configured `session_child_cycle` keybind) to cycle forward through parent → child1 → child2 → ... → parent + - **Ctrl+Left** (or your configured `session_child_cycle_reverse` keybind) to cycle backward through parent ← child1 ← child2 ← ... ← parent + + This allows you to seamlessly switch between the main conversation and specialized subagent work. + --- ## Configure diff --git a/packages/web/src/content/docs/docs/keybinds.mdx b/packages/web/src/content/docs/docs/keybinds.mdx index 60b9a5cd..6fd6148e 100644 --- a/packages/web/src/content/docs/docs/keybinds.mdx +++ b/packages/web/src/content/docs/docs/keybinds.mdx @@ -24,6 +24,8 @@ opencode has a list of keybinds that you can customize through the opencode conf "session_unshare": "none", "session_interrupt": "esc", "session_compact": "c", + "session_child_cycle": "ctrl+right", + "session_child_cycle_reverse": "ctrl+left", "messages_page_up": "pgup", "messages_page_down": "pgdown", "messages_half_page_up": "ctrl+alt+u",