Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 64 additions & 19 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<string, unknown> }> = []
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: `<system-reminder>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.</system-reminder>`,
synthetic: true,
metadata: { kind: "thread_context" },
})
}

if (parentContext) {
threadContextParts.push({
type: "text",
text: `<system-reminder>Here is the recent conversation context from the parent session:\n\n${parentContext}\n\nContinue the exploration from this context.</system-reminder>`,
synthetic: true,
metadata: { kind: "thread_context" },
})
}
}

sdk.client.session
.prompt(
{
Expand All @@ -1094,6 +1138,7 @@ export function Prompt(props: PromptProps) {
model: selectedModel,
variant,
parts: [
...threadContextParts,
...editorParts,
{
type: "text",
Expand Down
4 changes: 4 additions & 0 deletions packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<leader>t", "Create new thread from selection"),
session_graph: keybind("<leader>g", "Show session graph"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
Expand Down Expand Up @@ -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",
Expand Down
232 changes: 232 additions & 0 deletions packages/tui/src/routes/session/dialog-graph.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<box flexDirection="column">
<box
paddingLeft={2 + node.depth * 2}
paddingRight={2}
paddingTop={0.3}
paddingBottom={0.3}
onMouseUp={() => handleSelect(node.id)}
>
<text>
<Show when={node.isCurrent}>
<span style={{ fg: theme.primary }}>▸ </span>
</Show>
<Show when={!node.isCurrent}>
<span style={{ fg: theme.textMuted }}> </span>
</Show>
<span
style={{
fg: node.isCurrent ? theme.primary : theme.text,
bold: node.isCurrent,
}}
>
{node.title}
</span>
</text>
</box>
<Show when={node.selectedText}>
<box paddingLeft={4 + node.depth * 2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
"{node.selectedText!.slice(0, 60)}
{node.selectedText!.length > 60 ? "..." : ""}"
</text>
</box>
</Show>
<For each={node.children}>{(child) => renderNode(child)}</For>
</box>
)
}

return (
<box
flexDirection="column"
width={65}
height={20}
border={["top", "bottom", "left", "right"]}
borderColor={theme.border}
backgroundColor={theme.background}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
border={["bottom"]}
borderColor={theme.border}
>
<text fg={theme.text}>
<b>Thread Graph</b>
<Show when={currentSession()}>
<span style={{ fg: theme.textMuted }}> — {currentSession()!.title}</span>
</Show>
</text>
</box>
<box flexDirection="column" flexGrow={1} paddingTop={1} overflow="hidden">
{/* Show ancestors (parent threads) */}
<Show when={ancestors().length > 0}>
<For each={ancestors()}>
{(ancestor) => (
<box paddingLeft={2 + ancestor.depth * 2} paddingRight={2} paddingTop={0.3} paddingBottom={0.3}>
<text>
<span style={{ fg: theme.textMuted }}>↑ </span>
<span style={{ fg: theme.textMuted }}>{ancestor.title}</span>
</text>
<Show when={ancestor.selectedText}>
<box paddingLeft={4 + ancestor.depth * 2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
"{ancestor.selectedText!.slice(0, 50)}
{ancestor.selectedText!.length > 50 ? "..." : ""}"
</text>
</box>
</Show>
</box>
)}
</For>
<box paddingLeft={2} paddingTop={0.3} paddingBottom={0.3}>
<text fg={theme.textMuted}>│</text>
</box>
</Show>

{/* Show current session */}
<Show when={currentSession()}>
<box paddingLeft={2} paddingRight={2} paddingTop={0.3} paddingBottom={0.3}>
<text>
<span style={{ fg: theme.primary }}>▸ </span>
<span style={{ fg: theme.primary, bold: true }}>{currentSession()!.title}</span>
</text>
</box>
</Show>

{/* Show child threads */}
<Show
when={threadTree().length > 0}
fallback={
<box paddingLeft={2} paddingTop={0.5}>
<text fg={theme.textMuted}>No child threads. Use /thread to create one.</text>
</box>
}
>
<For each={threadTree()}>{(node) => renderNode(node)}</For>
</Show>
</box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} border={["top"]} borderColor={theme.border}>
<text fg={theme.textMuted}>Click to navigate · Esc to close</text>
</box>
</box>
)
}
Loading
Loading