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_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt 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_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_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_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"), 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) => { 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) 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 = { const result: Info = {
id: Identifier.descending("session"), id: Identifier.descending("session"),
version: Installation.VERSION, version: Installation.VERSION,
parentID, parentID,
title: createDefaultTitle(!!parentID), title: title ?? createDefaultTitle(!!parentID),
time: { time: {
created: Date.now(), created: Date.now(),
updated: 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"), subagent_type: z.string().describe("The type of specialized agent to use for this task"),
}), }),
async execute(params, ctx) { 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) 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`) 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 messageID = Identifier.ascending("message")
const parts: Record<string, MessageV2.ToolPart> = {} const parts: Record<string, MessageV2.ToolPart> = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {

View file

@ -107,39 +107,41 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
} }
const ( const (
AppHelpCommand CommandName = "app_help" AppHelpCommand CommandName = "app_help"
AppExitCommand CommandName = "app_exit" AppExitCommand CommandName = "app_exit"
ThemeListCommand CommandName = "theme_list" ThemeListCommand CommandName = "theme_list"
ProjectInitCommand CommandName = "project_init" ProjectInitCommand CommandName = "project_init"
EditorOpenCommand CommandName = "editor_open" EditorOpenCommand CommandName = "editor_open"
ToolDetailsCommand CommandName = "tool_details" ToolDetailsCommand CommandName = "tool_details"
ThinkingBlocksCommand CommandName = "thinking_blocks" ThinkingBlocksCommand CommandName = "thinking_blocks"
SessionNewCommand CommandName = "session_new" SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list" SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share" SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare" SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt" SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact" SessionCompactCommand CommandName = "session_compact"
SessionExportCommand CommandName = "session_export" SessionExportCommand CommandName = "session_export"
MessagesPageUpCommand CommandName = "messages_page_up" SessionChildCycleCommand CommandName = "session_child_cycle"
MessagesPageDownCommand CommandName = "messages_page_down" SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up" MessagesPageUpCommand CommandName = "messages_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down" MessagesPageDownCommand CommandName = "messages_page_down"
MessagesFirstCommand CommandName = "messages_first" MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesLastCommand CommandName = "messages_last" MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesCopyCommand CommandName = "messages_copy" MessagesFirstCommand CommandName = "messages_first"
MessagesUndoCommand CommandName = "messages_undo" MessagesLastCommand CommandName = "messages_last"
MessagesRedoCommand CommandName = "messages_redo" MessagesCopyCommand CommandName = "messages_copy"
ModelListCommand CommandName = "model_list" MessagesUndoCommand CommandName = "messages_undo"
ModelCycleRecentCommand CommandName = "model_cycle_recent" MessagesRedoCommand CommandName = "messages_redo"
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" ModelListCommand CommandName = "model_list"
AgentListCommand CommandName = "agent_list" ModelCycleRecentCommand CommandName = "model_cycle_recent"
AgentCycleCommand CommandName = "agent_cycle" ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
AgentCycleReverseCommand CommandName = "agent_cycle_reverse" AgentListCommand CommandName = "agent_list"
InputClearCommand CommandName = "input_clear" AgentCycleCommand CommandName = "agent_cycle"
InputPasteCommand CommandName = "input_paste" AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
InputSubmitCommand CommandName = "input_submit" InputClearCommand CommandName = "input_clear"
InputNewlineCommand CommandName = "input_newline" InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
) )
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
@ -224,6 +226,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>c"), Keybindings: parseBindings("<leader>c"),
Trigger: []string{"compact", "summarize"}, 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, Name: ToolDetailsCommand,
Description: "toggle tool details", Description: "toggle tool details",

View file

@ -14,6 +14,7 @@ import (
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go" "github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@ -479,6 +480,8 @@ func renderToolDetails(
backgroundColor := t.BackgroundPanel() backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel() borderColor := t.BackgroundPanel()
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render 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 := "" permissionContent := ""
if permission.ID != "" { if permission.ID != "" {
@ -602,14 +605,15 @@ func renderToolDetails(
} }
} }
case "bash": case "bash":
command := toolInputMap["command"].(string) if command, ok := toolInputMap["command"].(string); ok {
body = fmt.Sprintf("```console\n$ %s\n", command) body = fmt.Sprintf("```console\n$ %s\n", command)
output := metadata["output"] output := metadata["output"]
if output != nil { if output != nil {
body += ansi.Strip(fmt.Sprintf("%s", output)) body += ansi.Strip(fmt.Sprintf("%s", output))
}
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
} }
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
case "webfetch": case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil { if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result body = *result
@ -653,6 +657,12 @@ func renderToolDetails(
steps = append(steps, step) steps = append(steps, step)
} }
body = strings.Join(steps, "\n") 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) body = defaultStyle(body)
default: default:

View file

@ -180,6 +180,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tail = true m.tail = true
return m, m.renderView() return m, m.renderView()
} }
case app.SessionSelectedMsg:
m.viewport.GotoBottom()
case app.MessageRevertedMsg: case app.MessageRevertedMsg:
if msg.Session.ID == m.app.Session.ID { if msg.Session.ID == m.app.Session.ID {
m.cache.Clear() m.cache.Clear()
@ -782,8 +784,17 @@ func (m *messagesComponent) renderHeader() string {
headerWidth := m.width headerWidth := m.width
t := theme.CurrentTheme() t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render bgColor := t.Background()
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render 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 := "" sessionInfo := ""
tokens := float64(0) tokens := float64(0)
@ -815,20 +826,44 @@ func (m *messagesComponent) renderHeader() string {
sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel) sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
sessionInfo = styles.NewStyle(). sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()). Foreground(t.TextMuted()).
Background(t.Background()). Background(bgColor).
Render(sessionInfoText) Render(sessionInfoText)
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
navHint := ""
if isChildSession {
navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
}
headerTextWidth := headerWidth headerTextWidth := headerWidth
if !shareEnabled { if isChildSession {
// +1 is to ensure there is always at least one space between header and session info headerTextWidth -= lipgloss.Width(navHint)
headerTextWidth -= len(sessionInfoText) + 1 } else if !shareEnabled {
headerTextWidth -= lipgloss.Width(sessionInfoText)
} }
headerText := util.ToMarkdown( headerText := util.ToMarkdown(
"# "+m.app.Session.Title, "# "+m.app.Session.Title,
headerTextWidth, 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 var items []layout.FlexItem
if shareEnabled { if shareEnabled {
@ -841,10 +876,9 @@ func (m *messagesComponent) renderHeader() string {
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}} items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
} }
background := t.Background()
headerRow := layout.Render( headerRow := layout.Render(
layout.FlexOptions{ layout.FlexOptions{
Background: &background, Background: &bgColor,
Direction: layout.Row, Direction: layout.Row,
Justify: layout.JustifySpaceBetween, Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch, Align: layout.AlignStretch,
@ -860,14 +894,14 @@ func (m *messagesComponent) renderHeader() string {
header := strings.Join(headerLines, "\n") header := strings.Join(headerLines, "\n")
header = styles.NewStyle(). header = styles.NewStyle().
Background(t.Background()). Background(bgColor).
Width(headerWidth). Width(headerWidth).
PaddingLeft(2). PaddingLeft(2).
PaddingRight(2). PaddingRight(2).
BorderLeft(true). BorderLeft(true).
BorderRight(true). BorderRight(true).
BorderBackground(t.Background()). BorderBackground(t.Background()).
BorderForeground(t.BackgroundElement()). BorderForeground(borderColor).
BorderStyle(lipgloss.ThickBorder()). BorderStyle(lipgloss.ThickBorder()).
Render(header) Render(header)
@ -914,7 +948,7 @@ func formatTokensAndCost(
formattedCost := fmt.Sprintf("$%.2f", cost) formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf( return fmt.Sprintf(
"%s/%d%% (%s)", " %s/%d%% (%s)",
formattedTokens, formattedTokens,
int(percentage), int(percentage),
formattedCost, formattedCost,
@ -923,20 +957,22 @@ func formatTokensAndCost(
func (m *messagesComponent) View() string { func (m *messagesComponent) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
bgColor := t.Background()
if m.loading { if m.loading {
return lipgloss.Place( return lipgloss.Place(
m.width, m.width,
m.height, m.height,
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
styles.NewStyle().Background(t.Background()).Render(""), styles.NewStyle().Background(bgColor).Render(""),
styles.WhitespaceStyle(t.Background()), styles.WhitespaceStyle(bgColor),
) )
} }
viewport := m.viewport.View() viewport := m.viewport.View()
return styles.NewStyle(). return styles.NewStyle().
Background(t.Background()). Background(bgColor).
Render(m.header + "\n" + viewport) 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()) return a, toast.NewErrorToast(msg.Error())
case app.SendPrompt: case app.SendPrompt:
a.showCompletionDialog = false a.showCompletionDialog = false
a.app, cmd = a.app.SendPrompt(context.Background(), msg) // If we're in a child session, switch back to parent before sending prompt
cmds = append(cmds, cmd) 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: case app.SendShell:
a.app, cmd = a.app.SendShell(context.Background(), msg.Command) // If we're in a child session, switch back to parent before sending prompt
cmds = append(cmds, cmd) 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: case app.SetEditorContentMsg:
// Set the editor content without sending // Set the editor content without sending
a.editor.SetValueWithAttachments(msg.Text) 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 // TODO: block until compaction is complete
a.app.CompactSession(context.Background()) 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: case commands.SessionExportCommand:
if a.app.Session.ID == "" { if a.app.Session.ID == "" {
return a, toast.NewErrorToast("No active session to export.") 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 @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 ## 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_unshare": "none",
"session_interrupt": "esc", "session_interrupt": "esc",
"session_compact": "<leader>c", "session_compact": "<leader>c",
"session_child_cycle": "ctrl+right",
"session_child_cycle_reverse": "ctrl+left",
"messages_page_up": "pgup", "messages_page_up": "pgup",
"messages_page_down": "pgdown", "messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u", "messages_half_page_up": "ctrl+alt+u",