Просмотр исходного кода

feat(tui): navigate child sessions (subagents)

adamdotdevin 6 месяцев назад
Родитель
Сommit
07dbc30c63

+ 6 - 0
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("<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"),

+ 11 - 1
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)
         },
       )

+ 2 - 2
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(),

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

+ 45 - 33
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("<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",

+ 17 - 7
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:

+ 51 - 15
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)
 }
 

+ 150 - 4
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.")

+ 7 - 0
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

+ 2 - 0
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": "<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",