Skip to content

Commit 1024412

Browse files
committed
feat(tui): add session tree dialog with visual hierarchy and status indicators
- Add DialogSessionTree component with tree visualization (box-drawing chars) - Add session_child_list keybind (<leader>s) to open session tree dialog - Move status_view keybind from <leader>s to <leader>i (collision resolution) - Show status indicators: current (●), awaiting permission (◉), busy (spinner) - Extract agent names from session titles (@agent pattern or first word) - Use Locale.time() for timestamps (respects user OS locale) Related: anomalyco#6183
1 parent 8035e9a commit 1024412

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { useDialog } from "@tui/ui/dialog"
2+
import { DialogSelect } from "@tui/ui/dialog-select"
3+
import { useRoute } from "@tui/context/route"
4+
import { useSync } from "@tui/context/sync"
5+
import { createMemo, onMount, type JSX } from "solid-js"
6+
import { Locale } from "@/util/locale"
7+
import { useTheme } from "../../context/theme"
8+
import { useKV } from "../../context/kv"
9+
import type { Session } from "@opencode-ai/sdk/v2"
10+
import "opentui-spinner/solid"
11+
12+
interface TreeOption {
13+
title: string
14+
value: string
15+
prefix: string
16+
footer: string
17+
gutter: JSX.Element | undefined
18+
}
19+
20+
/**
21+
* Find the root session by walking up the parentID chain
22+
*/
23+
function findRootSession(
24+
currentSession: Session | undefined,
25+
getSession: (id: string) => Session | undefined,
26+
): Session | undefined {
27+
let current = currentSession
28+
while (current?.parentID) {
29+
current = getSession(current.parentID)
30+
}
31+
return current
32+
}
33+
34+
/**
35+
* Extract agent name from session title or agent field
36+
* Session titles often contain "@agent-name" pattern
37+
*/
38+
function extractAgentName(session: Session): string {
39+
// Try to extract from title pattern "... (@agent-name ...)"
40+
const match = session.title?.match(/@([^\s)]+)/)
41+
if (match) return match[1]
42+
43+
// Fallback to first meaningful word of title, or "Session"
44+
const firstWord = session.title?.split(" ")[0]
45+
if (firstWord && firstWord.length > 0 && firstWord.length < 30) {
46+
return firstWord
47+
}
48+
return "Session"
49+
}
50+
51+
/**
52+
* Build flat array of tree options with visual prefixes using DFS traversal
53+
*/
54+
function buildTreeOptions(
55+
sessions: Session[],
56+
currentSessionId: string,
57+
rootSession: Session | undefined,
58+
sync: ReturnType<typeof useSync>,
59+
theme: any,
60+
animationsEnabled: boolean,
61+
): TreeOption[] {
62+
if (!rootSession) return []
63+
64+
const result: TreeOption[] = []
65+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
66+
67+
function getStatusIndicator(session: Session) {
68+
// Current session indicator
69+
if (session.id === currentSessionId) {
70+
return <text fg={theme.primary}>●</text>
71+
}
72+
73+
// Permission awaiting indicator
74+
const permission = sync.data.permission[session.id]
75+
if (permission?.length) {
76+
return <text fg={theme.warning}>◉</text>
77+
}
78+
79+
// Busy session indicator (spinner)
80+
const status = sync.data.session_status?.[session.id]
81+
if (status?.type === "busy") {
82+
if (animationsEnabled) {
83+
return <spinner frames={spinnerFrames} interval={80} color={theme.primary} />
84+
}
85+
return <text fg={theme.textMuted}>[⋯]</text>
86+
}
87+
88+
return undefined
89+
}
90+
91+
function traverse(session: Session, depth: number, prefix: string, isLast: boolean) {
92+
// Determine connector for this node
93+
const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ "
94+
// Determine prefix for children (continuation line or space)
95+
const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "│ ")
96+
97+
const agentName = extractAgentName(session)
98+
// For root, show full title; for children, show agent + truncated title
99+
const displayTitle =
100+
depth === 0 ? session.title || "Session" : `${agentName} "${session.title || ""}"`
101+
102+
result.push({
103+
title: displayTitle,
104+
value: session.id,
105+
prefix: prefix + connector,
106+
footer: Locale.time(session.time.updated),
107+
gutter: getStatusIndicator(session),
108+
})
109+
110+
// Get direct children and sort by id for consistent ordering
111+
const children = sessions
112+
.filter((s) => s.parentID === session.id)
113+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
114+
115+
children.forEach((child, i) => {
116+
traverse(child, depth + 1, childPrefix, i === children.length - 1)
117+
})
118+
}
119+
120+
traverse(rootSession, 0, "", true)
121+
return result
122+
}
123+
124+
export function DialogSessionTree() {
125+
const dialog = useDialog()
126+
const route = useRoute()
127+
const sync = useSync()
128+
const { theme } = useTheme()
129+
const kv = useKV()
130+
131+
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
132+
133+
const session = createMemo(() => {
134+
const id = currentSessionID()
135+
return id ? sync.session.get(id) : undefined
136+
})
137+
138+
const rootSession = createMemo(() => {
139+
return findRootSession(session(), (id) => sync.session.get(id))
140+
})
141+
142+
const animationsEnabled = kv.get("animations_enabled", true)
143+
144+
const options = createMemo(() => {
145+
const root = rootSession()
146+
const currentId = currentSessionID()
147+
if (!root || !currentId) return []
148+
149+
const treeOptions = buildTreeOptions(
150+
sync.data.session,
151+
currentId,
152+
root,
153+
sync,
154+
theme,
155+
animationsEnabled,
156+
)
157+
158+
// Convert to DialogSelectOption format with custom rendering
159+
return treeOptions.map((opt) => ({
160+
title: opt.prefix + opt.title,
161+
value: opt.value,
162+
footer: opt.footer,
163+
gutter: opt.gutter,
164+
}))
165+
})
166+
167+
onMount(() => {
168+
dialog.setSize("large")
169+
})
170+
171+
return (
172+
<DialogSelect
173+
title="Session Tree"
174+
options={options()}
175+
current={currentSessionID()}
176+
onSelect={(option) => {
177+
route.navigate({
178+
type: "session",
179+
sessionID: option.value,
180+
})
181+
dialog.clear()
182+
}}
183+
/>
184+
)
185+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
8080
import { formatTranscript } from "../../util/transcript"
8181
import { UI } from "@/cli/ui.ts"
8282
import { useTuiConfig } from "../../context/tui-config"
83+
import { DialogSessionTree } from "./dialog-session-tree"
8384

8485
addDefaultParsers(parsers.parsers)
8586

@@ -1009,6 +1010,15 @@ export function Session() {
10091010
dialog.clear()
10101011
},
10111012
},
1013+
{
1014+
title: "Session tree",
1015+
value: "session.tree",
1016+
keybind: "session_child_list",
1017+
category: "Session",
1018+
onSelect: (dialog) => {
1019+
dialog.replace(() => <DialogSessionTree />)
1020+
},
1021+
},
10121022
])
10131023

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

‎packages/opencode/src/config/config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ export namespace Config {
767767
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
768768
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
769769
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
770-
status_view: z.string().optional().default("<leader>s").describe("View status"),
770+
status_view: z.string().optional().default("<leader>i").describe("View status"),
771771
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
772772
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
773773
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
@@ -898,6 +898,7 @@ export namespace Config {
898898
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
899899
session_child_down: z.string().optional().default("<leader>down").describe("Go to first child session"),
900900
session_root: z.string().optional().default("<leader>escape").describe("Go to root session"),
901+
session_child_list: z.string().optional().default("<leader>s").describe("Open session tree dialog"),
901902
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
902903
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
903904
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),

0 commit comments

Comments
 (0)