Skip to content

Commit cde1ead

Browse files
committed
feat(tui): add hierarchical session navigation with breadcrumb UI
- Add session_child_down and session_root keybinds for vertical navigation - Rewrite header.tsx with 3-row layout: breadcrumb trail, stats, nav hints - Add siblings() and directChildren() memos for proper navigation - Add moveToFirstChild() and moveToRoot() navigation functions - Dynamic breadcrumb truncation based on terminal width - Navigation hints show only when relevant (siblings exist, children exist, depth >= 2)
1 parent ff5ab1f commit cde1ead

File tree

4 files changed

+298
-47
lines changed

4 files changed

+298
-47
lines changed

packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

Lines changed: 214 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
2-
import { useRouteData } from "@tui/context/route"
1+
import { type Accessor, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
2+
import { useRoute, useRouteData } from "@tui/context/route"
33
import { useSync } from "@tui/context/sync"
44
import { pipe, sumBy } from "remeda"
55
import { useTheme } from "@tui/context/theme"
@@ -32,6 +32,7 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
3232

3333
export function Header() {
3434
const route = useRouteData("session")
35+
const { navigate } = useRoute()
3536
const sync = useSync()
3637
const session = createMemo(() => sync.session.get(route.sessionID)!)
3738
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
@@ -60,12 +61,113 @@ export function Header() {
6061
return result
6162
})
6263

64+
// Build session path from root to current session
65+
const sessionPath = createMemo(() => {
66+
const path: Session[] = []
67+
let current: Session | undefined = session()
68+
while (current) {
69+
path.unshift(current)
70+
current = current.parentID ? sync.session.get(current.parentID) : undefined
71+
}
72+
return path
73+
})
74+
75+
// Current depth (0 = root, 1 = first child, etc.)
76+
const depth = createMemo(() => sessionPath().length - 1)
77+
78+
// Direct children of current session (for down navigation availability)
79+
const directChildren = createMemo(() => {
80+
const currentID = session()?.id
81+
if (!currentID) return []
82+
return sync.data.session.filter((x) => x.parentID === currentID)
83+
})
84+
85+
// Siblings at current level (for left/right navigation availability)
86+
const siblings = createMemo(() => {
87+
const currentParentID = session()?.parentID
88+
if (!currentParentID) return []
89+
return sync.data.session.filter((x) => x.parentID === currentParentID)
90+
})
91+
92+
// Navigation availability
93+
const canGoUp = createMemo(() => !!session()?.parentID)
94+
const canGoDown = createMemo(() => directChildren().length > 0)
95+
const canCycleSiblings = createMemo(() => siblings().length > 1)
96+
97+
// Get display name for a session
98+
const getSessionDisplayName = (s: Session, isRoot: boolean) => {
99+
if (isRoot) {
100+
// Root session: show the title
101+
return s.title || s.id.slice(0, 8)
102+
}
103+
// Child session: extract agent name from title like "Description (@agent-name subagent)"
104+
const match = s.title?.match(/\(@([^)]+?)(?:\s+subagent)?\)/)
105+
if (match) {
106+
// Return just the agent name without @ and "subagent"
107+
return match[1]
108+
}
109+
// Fallback to title or shortened ID
110+
return s.title || s.id.slice(0, 8)
111+
}
112+
113+
// Get UP navigation label based on depth
114+
const upLabel = createMemo(() => {
115+
const d = depth()
116+
if (d <= 0) return "" // Root has no parent
117+
if (d === 1) return "Parent" // Depth 1 → Root
118+
return `Child(L${d - 1})` // Depth N → Child(L{N-1})
119+
})
120+
121+
// Get DOWN navigation label based on depth
122+
const downLabel = createMemo(() => {
123+
const d = depth()
124+
return `Child(L${d + 1})` // Depth N → Child(L{N+1})
125+
})
126+
63127
const { theme } = useTheme()
64128
const keybind = useKeybind()
65129
const command = useCommandDialog()
66-
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
67130
const dimensions = useTerminalDimensions()
68131
const narrow = createMemo(() => dimensions().width < 80)
132+
const [hover, setHover] = createSignal<"parent" | "root" | "prev" | "next" | "down" | "breadcrumb" | null>(null)
133+
const [hoverBreadcrumbIdx, setHoverBreadcrumbIdx] = createSignal<number | null>(null)
134+
135+
// Calculate breadcrumb text for a set of segments
136+
const calcBreadcrumbLength = (segments: Session[], truncated: boolean) => {
137+
let len = 0
138+
segments.forEach((s, i) => {
139+
len += getSessionDisplayName(s, !s.parentID).length
140+
if (i < segments.length - 1) {
141+
len += truncated && i === 0 ? 9 : 3 // " > ... > " or " > "
142+
}
143+
})
144+
return len
145+
}
146+
147+
// Dynamic breadcrumb truncation based on available width
148+
const breadcrumbSegments = createMemo(() => {
149+
const path = sessionPath()
150+
const availableWidth = dimensions().width - 40 // Reserve ~40 chars for right-side stats
151+
152+
// Try full path first
153+
const fullLength = calcBreadcrumbLength(path, false)
154+
if (fullLength <= availableWidth || path.length <= 2) {
155+
return { truncated: false, segments: path }
156+
}
157+
158+
// Truncate: show root + ... + last N segments that fit
159+
// Start with root + last segment, add more if space allows
160+
for (let keepLast = path.length - 1; keepLast >= 1; keepLast--) {
161+
const segments = [path[0], ...path.slice(-keepLast)]
162+
const len = calcBreadcrumbLength(segments, true)
163+
if (len <= availableWidth || keepLast === 1) {
164+
return { truncated: true, segments }
165+
}
166+
}
167+
168+
// Fallback: root + last segment
169+
return { truncated: true, segments: [path[0], path[path.length - 1]] }
170+
})
69171

70172
return (
71173
<box flexShrink={0}>
@@ -82,52 +184,125 @@ export function Header() {
82184
>
83185
<Switch>
84186
<Match when={session()?.parentID}>
85-
<box flexDirection="column" gap={1}>
86-
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
87-
<text fg={theme.text}>
88-
<b>Subagent session</b>
89-
</text>
187+
{/* Subagent session: 3-row layout */}
188+
<box flexDirection="column" gap={0}>
189+
{/* Row 1: Breadcrumb trail */}
190+
<box flexDirection="row" gap={0}>
191+
<For each={breadcrumbSegments().segments}>
192+
{(segment, index) => (
193+
<>
194+
<box
195+
onMouseOver={() => {
196+
setHover("breadcrumb")
197+
setHoverBreadcrumbIdx(index())
198+
}}
199+
onMouseOut={() => {
200+
setHover(null)
201+
setHoverBreadcrumbIdx(null)
202+
}}
203+
onMouseUp={() => {
204+
navigate({ type: "session", sessionID: segment.id })
205+
}}
206+
backgroundColor={
207+
hover() === "breadcrumb" && hoverBreadcrumbIdx() === index()
208+
? theme.backgroundElement
209+
: theme.backgroundPanel
210+
}
211+
>
212+
<text fg={index() === breadcrumbSegments().segments.length - 1 ? theme.text : theme.textMuted}>
213+
<Show when={index() === breadcrumbSegments().segments.length - 1} fallback={getSessionDisplayName(segment, !segment.parentID)}>
214+
<b>{getSessionDisplayName(segment, !segment.parentID)}</b>
215+
</Show>
216+
</text>
217+
</box>
218+
<Show when={index() < breadcrumbSegments().segments.length - 1}>
219+
{/* Show "... >" after root when truncated */}
220+
<text fg={theme.textMuted}>
221+
{index() === 0 && breadcrumbSegments().truncated ? " > ... >" : " > "}
222+
</text>
223+
</Show>
224+
</>
225+
)}
226+
</For>
227+
</box>
228+
229+
{/* Row 2: Divider + stats */}
230+
<box flexDirection="row" gap={1}>
231+
<box flexGrow={1}>
232+
<text fg={theme.border}>────────────────────────────────────────</text>
233+
</box>
90234
<box flexDirection="row" gap={1} flexShrink={0}>
91235
<ContextInfo context={context} cost={cost} />
92236
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
93237
</box>
94238
</box>
239+
240+
{/* Row 3: Navigation hints */}
95241
<box flexDirection="row" gap={2}>
96-
<box
97-
onMouseOver={() => setHover("parent")}
98-
onMouseOut={() => setHover(null)}
99-
onMouseUp={() => command.trigger("session.parent")}
100-
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
101-
>
102-
<text fg={theme.text}>
103-
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
104-
</text>
105-
</box>
106-
<box
107-
onMouseOver={() => setHover("prev")}
108-
onMouseOut={() => setHover(null)}
109-
onMouseUp={() => command.trigger("session.child.previous")}
110-
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
111-
>
112-
<text fg={theme.text}>
113-
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
114-
</text>
115-
</box>
116-
<box
117-
onMouseOver={() => setHover("next")}
118-
onMouseOut={() => setHover(null)}
119-
onMouseUp={() => command.trigger("session.child.next")}
120-
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
121-
>
122-
<text fg={theme.text}>
123-
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
124-
</text>
125-
</box>
242+
<Show when={canGoUp()}>
243+
<box
244+
onMouseOver={() => setHover("parent")}
245+
onMouseOut={() => setHover(null)}
246+
onMouseUp={() => command.trigger("session.parent")}
247+
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
248+
>
249+
<text fg={theme.text}>
250+
{upLabel()} <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
251+
</text>
252+
</box>
253+
</Show>
254+
<Show when={depth() >= 2}>
255+
<box
256+
onMouseOver={() => setHover("root")}
257+
onMouseOut={() => setHover(null)}
258+
onMouseUp={() => command.trigger("session.root")}
259+
backgroundColor={hover() === "root" ? theme.backgroundElement : theme.backgroundPanel}
260+
>
261+
<text fg={theme.text}>
262+
Root <span style={{ fg: theme.textMuted }}>{keybind.print("session_root")}</span>
263+
</text>
264+
</box>
265+
</Show>
266+
<Show when={canCycleSiblings()}>
267+
<box
268+
onMouseOver={() => setHover("next")}
269+
onMouseOut={() => setHover(null)}
270+
onMouseUp={() => command.trigger("session.child.next")}
271+
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
272+
>
273+
<text fg={theme.text}>
274+
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
275+
</text>
276+
</box>
277+
<box
278+
onMouseOver={() => setHover("prev")}
279+
onMouseOut={() => setHover(null)}
280+
onMouseUp={() => command.trigger("session.child.previous")}
281+
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
282+
>
283+
<text fg={theme.text}>
284+
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
285+
</text>
286+
</box>
287+
</Show>
288+
<Show when={canGoDown()}>
289+
<box
290+
onMouseOver={() => setHover("down")}
291+
onMouseOut={() => setHover(null)}
292+
onMouseUp={() => command.trigger("session.child.down")}
293+
backgroundColor={hover() === "down" ? theme.backgroundElement : theme.backgroundPanel}
294+
>
295+
<text fg={theme.text}>
296+
{downLabel()} <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_down")}</span>
297+
</text>
298+
</box>
299+
</Show>
126300
</box>
127301
</box>
128302
</Match>
129303
<Match when={true}>
130-
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
304+
{/* Root session: responsive layout from upstream */}
305+
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
131306
<Title session={session} />
132307
<box flexDirection="row" gap={1} flexShrink={0}>
133308
<ContextInfo context={context} cost={cost} />

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ export function Session() {
118118
.filter((x) => x.parentID === parentID || x.id === parentID)
119119
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
120120
})
121+
122+
// Siblings: sessions with the same parentID (for proper sibling cycling)
123+
const siblings = createMemo(() => {
124+
const currentParentID = session()?.parentID
125+
if (!currentParentID) return [] // Root has no siblings
126+
return sync.data.session
127+
.filter((x) => x.parentID === currentParentID)
128+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
129+
})
130+
131+
// Direct children: sessions where parentID === current session ID
132+
const directChildren = createMemo(() => {
133+
const currentID = session()?.id
134+
if (!currentID) return []
135+
return sync.data.session
136+
.filter((x) => x.parentID === currentID)
137+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
138+
})
139+
121140
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
122141
const permissions = createMemo(() => {
123142
if (session()?.parentID) return []
@@ -281,14 +300,39 @@ export function Session() {
281300
const local = useLocal()
282301

283302
function moveChild(direction: number) {
284-
if (children().length === 1) return
285-
let next = children().findIndex((x) => x.id === session()?.id) + direction
286-
if (next >= children().length) next = 0
287-
if (next < 0) next = children().length - 1
288-
if (children()[next]) {
303+
// Use siblings for cycling (sessions with same parentID)
304+
const sibs = siblings()
305+
if (sibs.length <= 1) return
306+
let next = sibs.findIndex((x) => x.id === session()?.id) + direction
307+
if (next >= sibs.length) next = 0
308+
if (next < 0) next = sibs.length - 1
309+
if (sibs[next]) {
310+
navigate({
311+
type: "session",
312+
sessionID: sibs[next].id,
313+
})
314+
}
315+
}
316+
317+
function moveToFirstChild() {
318+
const children = directChildren()
319+
if (children.length === 0) return
320+
navigate({
321+
type: "session",
322+
sessionID: children[0].id,
323+
})
324+
}
325+
326+
function moveToRoot() {
327+
// Traverse up to find root session (no parentID)
328+
let current = session()
329+
while (current?.parentID) {
330+
current = sync.session.get(current.parentID)
331+
}
332+
if (current && current.id !== session()?.id) {
289333
navigate({
290334
type: "session",
291-
sessionID: children()[next].id,
335+
sessionID: current.id,
292336
})
293337
}
294338
}
@@ -887,6 +931,28 @@ export function Session() {
887931
dialog.clear()
888932
},
889933
},
934+
{
935+
title: "Go to first child session",
936+
value: "session.child.down",
937+
keybind: "session_child_down",
938+
category: "Session",
939+
disabled: true,
940+
onSelect: (dialog) => {
941+
moveToFirstChild()
942+
dialog.clear()
943+
},
944+
},
945+
{
946+
title: "Go to root session",
947+
value: "session.root",
948+
keybind: "session_root",
949+
category: "Session",
950+
disabled: true,
951+
onSelect: (dialog) => {
952+
moveToRoot()
953+
dialog.clear()
954+
},
955+
},
890956
])
891957

892958
const revertInfo = createMemo(() => session()?.revert)

0 commit comments

Comments
 (0)