Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ export function Session() {
value: "session.parent",
keybind: "session_parent",
category: "Session",
disabled: true,
disabled: !session()?.parentID,
onSelect: (dialog) => {
const parentID = session()?.parentID
if (parentID) {
Expand Down
106 changes: 99 additions & 7 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createMemo, For, Show, Switch, Match, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { useRoute } from "../../context/route"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Global } from "@/global"
import type { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"

export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
const route = useRoute()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!)
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
Expand All @@ -23,11 +23,42 @@ export function Sidebar(props: { sessionID: string }) {
diff: true,
todo: true,
lsp: true,
subagents: true,
})

const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const [spinnerIndex, setSpinnerIndex] = createSignal(0)

const intervalId = setInterval(() => {
setSpinnerIndex((prev) => (prev + 1) % spinnerFrames.length)
}, 100)
onCleanup(() => clearInterval(intervalId))

// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))

const taskToolParts = createMemo(() => {
const parts: ToolPart[] = []
for (const message of messages()) {
for (const part of sync.data.part[message.id] ?? []) {
if (part.type === "tool" && part.tool === "task") parts.push(part)
}
}
return parts
})

const subagentGroups = createMemo(() => {
const groups = new Map<string, ToolPart[]>()
for (const part of taskToolParts()) {
const input = part.state.input as Record<string, unknown>
const agentName = input?.subagent_type as string
if (!agentName) continue
if (!groups.has(agentName)) groups.set(agentName, [])
groups.get(agentName)!.push(part)
}
return Array.from(groups.entries())
})

const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
Expand All @@ -48,7 +79,6 @@ export function Sidebar(props: { sessionID: string }) {
}
})

const keybind = useKeybind()
const directory = useDirectory()

const hasProviders = createMemo(() =>
Expand Down Expand Up @@ -176,6 +206,68 @@ export function Sidebar(props: { sessionID: string }) {
</For>
</Show>
</box>
<Show when={subagentGroups().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => subagentGroups().length > 2 && setExpanded("subagents", !expanded.subagents)}
>
<Show when={subagentGroups().length > 2}>
<text fg={theme.text}>{expanded.subagents ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Subagents</b>
</text>
</box>
<Show when={subagentGroups().length <= 2 || expanded.subagents}>
<For each={subagentGroups()}>
{([agentName, parts]) => {
const hasActive = () =>
parts.some((p) => p.state.status === "running" || p.state.status === "pending")
return (
<box>
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: hasActive() ? theme.success : theme.text }}>
</text>
<text fg={theme.text} wrapMode="word">
{agentName}
</text>
</box>
<For each={parts}>
{(part) => {
const isActive = () => part.state.status === "running" || part.state.status === "pending"
const isError = () => part.state.status === "error"
const input = part.state.input as Record<string, unknown>
const description = (input?.description as string) ?? ""
const sessionId = part.sessionID
return (
<box
flexDirection="row"
gap={1}
paddingLeft={2}
onMouseDown={() => {
route.navigate({ type: "session", sessionID: sessionId })
}}
>
<text flexShrink={0} fg={isActive() ? theme.success : theme.textMuted}>
{isActive() ? spinnerFrames[spinnerIndex()] : isError() ? "✗" : "✓"}
</text>
<text fg={isActive() ? theme.text : theme.textMuted} wrapMode="word">
{description}
</text>
</box>
)
}}
</For>
</box>
)
}}
</For>
</Show>
</box>
</Show>
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
<box>
<box
Expand Down Expand Up @@ -277,9 +369,9 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<text fg={theme.text}>{directory()}</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
<span style={{ fg: theme.success }}>•</span> <b>shuv</b>
<span style={{ fg: theme.text }}>
<b>Code</b>
<b>code</b>
</span>{" "}
<span>{Installation.VERSION}</span>
</text>
Expand Down
Loading