mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 14:04:07 +00:00
feat(tui): navigate child sessions (subagents)
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
This commit is contained in:
parent
1ae38c90a3
commit
07dbc30c63
10 changed files with 294 additions and 65 deletions
|
@ -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"),
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue