feat(tui): navigate child sessions (subagents)
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
adamdotdevin 2025-08-15 10:16:08 -05:00
parent 1ae38c90a3
commit 07dbc30c63
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
10 changed files with 294 additions and 65 deletions

View file

@ -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("<leader>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"),

View file

@ -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)
},
)

View file

@ -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(),

View file

@ -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<string, MessageV2.ToolPart> = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {

View file

@ -121,6 +121,8 @@ const (
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"
@ -224,6 +226,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>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",

View file

@ -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,7 +605,7 @@ func renderToolDetails(
}
}
case "bash":
command := toolInputMap["command"].(string)
if command, ok := toolInputMap["command"].(string); ok {
body = fmt.Sprintf("```console\n$ %s\n", command)
output := metadata["output"]
if output != nil {
@ -610,6 +613,7 @@ func renderToolDetails(
}
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:

View file

@ -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)
@ -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)
}

View file

@ -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
// 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:
// 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.")

View file

@ -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

View file

@ -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": "<leader>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",