mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 05:54:08 +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_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"),
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue