Skip to content

Commit 07dbc30

Browse files
committed
feat(tui): navigate child sessions (subagents)
1 parent 1ae38c9 commit 07dbc30

10 files changed

Lines changed: 294 additions & 65 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,12 @@ export namespace Config {
218218
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
219219
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
220220
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
221+
session_child_cycle: z.string().optional().default("ctrl+right").describe("Cycle to next child session"),
222+
session_child_cycle_reverse: z
223+
.string()
224+
.optional()
225+
.default("ctrl+left")
226+
.describe("Cycle to previous child session"),
221227
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
222228
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
223229
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),

packages/opencode/src/server/server.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,18 @@ export namespace Server {
293293
},
294294
},
295295
}),
296+
zValidator(
297+
"json",
298+
z
299+
.object({
300+
parentID: z.string().optional(),
301+
title: z.string().optional(),
302+
})
303+
.optional(),
304+
),
296305
async (c) => {
297-
const session = await Session.create()
306+
const body = c.req.valid("json") ?? {}
307+
const session = await Session.create(body.parentID, body.title)
298308
return c.json(session)
299309
},
300310
)

packages/opencode/src/session/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,12 @@ export namespace Session {
163163
},
164164
)
165165

166-
export async function create(parentID?: string) {
166+
export async function create(parentID?: string, title?: string) {
167167
const result: Info = {
168168
id: Identifier.descending("session"),
169169
version: Installation.VERSION,
170170
parentID,
171-
title: createDefaultTitle(!!parentID),
171+
title: title ?? createDefaultTitle(!!parentID),
172172
time: {
173173
created: Date.now(),
174174
updated: Date.now(),

packages/opencode/src/tool/task.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export const TaskTool = Tool.define("task", async () => {
2323
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
2424
}),
2525
async execute(params, ctx) {
26-
const session = await Session.create(ctx.sessionID)
27-
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
28-
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
2926
const agent = await Agent.get(params.subagent_type)
3027
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
28+
const session = await Session.create(ctx.sessionID, params.description + ` (@${agent.name} subagent)`)
29+
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
30+
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
3131
const messageID = Identifier.ascending("message")
3232
const parts: Record<string, MessageV2.ToolPart> = {}
3333
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {

packages/tui/internal/commands/command.go

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -107,39 +107,41 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
107107
}
108108

109109
const (
110-
AppHelpCommand CommandName = "app_help"
111-
AppExitCommand CommandName = "app_exit"
112-
ThemeListCommand CommandName = "theme_list"
113-
ProjectInitCommand CommandName = "project_init"
114-
EditorOpenCommand CommandName = "editor_open"
115-
ToolDetailsCommand CommandName = "tool_details"
116-
ThinkingBlocksCommand CommandName = "thinking_blocks"
117-
SessionNewCommand CommandName = "session_new"
118-
SessionListCommand CommandName = "session_list"
119-
SessionShareCommand CommandName = "session_share"
120-
SessionUnshareCommand CommandName = "session_unshare"
121-
SessionInterruptCommand CommandName = "session_interrupt"
122-
SessionCompactCommand CommandName = "session_compact"
123-
SessionExportCommand CommandName = "session_export"
124-
MessagesPageUpCommand CommandName = "messages_page_up"
125-
MessagesPageDownCommand CommandName = "messages_page_down"
126-
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
127-
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
128-
MessagesFirstCommand CommandName = "messages_first"
129-
MessagesLastCommand CommandName = "messages_last"
130-
MessagesCopyCommand CommandName = "messages_copy"
131-
MessagesUndoCommand CommandName = "messages_undo"
132-
MessagesRedoCommand CommandName = "messages_redo"
133-
ModelListCommand CommandName = "model_list"
134-
ModelCycleRecentCommand CommandName = "model_cycle_recent"
135-
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
136-
AgentListCommand CommandName = "agent_list"
137-
AgentCycleCommand CommandName = "agent_cycle"
138-
AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
139-
InputClearCommand CommandName = "input_clear"
140-
InputPasteCommand CommandName = "input_paste"
141-
InputSubmitCommand CommandName = "input_submit"
142-
InputNewlineCommand CommandName = "input_newline"
110+
AppHelpCommand CommandName = "app_help"
111+
AppExitCommand CommandName = "app_exit"
112+
ThemeListCommand CommandName = "theme_list"
113+
ProjectInitCommand CommandName = "project_init"
114+
EditorOpenCommand CommandName = "editor_open"
115+
ToolDetailsCommand CommandName = "tool_details"
116+
ThinkingBlocksCommand CommandName = "thinking_blocks"
117+
SessionNewCommand CommandName = "session_new"
118+
SessionListCommand CommandName = "session_list"
119+
SessionShareCommand CommandName = "session_share"
120+
SessionUnshareCommand CommandName = "session_unshare"
121+
SessionInterruptCommand CommandName = "session_interrupt"
122+
SessionCompactCommand CommandName = "session_compact"
123+
SessionExportCommand CommandName = "session_export"
124+
SessionChildCycleCommand CommandName = "session_child_cycle"
125+
SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
126+
MessagesPageUpCommand CommandName = "messages_page_up"
127+
MessagesPageDownCommand CommandName = "messages_page_down"
128+
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
129+
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
130+
MessagesFirstCommand CommandName = "messages_first"
131+
MessagesLastCommand CommandName = "messages_last"
132+
MessagesCopyCommand CommandName = "messages_copy"
133+
MessagesUndoCommand CommandName = "messages_undo"
134+
MessagesRedoCommand CommandName = "messages_redo"
135+
ModelListCommand CommandName = "model_list"
136+
ModelCycleRecentCommand CommandName = "model_cycle_recent"
137+
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
138+
AgentListCommand CommandName = "agent_list"
139+
AgentCycleCommand CommandName = "agent_cycle"
140+
AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
141+
InputClearCommand CommandName = "input_clear"
142+
InputPasteCommand CommandName = "input_paste"
143+
InputSubmitCommand CommandName = "input_submit"
144+
InputNewlineCommand CommandName = "input_newline"
143145
)
144146

145147
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
@@ -224,6 +226,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
224226
Keybindings: parseBindings("<leader>c"),
225227
Trigger: []string{"compact", "summarize"},
226228
},
229+
{
230+
Name: SessionChildCycleCommand,
231+
Description: "cycle to next child session",
232+
Keybindings: parseBindings("ctrl+right"),
233+
},
234+
{
235+
Name: SessionChildCycleReverseCommand,
236+
Description: "cycle to previous child session",
237+
Keybindings: parseBindings("ctrl+left"),
238+
},
227239
{
228240
Name: ToolDetailsCommand,
229241
Description: "toggle tool details",

packages/tui/internal/components/chat/message.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/muesli/reflow/truncate"
1515
"github.com/sst/opencode-sdk-go"
1616
"github.com/sst/opencode/internal/app"
17+
"github.com/sst/opencode/internal/commands"
1718
"github.com/sst/opencode/internal/components/diff"
1819
"github.com/sst/opencode/internal/styles"
1920
"github.com/sst/opencode/internal/theme"
@@ -479,6 +480,8 @@ func renderToolDetails(
479480
backgroundColor := t.BackgroundPanel()
480481
borderColor := t.BackgroundPanel()
481482
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
483+
baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
484+
mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
482485

483486
permissionContent := ""
484487
if permission.ID != "" {
@@ -602,14 +605,15 @@ func renderToolDetails(
602605
}
603606
}
604607
case "bash":
605-
command := toolInputMap["command"].(string)
606-
body = fmt.Sprintf("```console\n$ %s\n", command)
607-
output := metadata["output"]
608-
if output != nil {
609-
body += ansi.Strip(fmt.Sprintf("%s", output))
608+
if command, ok := toolInputMap["command"].(string); ok {
609+
body = fmt.Sprintf("```console\n$ %s\n", command)
610+
output := metadata["output"]
611+
if output != nil {
612+
body += ansi.Strip(fmt.Sprintf("%s", output))
613+
}
614+
body += "```"
615+
body = util.ToMarkdown(body, width, backgroundColor)
610616
}
611-
body += "```"
612-
body = util.ToMarkdown(body, width, backgroundColor)
613617
case "webfetch":
614618
if format, ok := toolInputMap["format"].(string); ok && result != nil {
615619
body = *result
@@ -653,6 +657,12 @@ func renderToolDetails(
653657
steps = append(steps, step)
654658
}
655659
body = strings.Join(steps, "\n")
660+
661+
body += "\n\n"
662+
body += baseStyle(app.Keybind(commands.SessionChildCycleCommand)) +
663+
mutedStyle(", ") +
664+
baseStyle(app.Keybind(commands.SessionChildCycleReverseCommand)) +
665+
mutedStyle(" navigate child sessions")
656666
}
657667
body = defaultStyle(body)
658668
default:

packages/tui/internal/components/chat/messages.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
180180
m.tail = true
181181
return m, m.renderView()
182182
}
183+
case app.SessionSelectedMsg:
184+
m.viewport.GotoBottom()
183185
case app.MessageRevertedMsg:
184186
if msg.Session.ID == m.app.Session.ID {
185187
m.cache.Clear()
@@ -782,8 +784,17 @@ func (m *messagesComponent) renderHeader() string {
782784
headerWidth := m.width
783785

784786
t := theme.CurrentTheme()
785-
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
786-
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
787+
bgColor := t.Background()
788+
borderColor := t.BackgroundElement()
789+
790+
isChildSession := m.app.Session.ParentID != ""
791+
if isChildSession {
792+
bgColor = t.BackgroundElement()
793+
borderColor = t.Accent()
794+
}
795+
796+
base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
797+
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
787798

788799
sessionInfo := ""
789800
tokens := float64(0)
@@ -815,20 +826,44 @@ func (m *messagesComponent) renderHeader() string {
815826
sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
816827
sessionInfo = styles.NewStyle().
817828
Foreground(t.TextMuted()).
818-
Background(t.Background()).
829+
Background(bgColor).
819830
Render(sessionInfoText)
820831

821832
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
833+
834+
navHint := ""
835+
if isChildSession {
836+
navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
837+
}
838+
822839
headerTextWidth := headerWidth
823-
if !shareEnabled {
824-
// +1 is to ensure there is always at least one space between header and session info
825-
headerTextWidth -= len(sessionInfoText) + 1
840+
if isChildSession {
841+
headerTextWidth -= lipgloss.Width(navHint)
842+
} else if !shareEnabled {
843+
headerTextWidth -= lipgloss.Width(sessionInfoText)
826844
}
827845
headerText := util.ToMarkdown(
828846
"# "+m.app.Session.Title,
829847
headerTextWidth,
830-
t.Background(),
848+
bgColor,
831849
)
850+
if isChildSession {
851+
headerText = layout.Render(
852+
layout.FlexOptions{
853+
Background: &bgColor,
854+
Direction: layout.Row,
855+
Justify: layout.JustifySpaceBetween,
856+
Align: layout.AlignStretch,
857+
Width: headerTextWidth,
858+
},
859+
layout.FlexItem{
860+
View: headerText,
861+
},
862+
layout.FlexItem{
863+
View: navHint,
864+
},
865+
)
866+
}
832867

833868
var items []layout.FlexItem
834869
if shareEnabled {
@@ -841,10 +876,9 @@ func (m *messagesComponent) renderHeader() string {
841876
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
842877
}
843878

844-
background := t.Background()
845879
headerRow := layout.Render(
846880
layout.FlexOptions{
847-
Background: &background,
881+
Background: &bgColor,
848882
Direction: layout.Row,
849883
Justify: layout.JustifySpaceBetween,
850884
Align: layout.AlignStretch,
@@ -860,14 +894,14 @@ func (m *messagesComponent) renderHeader() string {
860894

861895
header := strings.Join(headerLines, "\n")
862896
header = styles.NewStyle().
863-
Background(t.Background()).
897+
Background(bgColor).
864898
Width(headerWidth).
865899
PaddingLeft(2).
866900
PaddingRight(2).
867901
BorderLeft(true).
868902
BorderRight(true).
869903
BorderBackground(t.Background()).
870-
BorderForeground(t.BackgroundElement()).
904+
BorderForeground(borderColor).
871905
BorderStyle(lipgloss.ThickBorder()).
872906
Render(header)
873907

@@ -914,7 +948,7 @@ func formatTokensAndCost(
914948

915949
formattedCost := fmt.Sprintf("$%.2f", cost)
916950
return fmt.Sprintf(
917-
"%s/%d%% (%s)",
951+
" %s/%d%% (%s)",
918952
formattedTokens,
919953
int(percentage),
920954
formattedCost,
@@ -923,20 +957,22 @@ func formatTokensAndCost(
923957

924958
func (m *messagesComponent) View() string {
925959
t := theme.CurrentTheme()
960+
bgColor := t.Background()
961+
926962
if m.loading {
927963
return lipgloss.Place(
928964
m.width,
929965
m.height,
930966
lipgloss.Center,
931967
lipgloss.Center,
932-
styles.NewStyle().Background(t.Background()).Render(""),
933-
styles.WhitespaceStyle(t.Background()),
968+
styles.NewStyle().Background(bgColor).Render(""),
969+
styles.WhitespaceStyle(bgColor),
934970
)
935971
}
936972

937973
viewport := m.viewport.View()
938974
return styles.NewStyle().
939-
Background(t.Background()).
975+
Background(bgColor).
940976
Render(m.header + "\n" + viewport)
941977
}
942978

0 commit comments

Comments
 (0)