diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aa002080b1dd..b881cf1fdd54 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -51,7 +51,7 @@ import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "../../context/args" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" +import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap, useCommandSlashes } from "../../keymap" import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" @@ -160,6 +160,7 @@ export function Prompt(props: PromptProps) { const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() + const commandSlashes = useCommandSlashes() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() @@ -1064,27 +1065,70 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if ( inputText.startsWith("/") && - sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) + (sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) || + commandSlashes().some((x) => x.display === inputText.split("\n")[0].split(" ")[0])) ) { - move.startSubmit() - // Parse command from first line, preserve multi-line content in arguments - const firstLineEnd = inputText.indexOf("\n") - const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) - const [command, ...firstLineArgs] = firstLine.split(" ") - const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) - const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - - void sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: agent.name, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - variant, - parts: nonTextParts.filter((x) => x.type === "file"), - }) + // Check if it's a client-side slash command first + const commandName = inputText.split("\n")[0].split(" ")[0].slice(1) + const clientCommand = commandSlashes().find((x) => x.display === inputText.split("\n")[0].split(" ")[0]) + + if (clientCommand) { + // Execute client-side command + clientCommand.onSelect() + } else { + // Execute server-side command + move.startSubmit() + // Parse command from first line, preserve multi-line content in arguments + const firstLineEnd = inputText.indexOf("\n") + const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) + const [command, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) + const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + + void sdk.client.session.command({ + sessionID, + command: command.slice(1), + arguments: args, + agent: agent.name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant, + parts: nonTextParts.filter((x) => x.type === "file"), + }) + } } else { move.startSubmit() + + // Check if this is the first message in a thread session + const currentSession = sync.session.get(sessionID) + const isThreadSession = currentSession?.metadata?.type === "thread" + const existingMessages = sync.data.message[sessionID] ?? [] + const isFirstMessage = existingMessages.length === 0 + + // Build thread context parts for first message in thread sessions + const threadContextParts: Array<{ type: "text"; text: string; synthetic: boolean; metadata: Record }> = [] + if (isThreadSession && isFirstMessage) { + const selectedText = currentSession?.metadata?.selectedText as string | undefined + const parentContext = currentSession?.metadata?.parentContext as string | undefined + + if (selectedText) { + threadContextParts.push({ + type: "text", + text: `The user selected the following text from the parent session to explore further:\n\n"""\n${selectedText}\n"""\n\nPlease help explore this selection or answer questions about it.`, + synthetic: true, + metadata: { kind: "thread_context" }, + }) + } + + if (parentContext) { + threadContextParts.push({ + type: "text", + text: `Here is the recent conversation context from the parent session:\n\n${parentContext}\n\nContinue the exploration from this context.`, + synthetic: true, + metadata: { kind: "thread_context" }, + }) + } + } + sdk.client.session .prompt( { @@ -1094,6 +1138,7 @@ export function Prompt(props: PromptProps) { model: selectedModel, variant, parts: [ + ...threadContextParts, ...editorParts, { type: "text", diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0028b610f2a3..7a627dae3467 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -101,6 +101,8 @@ export const Definitions = { session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), + session_thread_new: keybind("t", "Create new thread from selection"), + session_graph: keybind("g", "Show session graph"), session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), session_quick_switch_1: keybind("1", "Switch to session in quick slot 1"), session_quick_switch_2: keybind("2", "Switch to session in quick slot 2"), @@ -305,6 +307,8 @@ export const CommandMap = { session_child_cycle: "session.child.next", session_child_cycle_reverse: "session.child.previous", session_parent: "session.parent", + session_thread_new: "session.thread.new", + session_graph: "session.graph", session_pin_toggle: "session.pin.toggle", session_quick_switch_1: "session.quick_switch.1", session_quick_switch_2: "session.quick_switch.2", diff --git a/packages/tui/src/routes/session/dialog-graph.tsx b/packages/tui/src/routes/session/dialog-graph.tsx new file mode 100644 index 000000000000..cc98366a7293 --- /dev/null +++ b/packages/tui/src/routes/session/dialog-graph.tsx @@ -0,0 +1,232 @@ +import { createMemo, createSignal, For, Show } from "solid-js" +import { useRoute } from "../../context/route" +import { useSync } from "../../context/sync" +import { useTheme } from "../../context/theme" +import { useDialog } from "../../ui/dialog" + +interface ThreadNode { + id: string + title: string + selectedText?: string + isCurrent: boolean + children: ThreadNode[] + depth: number +} + +export function DialogGraph() { + const routeData = useRoute() + const sync = useSync() + const { theme } = useTheme() + const dialog = useDialog() + + const sessions = createMemo(() => sync.data.session) + const currentSessionID = createMemo(() => + routeData.data.type === "session" ? routeData.data.sessionID : undefined + ) + + // Get current session + const currentSession = createMemo(() => { + const id = currentSessionID() + if (!id) return null + return sessions().find((s) => s.id === id) ?? null + }) + + // Build thread tree starting from the current session + const threadTree = createMemo((): ThreadNode[] => { + const id = currentSessionID() + if (!id) return [] + + const allSessions = sessions() + + // Find root: walk up parent chain from current session + let rootID = id + let current = allSessions.find((s) => s.id === id) + while (current?.parentID) { + rootID = current.parentID + current = allSessions.find((s) => s.id === current!.parentID) + } + + // Build tree recursively + const buildTree = (parentID: string, depth: number): ThreadNode[] => { + return allSessions + .filter((s) => s.parentID === parentID && s.metadata?.type === "thread") + .sort((a, b) => b.time.created - a.time.created) + .map((s) => ({ + id: s.id, + title: s.title, + selectedText: s.metadata?.selectedText as string | undefined, + isCurrent: s.id === id, + children: buildTree(s.id, depth + 1), + depth, + })) + } + + // Build from root + const rootNode = allSessions.find((s) => s.id === rootID) + const rootChildren = buildTree(rootID, 0) + + // If current session IS a thread, show it as root context + if (rootNode && rootID !== id) { + return [ + { + id: rootNode.id, + title: rootNode.title, + isCurrent: false, + children: rootChildren, + depth: 0, + }, + ] + } + + return rootChildren + }) + + // Also find ancestors of current session + const ancestors = createMemo((): ThreadNode[] => { + const id = currentSessionID() + if (!id) return [] + + const allSessions = sessions() + const result: ThreadNode[] = [] + let current = allSessions.find((s) => s.id === id) + + while (current?.parentID) { + const parent = allSessions.find((s) => s.id === current!.parentID) + if (parent && parent.metadata?.type === "thread") { + result.unshift({ + id: parent.id, + title: parent.title, + selectedText: parent.metadata?.selectedText as string | undefined, + isCurrent: false, + children: [], + depth: 0, + }) + } + current = parent + } + + return result + }) + + const handleSelect = (sessionID: string) => { + routeData.navigate({ type: "session", sessionID }) + dialog.clear() + } + + const renderNode = (node: ThreadNode) => { + return ( + + handleSelect(node.id)} + > + + + + + + + + + {node.title} + + + + + + + "{node.selectedText!.slice(0, 60)} + {node.selectedText!.length > 60 ? "..." : ""}" + + + + {(child) => renderNode(child)} + + ) + } + + return ( + + + + Thread Graph + + — {currentSession()!.title} + + + + + {/* Show ancestors (parent threads) */} + 0}> + + {(ancestor) => ( + + + + {ancestor.title} + + + + + "{ancestor.selectedText!.slice(0, 50)} + {ancestor.selectedText!.length > 50 ? "..." : ""}" + + + + + )} + + + + + + + {/* Show current session */} + + + + + {currentSession()!.title} + + + + + {/* Show child threads */} + 0} + fallback={ + + No child threads. Use /thread to create one. + + } + > + {(node) => renderNode(node)} + + + + Click to navigate · Esc to close + + + ) +} diff --git a/packages/tui/src/routes/session/dialog-session-graph.tsx b/packages/tui/src/routes/session/dialog-session-graph.tsx new file mode 100644 index 000000000000..7598a86b847f --- /dev/null +++ b/packages/tui/src/routes/session/dialog-session-graph.tsx @@ -0,0 +1,229 @@ +import { createMemo, createSignal, For, Show } from "solid-js" +import { useRoute } from "../../context/route" +import { useSync } from "../../context/sync" +import { useTheme } from "../../context/theme" +import { useDialog } from "../../ui/dialog" +import { isThread, isSubagent, getSelectedText, getThreadChildren, getSubagentChildren } from "../../util/session" +import type { Session } from "@opencode-ai/sdk/v2" + +interface TreeNode { + session: Session + isCurrent: boolean + threads: TreeNode[] + subagents: TreeNode[] + depth: number +} + +export function DialogSessionGraph() { + const routeData = useRoute() + const sync = useSync() + const { theme } = useTheme() + const dialog = useDialog() + + const sessions = createMemo(() => sync.data.session) + const currentSessionID = createMemo(() => + routeData.data.type === "session" ? routeData.data.sessionID : undefined + ) + + const currentSession = createMemo(() => { + const id = currentSessionID() + if (!id) return null + return sessions().find((s) => s.id === id) ?? null + }) + + const ancestors = createMemo((): Session[] => { + const id = currentSessionID() + if (!id) return [] + const session = sessions().find((s) => s.id === id) + if (!session) return [] + const result: Session[] = [] + let current = session + while (current.parentID) { + const parent = sessions().find((s) => s.id === current!.parentID) + if (!parent) break + if (isThread(parent)) { + result.unshift(parent) + } + current = parent + } + return result + }) + + const tree = createMemo((): TreeNode[] => { + const id = currentSessionID() + if (!id) return [] + + const allSessions = sessions() + + // Find root: walk up parent chain from current session + let rootID = id + let current = allSessions.find((s) => s.id === id) + while (current?.parentID) { + rootID = current.parentID + current = allSessions.find((s) => s.id === current!.parentID) + } + + // Build tree recursively for threads and subagents + const buildTree = (parentID: string, depth: number): TreeNode[] => { + const threads = getThreadChildren(parentID, allSessions).map((s) => ({ + session: s, + isCurrent: s.id === id, + threads: buildTree(s.id, depth + 1), + subagents: getSubagentChildren(s.id, allSessions).map((sa) => ({ + session: sa, + isCurrent: sa.id === id, + threads: [], + subagents: [], + depth: depth + 1, + })), + depth, + })) + + return threads + } + + const rootNode = allSessions.find((s) => s.id === rootID) + if (!rootNode) return [] + + // Build root node + const rootChildren = buildTree(rootID, 0) + const rootSubagents = getSubagentChildren(rootID, allSessions).map((sa) => ({ + session: sa, + isCurrent: sa.id === id, + threads: [], + subagents: [], + depth: 0, + })) + + return [ + { + session: rootNode, + isCurrent: rootID === id, + threads: rootChildren, + subagents: rootSubagents, + depth: 0, + }, + ] + }) + + const handleSelect = (sessionID: string) => { + routeData.navigate({ type: "session", sessionID }) + dialog.clear() + } + + const renderNode = (node: TreeNode, indent: number = 0) => { + const isSelectedText = getSelectedText(node.session) + const isSubagentNode = isSubagent(node.session) + + return ( + + handleSelect(node.session.id)} + > + + + + + + + + + + + + {node.session.title} + + + + + + + "{isSelectedText!.slice(0, 60)} + {isSelectedText!.length > 60 ? "..." : ""}" + + + + {(child) => renderNode(child, indent + 1)} + {(child) => renderNode(child, indent + 1)} + + ) + } + + return ( + + + + Session Graph + + — {currentSession()!.title} + + + + + 0}> + + {(ancestor) => ( + handleSelect(ancestor.id)} + > + + ↑ {ancestor.title} + + + + + "{getSelectedText(ancestor)!.slice(0, 50)} + {getSelectedText(ancestor)!.length > 50 ? "..." : ""}" + + + + + )} + + + + + + + {(node) => renderNode(node)} + + + + No threads or subagents. Use /thread to create one. + + + + + Click to navigate · Esc to close + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1a1eb0fcc15f..4603ca709f1b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -55,6 +55,9 @@ import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" +import { ThreadFooter } from "./thread-footer.tsx" +import { DialogGraph } from "./dialog-graph" +import { DialogSessionGraph } from "./dialog-session-graph" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" import { errorMessage } from "../../util/error" @@ -138,6 +141,8 @@ const sessionBindingCommands = [ "session.parent", "session.child.next", "session.child.previous", + "session.thread.new", + "session.graph", ] as const const sessionGlobalBindingCommands = [ @@ -232,7 +237,13 @@ export function Session() { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.question[x.id] ?? []) }) - const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) + const isThread = createMemo(() => session()?.metadata?.type === "thread") + const visible = createMemo(() => { + if (permissions().length > 0 || questions().length > 0) return false + if (!session()?.parentID) return true + if (isThread()) return true + return false + }) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { @@ -1078,6 +1089,109 @@ export function Session() { moveChild(-1) }), }, + { + title: "Create child thread", + value: "session.thread.new", + category: "Session", + slash: { + name: "thread", + }, + run: async () => { + dialog.clear() + + const currentSession = session() + if (!currentSession) return + + const selectedModel = local.model.current() + if (!selectedModel) { + toast.show({ + variant: "warning", + message: "Connect a provider to create a thread", + duration: 3000, + }) + return + } + + const agent = local.agent.current() + if (!agent) return + + // Capture selected text before creating the thread + const selection = renderer.getSelection() + const selectedText = selection?.getSelectedText() ?? "" + if (selection) renderer.clearSelection() + + // Gather parent context: last few messages for context + const parentMessages = messages() + const recentContext = parentMessages + .slice(-10) + .map((msg) => { + const parts = sync.data.part[msg.id] ?? [] + const textParts = parts + .filter((p): p is Extract => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("\n") + if (!textParts) return null + return `${msg.role === "user" ? "User" : "Assistant"}: ${textParts.slice(0, 500)}` + }) + .filter(Boolean) + .join("\n\n") + + // Create child session with thread metadata + const variant = local.model.variant.current() + const res = await sdk.client.session.create({ + parentID: route.sessionID, + title: selectedText.trim().slice(0, 50) || "Thread", + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + metadata: { + type: "thread", + selectedText: selectedText.trim() || undefined, + parentTitle: currentSession.title, + parentContext: recentContext || undefined, + }, + }) + + if (res.error) { + toast.show({ + message: "Failed to create child thread", + variant: "error", + }) + return + } + + const childSessionID = res.data.id + + // Context is stored in metadata and displayed in ThreadHeader + // No initial prompt is sent - user can start chatting directly + + // Navigate to the child session + navigate({ + type: "session", + sessionID: childSessionID, + }) + + toast.show({ + message: "Child thread created", + variant: "success", + duration: 2000, + }) + }, + }, + { + title: "Show session graph", + value: "session.graph", + category: "Session", + slash: { + name: "graph", + }, + run: () => { + dialog.replace(() => ) + }, + }, ]) const sessionCommands = createMemo(() => @@ -1294,7 +1408,12 @@ export function Session() { /> - + + + + + + (null) + + const session = createMemo(() => sync.session.get(route.sessionID)) + const parentSession = createMemo(() => { + const parentID = session()?.parentID + if (!parentID) return null + return sync.session.get(parentID) + }) + + const selectedText = createMemo(() => session()?.metadata?.selectedText as string | undefined) + const parentTitle = createMemo(() => session()?.metadata?.parentTitle as string | undefined) + + return ( + + + + + + Thread + + + · Child of {parentTitle()} + + + setHover("parent")} + onMouseOut={() => setHover(null)} + onMouseUp={() => keymap.dispatchCommand("session.parent")} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + > + + Parent {parentShortcut()} + + + + + + + Selected: "{selectedText()}" + + + + + + ) +} + function input(input: Record, omit?: string[]): string { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false diff --git a/packages/tui/src/routes/session/thread-footer.tsx b/packages/tui/src/routes/session/thread-footer.tsx new file mode 100644 index 000000000000..5a0281ebab0f --- /dev/null +++ b/packages/tui/src/routes/session/thread-footer.tsx @@ -0,0 +1,170 @@ +import { createMemo, createSignal, Show } from "solid-js" +import { useRouteData } from "../../context/route" +import { useSync } from "../../context/sync" +import { useTheme } from "../../context/theme" +import { SplitBorder } from "../../ui/border" +import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" +import { Locale } from "../../util/locale" +import { useTerminalDimensions } from "@opentui/solid" +import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { getSelectedText, getParentTitle, getThreadChildren } from "../../util/session" + +export function ThreadFooter() { + const route = useRouteData("session") + const sync = useSync() + const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const session = createMemo(() => sync.session.get(route.sessionID)) + + const threadInfo = createMemo(() => { + const s = session() + if (!s) return { selectedText: undefined, parentTitle: undefined, agent: undefined } + return { + selectedText: getSelectedText(s), + parentTitle: getParentTitle(s), + agent: s.agent, + } + }) + + const siblings = createMemo(() => { + const s = session() + if (!s?.parentID) return { prev: null, next: null, total: 0, index: 0 } + const children = getThreadChildren(s.parentID, sync.data.session) + const index = children.findIndex((c) => c.id === s.id) + return { + prev: index < children.length - 1 ? children[index + 1] : null, + next: index > 0 ? children[index - 1] : null, + total: children.length, + index: index + 1, + } + }) + + const usage = createMemo(() => { + const msg = messages() + const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + if (!last) return + + const tokens = + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + if (tokens <= 0) return + + const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined + const cost = session()?.cost ?? 0 + + const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }) + + return { + context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), + cost: cost > 0 ? money.format(cost) : undefined, + } + }) + + const { theme } = useTheme() + const keymap = useOpencodeKeymap() + const parentShortcut = useCommandShortcut("session.parent") + const prevShortcut = useCommandShortcut("session.child.previous") + const nextShortcut = useCommandShortcut("session.child.next") + const graphShortcut = useCommandShortcut("session.graph") + const [hover, setHover] = createSignal<"parent" | "prev" | "next" | "graph" | null>(null) + useTerminalDimensions() + + return ( + + + + + + Thread + + + · Child of {threadInfo().parentTitle} + + 0}> + + {" "}({siblings().index} of {siblings().total}) + + + + {(item) => ( + + {" "}{[item().context, item().cost].filter(Boolean).join(" · ")} + + )} + + + + setHover("graph")} + onMouseOut={() => setHover(null)} + onMouseUp={() => keymap.dispatchCommand("session.graph")} + backgroundColor={hover() === "graph" ? theme.backgroundElement : theme.backgroundPanel} + > + + Graph {graphShortcut()} + + + setHover("parent")} + onMouseOut={() => setHover(null)} + onMouseUp={() => keymap.dispatchCommand("session.parent")} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + > + + Parent {parentShortcut()} + + + setHover("prev")} + onMouseOut={() => setHover(null)} + onMouseUp={() => keymap.dispatchCommand("session.child.previous")} + backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} + opacity={siblings().prev ? 1.0 : 0.5} + > + + Prev {prevShortcut()} + + + setHover("next")} + onMouseOut={() => setHover(null)} + onMouseUp={() => keymap.dispatchCommand("session.child.next")} + backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} + opacity={siblings().next ? 1.0 : 0.5} + > + + Next {nextShortcut()} + + + + + + + + "{threadInfo().selectedText}" + + + + + + ) +} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d093..d40cb666174f 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,68 @@ +import type { Session } from "@opencode-ai/sdk/v2" + export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) } + +export type ThreadContext = { + type: "thread" + selectedText?: string + parentTitle?: string + parentContext?: string +} + +export function isThread(session: Session): boolean { + return session.metadata?.type === "thread" +} + +export function isSubagent(session: Session): boolean { + return session.parentID !== undefined && !isThread(session) +} + +export function getThreadContext(session: Session): ThreadContext | undefined { + if (!isThread(session)) return undefined + return { + type: "thread", + selectedText: session.metadata?.selectedText as string | undefined, + parentTitle: session.metadata?.parentTitle as string | undefined, + parentContext: session.metadata?.parentContext as string | undefined, + } +} + +export function getSelectedText(session: Session): string | undefined { + return session.metadata?.selectedText as string | undefined +} + +export function getParentTitle(session: Session): string | undefined { + return session.metadata?.parentTitle as string | undefined +} + +export function getParentContext(session: Session): string | undefined { + return session.metadata?.parentContext as string | undefined +} + +export function getAncestors(session: Session, allSessions: Session[]): Session[] { + const result: Session[] = [] + let current = session + while (current.parentID) { + const parent = allSessions.find((s) => s.id === current.parentID) + if (!parent) break + if (isThread(parent)) { + result.unshift(parent) + } + current = parent + } + return result +} + +export function getThreadChildren(sessionID: string, allSessions: Session[]): Session[] { + return allSessions + .filter((s) => s.parentID === sessionID && isThread(s)) + .sort((a, b) => b.time.created - a.time.created) +} + +export function getSubagentChildren(sessionID: string, allSessions: Session[]): Session[] { + return allSessions + .filter((s) => s.parentID === sessionID && isSubagent(s)) + .sort((a, b) => b.time.created - a.time.created) +}