diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-team.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-team.tsx new file mode 100644 index 000000000000..c9de53cfa08e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-team.tsx @@ -0,0 +1,179 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { createMemo, onMount, Show } from "solid-js" +import { useTheme } from "../context/theme" +import { useSync } from "../context/sync" +import { useRouteData } from "../context/route" +import { useRoute } from "../context/route" +import { useToast } from "../ui/toast" +import { useSDK } from "../context/sdk" + +function statusIcon(status: string): string { + switch (status) { + case "busy": + return "*" + case "ready": + return "o" + case "shutdown_requested": + return "!" + case "shutdown": + return "x" + case "completed": + return "+" + case "in_progress": + return ">" + case "blocked": + return "#" + case "cancelled": + return "-" + case "pending": + return " " + default: + return "?" + } +} + +function statusColor(status: string, theme: any): string { + switch (status) { + case "busy": + return theme.primary + case "ready": + return theme.textMuted + case "shutdown_requested": + return theme.warning + case "shutdown": + return theme.error + case "completed": + return theme.success + case "in_progress": + return theme.primary + case "blocked": + return theme.error + case "pending": + return theme.textMuted + default: + return theme.textMuted + } +} + +export function DialogTeam() { + const dialog = useDialog() + const { theme } = useTheme() + const sync = useSync() + const route = useRouteData("session") + const nav = useRoute() + const toast = useToast() + const sdk = useSDK() + + const teamInfo = createMemo(() => sync.data.team[route.sessionID]) + + // Refresh team data on open + onMount(() => { + dialog.setSize("large") + fetch(`${sdk.url}/team/by-session/${route.sessionID}`) + .then((r: Response) => r.json()) + .then((data: any) => { + if (!data) return + sync.set("team", route.sessionID, { + teamName: data.team.name, + role: data.role, + memberName: data.memberName, + members: data.team.members ?? [], + tasks: data.tasks ?? [], + }) + }) + .catch(() => {}) + }) + + const options = createMemo((): DialogSelectOption[] => { + const info = teamInfo() + if (!info) return [] + + const memberOptions: DialogSelectOption[] = info.members.map((m) => ({ + title: `${m.name} (@${m.agent})`, + value: `member:${m.sessionID}`, + category: "Teammates", + footer: `Status: ${m.status}`, + gutter: {statusIcon(m.status)}, + })) + + const taskOptions: DialogSelectOption[] = (info.tasks ?? []).map((t) => ({ + title: t.content, + value: `task:${t.id}`, + category: "Shared Tasks", + footer: [ + t.status, + t.assignee ? `@${t.assignee}` : null, + t.depends_on?.length ? `depends: ${t.depends_on.join(", ")}` : null, + ] + .filter(Boolean) + .join(" | "), + gutter: {statusIcon(t.status)}, + disabled: t.status === "completed" || t.status === "cancelled", + })) + + return [...memberOptions, ...taskOptions] + }) + + const handleSelect = (option: DialogSelectOption) => { + const [type, id] = option.value.split(":", 2) + if (type === "member" && id) { + dialog.clear() + nav.navigate({ type: "session", sessionID: id }) + } + } + + return ( + + + + Agent Team + + esc + + No active team for this session. + The lead agent can create a team using the team_create tool. + + } + > + { + const [type] = option.value.split(":", 2) + if (type === "member") { + toast.show({ message: "Use team_message tool from the prompt to message teammates", variant: "info" }) + } + }, + }, + { + keybind: { name: "l", ctrl: false, meta: false, shift: false, leader: false }, + title: "go to lead", + onTrigger: () => { + const info = teamInfo() + if (!info) return + // Find lead session: iterate members looking for the session that has role=lead + // Or look up from team data + for (const [sid, entry] of Object.entries(sync.data.team)) { + const e = entry as any + if (e?.teamName === info.teamName && e?.role === "lead") { + dialog.clear() + nav.navigate({ type: "session", sessionID: sid }) + return + } + } + }, + }, + ]} + /> + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8576dd5763ab..41b2f636e207 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -41,6 +41,10 @@ export type PromptProps = { ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean + /** When set, submit sends a team message to this teammate instead of a normal prompt */ + selectedTeammate?: string | null + /** Called after a team message is sent so the parent can reset selection state */ + onTeammateMessageSent?: () => void } export type PromptRef = { @@ -68,6 +72,16 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + const teamBusy = createMemo(() => { + const sid = props.sessionID + if (!sid) return 0 + const team = sync.data.team?.[sid] + if (!team || team.role !== "lead") return 0 + return team.members.filter((m) => { + if (m.status === "shutdown") return false + return ["starting", "running", "cancel_requested", "cancelling", "completing"].includes(m.execution_status) + }).length + }) const history = usePromptHistory() const stash = usePromptStash() const command = useCommandDialog() @@ -203,7 +217,7 @@ export function Prompt(props: PromptProps) { keybind: "session_interrupt", category: "Session", hidden: true, - enabled: status().type !== "idle", + enabled: status().type !== "idle" || teamBusy() > 0, onSelect: (dialog) => { if (autocomplete.visible) return if (!input.focused) return @@ -214,6 +228,22 @@ export function Prompt(props: PromptProps) { } if (!props.sessionID) return + // Lead is idle but teammates are busy — cancel teammates directly + if (status().type === "idle" && teamBusy() > 0) { + const team = sync.data.team?.[props.sessionID] + if (team?.members) { + for (const m of team.members) { + if ( + ["starting", "running", "cancel_requested", "cancelling", "completing"].includes(m.execution_status) + ) { + sdk.client.session.abort({ sessionID: m.sessionID }).catch(() => {}) + } + } + } + dialog.clear() + return + } + setStore("interrupt", store.interrupt + 1) setTimeout(() => { @@ -221,6 +251,8 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { + // Abort the lead session — server-side abort propagation + // (session.ts route) will also cancel active teammates sdk.client.session.abort({ sessionID: props.sessionID, }) @@ -558,7 +590,28 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() - if (store.mode === "shell") { + // Team message interception: when a teammate is selected, send via team_message endpoint + if (props.selectedTeammate) { + const teammate = props.selectedTeammate + try { + const res = await fetch(`${sdk.url}/session/${sessionID}/team-message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + agent: local.agent.current().name, + to: teammate, + text: inputText, + }), + }) + if (!res.ok) { + toast.show({ message: `Failed to message @${teammate}`, variant: "error" }) + } + } catch { + toast.show({ message: `Failed to message @${teammate}`, variant: "error" }) + } + props.onTeammateMessageSent?.() + // Fall through to the clear logic below + } else if (store.mode === "shell") { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, @@ -1021,7 +1074,21 @@ export function Prompt(props: PromptProps) { /> - }> + 0}> + + [⋯]}> + + + + {teamBusy()} teammate{teamBusy() > 1 ? "s" : ""} working + + + + } + > + tasks: Array<{ + id: string + content: string + status: string + priority: string + assignee?: string + depends_on?: string[] + }> + } + } }>({ provider_next: { all: [], @@ -100,6 +136,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, + team: {}, }) const sdk = useSDK() @@ -194,14 +231,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break case "session.deleted": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + const id = event.properties.info.id + const result = Binary.search(store.session, id, (s) => s.id) if (result.found) { + const messages = store.message[id] setStore( - "session", produce((draft) => { - draft.splice(result.index, 1) + draft.session.splice(result.index, 1) + if (messages) { + for (const msg of messages) { + if (msg?.id) delete draft.part[msg.id] + } + } + delete draft.message[id] + delete draft.session_diff[id] + delete draft.todo[id] + delete draft.session_status[id] + delete draft.permission[id] + delete draft.question[id] + delete draft.team[id] }), ) + fullSyncedSessions.delete(id) } break } @@ -322,6 +373,112 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + // ---------- Custom events (not in typed Event union) ---------- + default: { + const raw = event as any + + // Team events arrive as raw bus events with type "team.*" + if (typeof raw.type !== "string" || !raw.type.startsWith("team.")) break + + switch (raw.type) { + case "team.created": { + const team = raw.properties.team + setStore("team", team.leadSessionID, { + teamName: team.name, + role: "lead", + delegate: team.delegate, + members: team.members ?? [], + tasks: [], + }) + break + } + case "team.member.spawned": { + const { teamName, member } = raw.properties + // Update lead's entry + for (const [sid, entry] of Object.entries(store.team)) { + const e = entry as any + if (e?.teamName === teamName) { + setStore("team", sid, "members", (prev: any[]) => [...(prev ?? []), member]) + } + } + // Add member's own entry + setStore("team", member.sessionID, { + teamName, + role: "member", + memberName: member.name, + members: [], // members don't track other members directly + tasks: [], + }) + break + } + case "team.member.status": { + const { teamName, memberName, status } = raw.properties + for (const [sid, entry] of Object.entries(store.team)) { + const e = entry as any + if (e?.teamName === teamName && e?.members) { + const idx = e.members.findIndex((m: any) => m.name === memberName) + if (idx >= 0) { + setStore("team", sid, "members", idx, "status", status) + } + } + } + break + } + case "team.member.execution": { + const { teamName, memberName, status } = raw.properties + for (const [sid, entry] of Object.entries(store.team)) { + const e = entry as any + if (e?.teamName === teamName && e?.members) { + const idx = e.members.findIndex((m: any) => m.name === memberName) + if (idx >= 0) { + setStore("team", sid, "members", idx, "execution_status", status) + } + } + } + break + } + case "team.task.updated": { + const { teamName, tasks: newTasks } = raw.properties + for (const [sid, entry] of Object.entries(store.team)) { + const e = entry as any + if (e?.teamName === teamName) { + setStore("team", sid, "tasks", reconcile(newTasks)) + } + } + break + } + case "team.task.claimed": { + const { teamName, taskId, memberName } = raw.properties + for (const [sid, entry] of Object.entries(store.team)) { + const e = entry as any + if (e?.teamName === teamName && e?.tasks) { + const idx = e.tasks.findIndex((t: any) => t.id === taskId) + if (idx >= 0) { + setStore("team", sid, "tasks", idx, "status", "in_progress") + setStore("team", sid, "tasks", idx, "assignee", memberName) + } + } + } + break + } + case "team.cleaned": { + const { teamName } = raw.properties + setStore( + "team", + produce((draft: any) => { + for (const [sid, entry] of Object.entries(draft)) { + if ((entry as any)?.teamName === teamName) { + delete draft[sid] + } + } + }), + ) + break + } + } + break + } } }) @@ -461,6 +618,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) fullSyncedSessions.add(sessionID) + + // Fetch team context for this session (non-blocking). + if (!store.team[sessionID]) { + fetch(`${sdk.url}/team/by-session/${sessionID}`) + .then((r: Response) => r.json()) + .then((data: any) => { + if (!data) return + setStore("team", sessionID, { + teamName: data.team.name, + role: data.role, + memberName: data.memberName, + delegate: data.team.delegate, + members: data.team.members ?? [], + tasks: data.tasks ?? [], + }) + }) + .catch(() => {}) // Team fetch is non-critical + } }, }, bootstrap, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0c5ea9a85723..c0bacc05b727 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,4 +1,4 @@ -import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js" +import { type Accessor, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" import { useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { pipe, sumBy } from "remeda" @@ -7,7 +7,137 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" +import { Installation } from "@/installation" import { useTerminalDimensions } from "@opentui/solid" +import { useRoute } from "@tui/context/route" + +function memberStatusIcon(status: string): string { + switch (status) { + case "busy": + return "*" + case "ready": + return "o" + case "shutdown_requested": + return "!" + case "shutdown": + return "x" + case "error": + return "!" + default: + return "?" + } +} + +function TeamBadge(props: { teamInfo: any }) { + const { theme } = useTheme() + const info = () => props.teamInfo + if (!info()) return null + + const activeCount = () => info().members?.filter((m: any) => m.status === "busy").length ?? 0 + const idleCount = () => info().members?.filter((m: any) => m.status === "ready").length ?? 0 + const totalCount = () => info().members?.length ?? 0 + + return ( + + + + Team: {info().teamName} ({activeCount()} active, {idleCount()} idle, {totalCount()} total) + + + + + {info().memberName} @{info().teamName} + + + + ) +} + +/** Persistent status bar showing team members — displayed below the header when a team is active */ +function TeamStatusBar(props: { teamInfo: any }) { + const { theme } = useTheme() + const nav = useRoute() + const info = () => props.teamInfo + if (!info()) return null + const members = () => info().members ?? [] + if (members().length === 0) return null + + const tasks = () => info().tasks ?? [] + const completedTasks = () => tasks().filter((t: any) => t.status === "completed").length + + // Find what task a member is working on + const memberTask = (memberName: string) => { + return tasks().find((t: any) => t.assignee === memberName && t.status === "in_progress") + } + + return ( + + + {(member: any) => { + const statusColor = () => { + if (member.planApproval === "pending") return theme.warning + switch (member.status) { + case "busy": + return theme.success + case "ready": + return theme.textMuted + case "shutdown_requested": + return theme.warning + case "shutdown": + return theme.error + case "error": + return theme.error + default: + return theme.textMuted + } + } + const task = () => memberTask(member.name) + const planLabel = () => { + if (member.planApproval === "pending") return " [awaiting plan approval]" + if (member.planApproval === "approved") return " [plan approved]" + return "" + } + return ( + { + if (member.sessionID) { + nav.navigate({ type: "session", sessionID: member.sessionID }) + } + }} + > + + {memberStatusIcon(member.status)} {member.name} + {member.model ? ` (${member.model})` : ""} + {planLabel()} + + + + — {task()!.content.length > 50 ? task()!.content.slice(0, 50) + "..." : task()!.content} + + + + ) + }} + + 0}> + + tasks: {completedTasks()}/{tasks().length} + | delegate mode + + + + ) +} const Title = (props: { session: Accessor }) => { const { theme } = useTheme() @@ -29,11 +159,12 @@ const ContextInfo = (props: { context: Accessor; cost: Acces ) } -export function Header() { +export function Header(props: { sidebarVisible?: boolean }) { const route = useRouteData("session") const sync = useSync() const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const teamInfo = createMemo(() => sync.data.team[route.sessionID]) const cost = createMemo(() => { const total = pipe( @@ -53,6 +184,11 @@ export function Header() { last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID] let result = total.toLocaleString() + if (last.tokens.cache.read > 0) { + const totalInput = last.tokens.input + last.tokens.cache.read + last.tokens.cache.write + const pct = Math.round((last.tokens.cache.read / totalInput) * 100) + result += ` (${pct}% cached)` + } if (model?.limit.context) { result += " " + Math.round((total / model.limit.context) * 100) + "%" } @@ -83,10 +219,18 @@ export function Header() { - - Subagent session - - + + + {teamInfo() ? "Teammate" : "Subagent"} session + + + + + + + + v{Installation.VERSION} + - - - <ContextInfo context={context} cost={cost} /> + <box flexDirection="column" gap={0}> + <box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}> + <Title session={session} /> + <box flexDirection="row" gap={1} flexShrink={0}> + <ContextInfo context={context} cost={cost} /> + <text fg={theme.textMuted}>v{Installation.VERSION}</text> + </box> + </box> + <Show when={teamInfo()}> + <TeamBadge teamInfo={teamInfo()} /> + </Show> </box> </Match> </Switch> </box> + <Show when={teamInfo() && !props.sidebarVisible}> + <TeamStatusBar teamInfo={teamInfo()} /> + </Show> </box> ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 70ff5eaf9fb6..38b735e3a5db 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -17,7 +17,7 @@ import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" -import { selectedForeground, useTheme } from "@tui/context/theme" +import { useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, @@ -152,15 +152,26 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + // Teammate selection state for Shift+Up/Down inline messaging + const [selectedTeammate, setSelectedTeammate] = createSignal<string | null>(null) + const teamInfo = createMemo(() => sync.data.team[route.sessionID]) + const teamMembers = createMemo(() => { + const info = teamInfo() + if (!info?.members?.length) return [] + return info.members.filter((m: any) => m.sessionID && m.status !== "shutdown") + }) + const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { - if (session()?.parentID) return false + // Hide sidebar for task subagents (child sessions) but not for teammates + if (session()?.parentID && !sync.data.team[route.sessionID]) return false if (sidebarOpen()) return true if (sidebar() === "auto" && wide()) return true return false }) const showTimestamps = createMemo(() => timestamps() === "show") - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const sidebarWidth = createMemo(() => kv.get("sidebar_width", 42) as number) + const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? sidebarWidth() : 0) - 4) const scrollAcceleration = createMemo(() => { const tui = sync.data.config.tui @@ -243,6 +254,60 @@ export function Session() { } }) + // Escape in a teammate child session: cancel that teammate's prompt loop + useKeyboard((evt) => { + if (evt.name !== "escape") return + const s = session() + if (!s?.parentID) return + // Only for teammate sessions (not subagent views) + const team = sync.data.team[route.sessionID] + if (!team) return + const status = sync.data.session_status?.[route.sessionID] + if (status?.type !== "busy") return + evt.preventDefault() + sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) + }) + + // Shift+Up/Down: Cycle through teammates for inline messaging (only when team is active) + useKeyboard((evt) => { + // Escape deselects the current teammate + if (evt.name === "escape" && selectedTeammate()) { + evt.preventDefault() + setSelectedTeammate(null) + return + } + + if (!evt.shift) return + if (evt.name !== "up" && evt.name !== "down") return + const members = teamMembers() + if (members.length === 0) return + + evt.preventDefault() + + const current = selectedTeammate() + if (current === null) { + // Select first/last teammate + setSelectedTeammate(evt.name === "down" ? members[0].name : members[members.length - 1].name) + } else { + const idx = members.findIndex((m: any) => m.name === current) + if (evt.name === "down") { + // Next teammate or deselect (wrap to null) + if (idx >= members.length - 1) { + setSelectedTeammate(null) // Deselect — back to normal prompt + } else { + setSelectedTeammate(members[idx + 1].name) + } + } else { + // Previous teammate or deselect + if (idx <= 0) { + setSelectedTeammate(null) + } else { + setSelectedTeammate(members[idx - 1].name) + } + } + } + }) + // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() @@ -885,6 +950,121 @@ export function Session() { dialog.clear() }, }, + { + title: "Next teammate", + value: "team.next", + keybind: "team_next" as any, + category: "Team", + hidden: true, + enabled: !!sync.data.team[route.sessionID], + onSelect: (dialog) => { + const teamInfo = sync.data.team[route.sessionID] + if (!teamInfo?.members?.length) { + dialog.clear() + return + } + const members = teamInfo.members.filter((m: any) => m.sessionID) + if (members.length === 0) { + dialog.clear() + return + } + // Find current position (lead or member) + const currentIdx = members.findIndex((m: any) => m.sessionID === route.sessionID) + if (currentIdx >= 0) { + // Currently viewing a member — go to next member or wrap to lead + const nextIdx = (currentIdx + 1) % members.length + navigate({ type: "session", sessionID: members[nextIdx].sessionID }) + } else { + // Currently viewing lead — go to first member + navigate({ type: "session", sessionID: members[0].sessionID }) + } + dialog.clear() + }, + }, + { + title: "Previous teammate", + value: "team.previous", + keybind: "team_previous" as any, + category: "Team", + hidden: true, + enabled: !!sync.data.team[route.sessionID], + onSelect: (dialog) => { + const teamInfo = sync.data.team[route.sessionID] + if (!teamInfo?.members?.length) { + dialog.clear() + return + } + const members = teamInfo.members.filter((m: any) => m.sessionID) + if (members.length === 0) { + dialog.clear() + return + } + const currentIdx = members.findIndex((m: any) => m.sessionID === route.sessionID) + if (currentIdx >= 0) { + // Currently viewing a member — go to previous member or wrap to last + const prevIdx = (currentIdx - 1 + members.length) % members.length + navigate({ type: "session", sessionID: members[prevIdx].sessionID }) + } else { + // Currently viewing lead — go to last member + navigate({ type: "session", sessionID: members[members.length - 1].sessionID }) + } + dialog.clear() + }, + }, + { + title: "Go to team lead", + value: "team.lead", + category: "Team", + hidden: true, + enabled: !!sync.data.team[route.sessionID], + onSelect: (dialog) => { + const teamInfo = sync.data.team[route.sessionID] + if (!teamInfo) { + dialog.clear() + return + } + // Find the lead session + for (const [sid, entry] of Object.entries(sync.data.team)) { + const e = entry as any + if (e?.teamName === teamInfo.teamName && e?.role === "lead") { + navigate({ type: "session", sessionID: sid }) + dialog.clear() + return + } + } + dialog.clear() + }, + }, + { + title: "Toggle delegate mode", + value: "team.delegate.toggle", + keybind: "team_delegate" as any, + slash: { name: "delegate" }, + category: "Team", + enabled: !!sync.data.team[route.sessionID] && sync.data.team[route.sessionID]?.role === "lead", + onSelect: async (dialog) => { + const info = sync.data.team[route.sessionID] + if (!info || info.role !== "lead") { + dialog.clear() + return + } + const isDelegate = info.delegate + try { + await fetch(`${sdk.url}/team/${info.teamName}/delegate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !isDelegate }), + }) + toast.show({ + message: !isDelegate ? "Delegate mode enabled — coordination only" : "Delegate mode disabled", + variant: "info", + }) + } catch { + toast.show({ message: "Failed to toggle delegate mode", variant: "error" }) + } + dialog.clear() + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) @@ -958,8 +1138,8 @@ export function Session() { <box flexDirection="row"> <box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}> <Show when={session()}> - <Show when={!sidebarVisible() || !wide()}> - <Header /> + <Show when={!sidebarVisible() || !wide() || session()?.parentID}> + <Header sidebarVisible={sidebarVisible()} /> </Show> <scrollbox ref={(r) => (scroll = r)} @@ -1082,6 +1262,14 @@ export function Session() { <Show when={permissions().length === 0 && questions().length > 0}> <QuestionPrompt request={questions()[0]} /> </Show> + <Show when={selectedTeammate()}> + <box paddingLeft={3} flexShrink={0}> + <text fg={theme.primary}> + Messaging: <span style={{ bold: true }}>@{selectedTeammate()}</span> + <span style={{ fg: theme.textMuted }}> (Shift+Up/Down to change, Esc to deselect)</span> + </text> + </box> + </Show> <Prompt visible={!session()?.parentID && permissions().length === 0 && questions().length === 0} ref={(r) => { @@ -1093,12 +1281,17 @@ export function Session() { } }} disabled={permissions().length > 0 || questions().length > 0} + selectedTeammate={selectedTeammate()} + onTeammateMessageSent={() => setSelectedTeammate(null)} onSubmit={() => { toBottom() }} sessionID={route.sessionID} /> </box> + <Show when={!sidebarVisible() || !wide()}> + <Footer /> + </Show> </Show> <Toast /> </box> @@ -1152,8 +1345,7 @@ function UserMessage(props: { const { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) - const color = createMemo(() => local.agent.color(props.message.agent)) - const queuedFg = createMemo(() => selectedForeground(theme, color())) + const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) @@ -1215,7 +1407,7 @@ function UserMessage(props: { } > <text fg={theme.textMuted}> - <span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span> + <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> </text> </Show> </box> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4ffe91558ed7..2b542ad6f115 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,16 +1,18 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" import path from "path" -import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2" import { Global } from "@/global" import { Installation } from "@/installation" -import { useKeybind } from "../../context/keybind" +import { useTerminalDimensions } from "@opentui/solid" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" +import { useRoute } from "../../context/route" import { TodoItem } from "../../component/todo-item" +import { Spinner } from "../../component/spinner" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -25,6 +27,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { diff: true, todo: true, lsp: true, + team: true, }) // Sort MCP servers alphabetically for consistent display order @@ -62,252 +65,671 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const directory = useDirectory() const kv = useKV() + const teamInfo = createMemo(() => sync.data.team[props.sessionID]) + const teamMembers = createMemo( + () => + (teamInfo()?.members ?? []) as Array<{ + name: string + sessionID: string + agent: string + status: string + model?: string + planApproval?: string + }>, + ) + const teamTasks = createMemo( + () => + (teamInfo()?.tasks ?? []) as Array<{ + id: string + content: string + status: string + priority: string + assignee?: string + depends_on?: string[] + }>, + ) const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) + const dimensions = useTerminalDimensions() + const [dragging, setDragging] = createSignal(false) + const dragStart = { x: 0, width: 42 } + const MIN_WIDTH = 32 + const width = createMemo(() => { + const max = Math.floor(dimensions().width * 0.75) + return Math.max(MIN_WIDTH, Math.min(max, kv.get("sidebar_width", 42) as number)) + }) return ( <Show when={session()}> <box - backgroundColor={theme.backgroundPanel} - width={42} + flexDirection="row" + width={width()} height="100%" - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - paddingRight={2} + flexShrink={0} position={props.overlay ? "absolute" : "relative"} > - <scrollbox flexGrow={1}> - <box flexShrink={0} gap={1} paddingRight={1}> - <box paddingRight={1}> - <text fg={theme.text}> - <b>{session().title}</b> - </text> - <Show when={session().share?.url}> - <text fg={theme.textMuted}>{session().share!.url}</text> + <box + width={1} + height="100%" + backgroundColor={dragging() ? theme.primary : theme.border} + onMouseDown={(e: any) => { + setDragging(true) + dragStart.x = e.x + dragStart.width = width() + }} + onMouseDrag={(e: any) => { + const delta = dragStart.x - e.x + const max = Math.floor(dimensions().width * 0.75) + const next = Math.max(MIN_WIDTH, Math.min(max, dragStart.width + delta)) + kv.set("sidebar_width", next) + }} + onMouseDragEnd={() => setDragging(false)} + onMouseUp={() => setDragging(false)} + flexShrink={0} + /> + <box + backgroundColor={theme.backgroundPanel} + width={width() - 2} + height="100%" + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + paddingRight={1} + flexShrink={0} + overflow="hidden" + > + <scrollbox flexGrow={1}> + <box flexShrink={0} gap={1} paddingRight={1}> + <box paddingRight={1}> + <text fg={theme.text}> + <b>{session().title}</b> + </text> + <Show when={session().share?.url}> + <text fg={theme.textMuted}>{session().share!.url}</text> + </Show> + </box> + <box> + <text fg={theme.text}> + <b>Context</b> + </text> + <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text> + <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text> + <text fg={theme.textMuted}>{cost()} spent</text> + </box> + <Show when={mcpEntries().length > 0}> + <box> + <box + flexDirection="row" + gap={1} + onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} + > + <Show when={mcpEntries().length > 2}> + <text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text> + </Show> + <text fg={theme.text}> + <b>MCP</b> + <Show when={!expanded.mcp}> + <span style={{ fg: theme.textMuted }}> + {" "} + ({connectedMcpCount()} active + {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""}) + </span> + </Show> + </text> + </box> + <Show when={mcpEntries().length <= 2 || expanded.mcp}> + <For each={mcpEntries()}> + {([key, item]) => ( + <box flexDirection="row" gap={1}> + <text + flexShrink={0} + style={{ + fg: ( + { + connected: theme.success, + failed: theme.error, + disabled: theme.textMuted, + needs_auth: theme.warning, + needs_client_registration: theme.error, + } as Record<string, typeof theme.success> + )[item.status], + }} + > + • + </text> + <text fg={theme.text} wrapMode="word"> + {key}{" "} + <span style={{ fg: theme.textMuted }}> + <Switch fallback={item.status}> + <Match when={item.status === "connected"}>Connected</Match> + <Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match> + <Match when={item.status === "disabled"}>Disabled</Match> + <Match when={(item.status as string) === "needs_auth"}>Needs auth</Match> + <Match when={(item.status as string) === "needs_client_registration"}> + Needs client ID + </Match> + </Switch> + </span> + </text> + </box> + )} + </For> + </Show> + </box> </Show> - </box> - <box> - <text fg={theme.text}> - <b>Context</b> - </text> - <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text> - <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text> - <text fg={theme.textMuted}>{cost()} spent</text> - </box> - <Show when={mcpEntries().length > 0}> <box> <box flexDirection="row" gap={1} - onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} + onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)} > - <Show when={mcpEntries().length > 2}> - <text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text> + <Show when={sync.data.lsp.length > 2}> + <text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text> </Show> <text fg={theme.text}> - <b>MCP</b> - <Show when={!expanded.mcp}> - <span style={{ fg: theme.textMuted }}> - {" "} - ({connectedMcpCount()} active - {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""}) - </span> - </Show> + <b>LSP</b> </text> </box> - <Show when={mcpEntries().length <= 2 || expanded.mcp}> - <For each={mcpEntries()}> - {([key, item]) => ( + <Show when={sync.data.lsp.length <= 2 || expanded.lsp}> + <Show when={sync.data.lsp.length === 0}> + <text fg={theme.textMuted}> + {sync.data.config.lsp === false + ? "LSPs have been disabled in settings" + : "LSPs will activate as files are read"} + </text> + </Show> + <For each={sync.data.lsp}> + {(item) => ( <box flexDirection="row" gap={1}> <text flexShrink={0} style={{ - fg: ( - { - connected: theme.success, - failed: theme.error, - disabled: theme.textMuted, - needs_auth: theme.warning, - needs_client_registration: theme.error, - } as Record<string, typeof theme.success> - )[item.status], + fg: { + connected: theme.success, + error: theme.error, + }[item.status], }} > • </text> - <text fg={theme.text} wrapMode="word"> - {key}{" "} - <span style={{ fg: theme.textMuted }}> - <Switch fallback={item.status}> - <Match when={item.status === "connected"}>Connected</Match> - <Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match> - <Match when={item.status === "disabled"}>Disabled</Match> - <Match when={(item.status as string) === "needs_auth"}>Needs auth</Match> - <Match when={(item.status as string) === "needs_client_registration"}> - Needs client ID - </Match> - </Switch> - </span> + <text fg={theme.textMuted}> + {item.id} {item.root} </text> </box> )} </For> </Show> </box> - </Show> - <box> + <Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}> + <box> + <box + flexDirection="row" + gap={1} + onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)} + > + <Show when={todo().length > 2}> + <text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text> + </Show> + <text fg={theme.text}> + <b>Todo</b> + </text> + </box> + <Show when={todo().length <= 2 || expanded.todo}> + <For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For> + </Show> + </box> + </Show> + <Show when={teamInfo()}> + <Show when={(teamInfo() as any)?.role === "member"}> + <TeamMemberSidebar teamInfo={teamInfo()} sessionID={props.sessionID} teamTasks={teamTasks()} /> + </Show> + <Show when={(teamInfo() as any)?.role !== "member" && teamMembers().length > 0}> + <TeamLeadSidebar teamInfo={teamInfo()} teamMembers={teamMembers()} teamTasks={teamTasks()} /> + </Show> + </Show> + <Show when={diff().length > 0}> + <box> + <box + flexDirection="row" + gap={1} + onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)} + > + <Show when={diff().length > 2}> + <text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text> + </Show> + <text fg={theme.text}> + <b>Modified Files</b> + </text> + </box> + <Show when={diff().length <= 2 || expanded.diff}> + <For each={diff() || []}> + {(item) => { + return ( + <box flexDirection="row" gap={1} justifyContent="space-between"> + <text fg={theme.textMuted} wrapMode="none"> + {item.file} + </text> + <box flexDirection="row" gap={1} flexShrink={0}> + <Show when={item.additions}> + <text fg={theme.diffAdded}>+{item.additions}</text> + </Show> + <Show when={item.deletions}> + <text fg={theme.diffRemoved}>-{item.deletions}</text> + </Show> + </box> + </box> + ) + }} + </For> + </Show> + </box> + </Show> + </box> + </scrollbox> + + <box flexShrink={0} gap={1} paddingTop={1}> + <Show when={!hasProviders() && !gettingStartedDismissed()}> <box + backgroundColor={theme.backgroundElement} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + paddingRight={2} flexDirection="row" gap={1} - onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)} > - <Show when={sync.data.lsp.length > 2}> - <text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text> - </Show> - <text fg={theme.text}> - <b>LSP</b> + <text flexShrink={0} fg={theme.text}> + ⬖ </text> - </box> - <Show when={sync.data.lsp.length <= 2 || expanded.lsp}> - <Show when={sync.data.lsp.length === 0}> + <box flexGrow={1} gap={1}> + <box flexDirection="row" justifyContent="space-between"> + <text fg={theme.text}> + <b>Getting started</b> + </text> + <text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}> + ✕ + </text> + </box> + <text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text> <text fg={theme.textMuted}> - {sync.data.config.lsp === false - ? "LSPs have been disabled in settings" - : "LSPs will activate as files are read"} + Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc </text> + <box flexDirection="row" gap={1} justifyContent="space-between"> + <text fg={theme.text}>Connect provider</text> + <text fg={theme.textMuted}>/connect</text> + </box> + </box> + </box> + </Show> + <text> + <span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span> + <span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span> + </text> + <text fg={theme.textMuted}> + <span style={{ fg: theme.success }}>•</span> <b>Open</b> + <span style={{ fg: theme.text }}> + <b>Code</b> + </span>{" "} + <span>{Installation.VERSION}</span> + </text> + </box> + </box> + <box width={1} height="100%" backgroundColor={theme.backgroundPanel} flexShrink={0} /> + </box> + </Show> + ) +} + +function memberColor(status: string, theme: any) { + switch (status) { + case "busy": + return theme.success + case "shutdown_requested": + return theme.warning + case "error": + return theme.error + case "shutdown": + return theme.error + default: + return theme.textMuted + } +} + +/** Lead's team sidebar — compact overview with truncated per-member todos */ +function TeamLeadSidebar(props: { + teamInfo: any + teamMembers: Array<{ name: string; sessionID: string; agent: string; status: string; model?: string }> + teamTasks: Array<{ + id: string + content: string + status: string + priority: string + assignee?: string + depends_on?: string[] + }> +}) { + const sync = useSync() + const { theme } = useTheme() + const nav = useRoute() + const [expanded, setExpanded] = createSignal(true) + + return ( + <box> + <box flexDirection="row" gap={1} onMouseDown={() => setExpanded(!expanded())}> + <text fg={theme.text}>{expanded() ? "▼" : "▶"}</text> + <text fg={theme.text}> + <b>Team</b> + <Show when={!expanded()}> + <span style={{ fg: theme.textMuted }}> + {" "} + ({props.teamMembers.filter((m) => m.status === "busy").length} active, {props.teamMembers.length} total) + </span> + </Show> + </text> + </box> + <Show when={expanded()}> + <For each={props.teamMembers}> + {(member) => { + const todos = createMemo(() => + (sync.data.todo[member.sessionID] ?? []).filter((t: any) => t.status !== "completed"), + ) + const visible = createMemo(() => todos().slice(0, 2)) + const remaining = createMemo(() => Math.max(0, todos().length - 2)) + return ( + <box + flexDirection="column" + onMouseUp={() => { + if (member.sessionID) nav.navigate({ type: "session", sessionID: member.sessionID }) + }} + > + <box flexDirection="row" gap={1}> + <text flexShrink={0} fg={memberColor(member.status, theme)}> + • + </text> + <text fg={theme.text} wrapMode="word"> + {member.name} + <span style={{ fg: theme.textMuted }}> ({member.agent})</span> + </text> + </box> + <Show when={member.status === "busy"}> + <TeammateActivity sessionID={member.sessionID} /> </Show> - <For each={sync.data.lsp}> - {(item) => ( - <box flexDirection="row" gap={1}> - <text - flexShrink={0} - style={{ - fg: { - connected: theme.success, - error: theme.error, - }[item.status], - }} - > - • - </text> - <text fg={theme.textMuted}> - {item.id} {item.root} - </text> - </box> - )} - </For> - </Show> - </box> - <Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}> - <box> + <Show when={visible().length > 0}> + <box paddingLeft={2}> + <For each={visible()}>{(t: any) => <TodoItem status={t.status} content={t.content} />}</For> + <Show when={remaining() > 0}> + <text fg={theme.textMuted}>+{remaining()} more</text> + </Show> + </box> + </Show> + </box> + ) + }} + </For> + <Show when={props.teamTasks.length > 0}> + <For each={props.teamTasks}> + {(task) => { + const assignee = createMemo(() => + task.assignee ? props.teamMembers.find((m) => m.name === task.assignee) : undefined, + ) + return ( <box - flexDirection="row" - gap={1} - onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)} + flexDirection="column" + onMouseUp={() => { + const a = assignee() + if (a?.sessionID) nav.navigate({ type: "session", sessionID: a.sessionID }) + }} > - <Show when={todo().length > 2}> - <text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text> + <box flexDirection="row" gap={1}> + <text fg={teamTaskColor(task.status, theme)} flexShrink={0} wrapMode="none"> + {teamTaskIcon(task.status)} + </text> + <text fg={task.status === "in_progress" ? theme.text : theme.textMuted} wrapMode="word"> + {task.content} + </text> + </box> + <Show when={task.assignee}> + <text fg={theme.primary} paddingLeft={2} wrapMode="none"> + @{task.assignee} + </text> + </Show> + <Show when={task.depends_on && task.depends_on.length > 0 && task.status === "blocked"}> + <text fg={theme.textMuted} paddingLeft={2} wrapMode="none"> + blocked by {task.depends_on!.map((d) => `#${d}`).join(", ")} + </text> + </Show> + <Show when={task.status === "in_progress" && assignee()?.status === "busy"}> + <TeammateActivity sessionID={assignee()!.sessionID} /> </Show> - <text fg={theme.text}> - <b>Todo</b> - </text> </box> - <Show when={todo().length <= 2 || expanded.todo}> - <For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For> - </Show> - </box> + ) + }} + </For> + <text fg={theme.textMuted}> + {props.teamTasks.filter((t) => t.status === "completed").length}/{props.teamTasks.length} completed + </text> + </Show> + </Show> + </box> + ) +} + +/** Teammate's team sidebar — full detail for this member, compact view of others */ +function TeamMemberSidebar(props: { + teamInfo: any + sessionID: string + teamTasks: Array<{ + id: string + content: string + status: string + priority: string + assignee?: string + depends_on?: string[] + }> +}) { + const sync = useSync() + const { theme } = useTheme() + const nav = useRoute() + const [expanded, setExpanded] = createSignal(true) + + const info = () => + props.teamInfo as { teamName: string; role: string; memberName: string; members: any[]; tasks: any[] } + const members = createMemo( + () => (info().members ?? []) as Array<{ name: string; sessionID: string; agent: string; status: string }>, + ) + const todos = createMemo(() => (sync.data.todo[props.sessionID] ?? []).filter((t: any) => t.status !== "completed")) + const completedCount = createMemo( + () => (sync.data.todo[props.sessionID] ?? []).filter((t: any) => t.status === "completed").length, + ) + const totalCount = createMemo(() => (sync.data.todo[props.sessionID] ?? []).length) + + return ( + <box> + <box flexDirection="row" gap={1} onMouseDown={() => setExpanded(!expanded())}> + <text fg={theme.text}>{expanded() ? "▼" : "▶"}</text> + <text fg={theme.text}> + <b>Team: {info().teamName}</b> + </text> + </box> + <Show when={expanded()}> + <text fg={theme.textMuted}>Role: member ({info().memberName})</text> + {/* Full individual todo list */} + <Show when={todos().length > 0}> + <box paddingTop={1}> + <text fg={theme.text}> + <b>My Tasks</b> + </text> + <For each={todos()}>{(t: any) => <TodoItem status={t.status} content={t.content} />}</For> + <Show when={totalCount() > 0}> + <text fg={theme.textMuted}> + {completedCount()}/{totalCount()} completed + </text> </Show> - <Show when={diff().length > 0}> - <box> + </box> + </Show> + {/* Compact teammate list */} + <Show when={members().length > 0}> + <box paddingTop={1}> + <text fg={theme.text}> + <b>Teammates</b> + </text> + <For each={members()}> + {(member) => ( <box flexDirection="row" gap={1} - onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)} + onMouseUp={() => { + if (member.sessionID) nav.navigate({ type: "session", sessionID: member.sessionID }) + }} > - <Show when={diff().length > 2}> - <text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text> - </Show> - <text fg={theme.text}> - <b>Modified Files</b> + <text flexShrink={0} fg={memberColor(member.status, theme)}> + • + </text> + <text fg={member.name === info().memberName ? theme.primary : theme.text} wrapMode="word"> + {member.name} + <Show when={member.name === info().memberName}> + <span style={{ fg: theme.textMuted }}> (you)</span> + </Show> + <span style={{ fg: theme.textMuted }}> — {member.status}</span> </text> </box> - <Show when={diff().length <= 2 || expanded.diff}> - <For each={diff() || []}> - {(item) => { - return ( - <box flexDirection="row" gap={1} justifyContent="space-between"> - <text fg={theme.textMuted} wrapMode="none"> - {item.file} - </text> - <box flexDirection="row" gap={1} flexShrink={0}> - <Show when={item.additions}> - <text fg={theme.diffAdded}>+{item.additions}</text> - </Show> - <Show when={item.deletions}> - <text fg={theme.diffRemoved}>-{item.deletions}</text> - </Show> - </box> - </box> - ) - }} - </For> - </Show> - </box> - </Show> + )} + </For> </box> - </scrollbox> - - <box flexShrink={0} gap={1} paddingTop={1}> - <Show when={!hasProviders() && !gettingStartedDismissed()}> - <box - backgroundColor={theme.backgroundElement} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - paddingRight={2} - flexDirection="row" - gap={1} - > - <text flexShrink={0} fg={theme.text}> - ⬖ - </text> - <box flexGrow={1} gap={1}> - <box flexDirection="row" justifyContent="space-between"> - <text fg={theme.text}> - <b>Getting started</b> + </Show> + {/* Shared task list */} + <Show when={props.teamTasks.length > 0}> + <box paddingTop={1}> + <text fg={theme.text}> + <b>Shared Tasks</b> + </text> + <For each={props.teamTasks}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={teamTaskColor(task.status, theme)} flexShrink={0} wrapMode="none"> + {teamTaskIcon(task.status)} </text> - <text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}> - ✕ + <text fg={task.status === "in_progress" ? theme.text : theme.textMuted} wrapMode="word"> + {task.content} + <Show when={task.assignee}> + <span style={{ fg: theme.primary }}> @{task.assignee}</span> + </Show> </text> </box> - <text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text> - <text fg={theme.textMuted}> - Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc - </text> - <box flexDirection="row" gap={1} justifyContent="space-between"> - <text fg={theme.text}>Connect provider</text> - <text fg={theme.textMuted}>/connect</text> - </box> - </box> - </box> + )} + </For> + <text fg={theme.textMuted}> + {props.teamTasks.filter((t) => t.status === "completed").length}/{props.teamTasks.length} completed + </text> + </box> + </Show> + </Show> + </box> + ) +} + +function teamTaskIcon(status: string): string { + switch (status) { + case "completed": + return "+" + case "in_progress": + return "■" + case "blocked": + return "□" + case "cancelled": + return "x" + default: + return "□" + } +} + +function teamTaskColor(status: string, theme: any) { + switch (status) { + case "completed": + return theme.success + case "in_progress": + return theme.warning + case "blocked": + return theme.textMuted + case "cancelled": + return theme.error + default: + return theme.textMuted + } +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s + return s.slice(0, max - 1) + "…" +} + +function formatToolActivity(tp: ToolPart): string { + const name = tp.tool + const state = tp.state + if (state.status === "running") { + if (state.title) return `${name}: ${state.title}` + const input = state.input as Record<string, any> + if (name === "bash" && input.command) return truncate(`$ ${input.command}`, 36) + if (name === "read" && input.filePath) return `Read ${truncate(input.filePath, 30)}` + if (name === "grep" && input.pattern) return `Grep "${truncate(input.pattern, 28)}"` + if (name === "glob" && input.pattern) return `Glob "${truncate(input.pattern, 28)}"` + if (name === "write" && input.filePath) return `Write ${truncate(input.filePath, 30)}` + if (name === "edit" && input.filePath) return `Edit ${truncate(input.filePath, 30)}` + if ((name === "webfetch" || name === "mcp_webfetch") && input.url) return `Fetch ${truncate(input.url, 30)}` + if (name === "web_search" && input.query) return `Search "${truncate(input.query, 26)}"` + if (name === "team_message") return `Msg @${input.to ?? "..."}` + if (name === "team_tasks") return "Checking tasks" + return `${name}...` + } + if (state.status === "completed") { + if (state.title) return truncate(`${name}: ${state.title}`, 36) + return `${name} done` + } + if (state.status === "error") return `${name} error` + return `${name}...` +} + +/** Shows the current tool activity for a teammate session */ +function TeammateActivity(props: { sessionID: string }) { + const sync = useSync() + const { theme } = useTheme() + + const activity = createMemo(() => { + const msgs = sync.data.message[props.sessionID] ?? [] + for (let i = msgs.length - 1; i >= 0; i--) { + const msg = msgs[i] + if (msg.role !== "assistant") continue + const parts = sync.data.part[msg.id] ?? [] + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j] + if (part.type !== "tool") continue + const tp = part as ToolPart + if (tp.state.status === "pending") continue + return tp + } + } + return undefined + }) + + return ( + <Show when={activity()}> + {(tp) => ( + <box paddingLeft={2}> + <Show + when={tp().state.status === "running"} + fallback={ + <text fg={theme.textMuted} wrapMode="none"> + {formatToolActivity(tp())} + </text> + } + > + <Spinner color={theme.textMuted}>{formatToolActivity(tp())}</Spinner> </Show> - <text> - <span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span> - <span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span> - </text> - <text fg={theme.textMuted}> - <span style={{ fg: theme.success }}>•</span> <b>Open</b> - <span style={{ fg: theme.text }}> - <b>Code</b> - </span>{" "} - <span>{Installation.VERSION}</span> - </text> </box> - </box> + )} </Show> ) } diff --git a/packages/web/src/content/docs/agent-teams.mdx b/packages/web/src/content/docs/agent-teams.mdx new file mode 100644 index 000000000000..071732962b23 --- /dev/null +++ b/packages/web/src/content/docs/agent-teams.mdx @@ -0,0 +1,246 @@ +--- +title: Agent Teams +description: Coordinate multiple agents working in parallel on complex tasks. +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components" + +Agent teams let a lead agent spawn and coordinate multiple teammate agents, each running in their own session with their own model and context window. Teammates can work in parallel, communicate with each other, and share a task list. + +:::caution +Agent teams are an **experimental** feature. Enable them with the `OPENCODE_EXPERIMENTAL_AGENT_TEAMS=1` environment variable, or set `OPENCODE_EXPERIMENTAL=true` to enable all experimental features. +::: + +--- + +## When to use teams vs subagents + +| | Subagents | Agent Teams | +|---|-----------|-------------| +| **Parallelism** | Sequential (one at a time) | Parallel (multiple simultaneous) | +| **Context** | Shares parent context | Independent context windows | +| **Communication** | Returns result to parent | Bidirectional messaging | +| **Coordination** | Parent manages | Shared task list + messaging | +| **Best for** | Focused single tasks | Complex multi-part work | + +Use teams when you need multiple agents working simultaneously on different parts of a problem. Use subagents for focused, sequential subtasks. + +--- + +## Getting started + +### Enable the feature + +```bash frame="none" +export OPENCODE_EXPERIMENTAL_AGENT_TEAMS=1 +``` + +Or in your config: + +```json title="opencode.json" +{ + "experimental": true +} +``` + +### Starting a team + +Ask the agent naturally to create a team: + +> "Create a team to review the authentication module. Spawn a security reviewer and a test writer." + +The agent will use the `team_create` tool to set up the team, then `team_spawn` to add teammates. + +--- + +## Architecture + +``` +Lead Agent (your session) + | + |-- team_create --> creates team + shared task list + |-- team_spawn --> spawns teammates + | + +-- Teammate "reviewer" (own session, own model) + | |-- reads/writes code + | |-- team_message to lead or peers + | |-- team_tasks to check/complete tasks + | + +-- Teammate "tester" (own session, own model) + |-- reads/writes code + |-- team_message to lead or peers + |-- team_tasks to check/complete tasks +``` + +**Key concepts:** +- The **lead** is your current session. It creates the team and coordinates work. +- **Teammates** are spawned as independent sessions, each with their own agent, model, and context. +- A **shared task list** tracks work items with statuses, priorities, and dependencies. +- **Messaging** allows direct communication between any team members (lead-to-teammate, teammate-to-lead, or teammate-to-teammate). + +--- + +## Communication + +### Direct messages + +Any team member can message any other member by name: + +``` +team_message(to: "reviewer", text: "Please check the auth middleware") +``` + +Teammates can message the lead: + +``` +team_message(to: "lead", text: "Found a SQL injection vulnerability in login.ts") +``` + +### Broadcasts + +Send a message to all team members at once: + +``` +team_broadcast(text: "Switching to the new API schema, update your work accordingly") +``` + +:::caution +Broadcasts send a separate message to every teammate. Use sparingly — prefer direct messages when possible. +::: + +### How messages are delivered + +Messages are injected as synthetic user messages into the recipient's session. The recipient's LLM sees them as `[Team message from <sender>]: <text>` and responds naturally. + +--- + +## Shared task list + +Teams share a task list that all members can access. Tasks support: + +- **Statuses**: pending, in_progress, completed, cancelled, blocked +- **Priorities**: high, medium, low +- **Dependencies**: tasks can depend on other tasks (`depends_on`) +- **Assignment**: tasks can be claimed by a specific teammate + +### Task workflow + +1. The lead creates tasks when setting up the team (or later via `team_tasks`) +2. Teammates check available tasks with `team_tasks(action: "list")` +3. A teammate claims a task with `team_claim(task_id: "1")` +4. When done, they complete it with `team_tasks(action: "complete", task_id: "1")` +5. Dependent tasks are automatically unblocked + +--- + +## Multi-model teams + +Each teammate can use a different model. This is useful for mixing strengths — fast models for simple tasks, powerful models for complex reasoning: + +``` +team_spawn( + name: "researcher", + model: "anthropic/claude-sonnet-4-20250514", + prompt: "Research the authentication patterns used in the codebase" +) + +team_spawn( + name: "implementer", + model: "openai/gpt-4.1", + prompt: "Implement the changes based on the researcher's findings" +) +``` + +--- + +## Delegate mode + +When creating a team with `delegate: true`, the lead restricts itself to coordination only — it cannot use write tools (`bash`, `write`, `edit`, `apply_patch`). This forces all implementation work through teammates. + +Toggle delegate mode at any time with the `<leader>d` keybind. + +--- + +## Plan approval + +Spawn a teammate with `require_plan_approval: true` to put them in read-only mode. The teammate must: + +1. Analyze the codebase (read-only tools available) +2. Send a plan to the lead via `team_message` +3. Wait for the lead to approve with `team_approve_plan` +4. Once approved, write tools are unlocked + +This adds a review step before teammates make changes. + +--- + +## Team tools reference + +### Lead-only tools + +| Tool | Description | +|------|-------------| +| `team_create` | Create a new team. Parameters: `name`, `tasks[]`, `delegate` | +| `team_spawn` | Spawn a teammate. Parameters: `name`, `agent`, `model`, `prompt`, `claim_task`, `require_plan_approval` | +| `team_approve_plan` | Approve/reject a teammate's plan. Parameters: `name`, `approved`, `feedback` | +| `team_shutdown` | Request a teammate to shut down. Parameters: `name`, `reason` | +| `team_cleanup` | Remove team resources. All members must be shut down first. | + +### Available to all team members + +| Tool | Description | +|------|-------------| +| `team_message` | Send a message to a specific teammate or "lead" | +| `team_broadcast` | Send a message to all teammates | +| `team_tasks` | View/update the shared task list (actions: list, add, complete, update) | +| `team_claim` | Atomically claim a pending task | + +--- + +## TUI keybinds + +| Keybind | Action | +|---------|--------| +| `<leader>w` | Open team status dialog | +| `<leader>j` | Navigate to next teammate session | +| `<leader>k` | Navigate to previous teammate session | +| `<leader>d` | Toggle delegate mode | + +The team dialog (`<leader>w`) shows: +- All teammates with their status (active, idle, shutdown) +- The shared task list with statuses and assignments +- Press `Enter` on a teammate to jump to their session +- Press `l` to jump to the lead session + +--- + +## API reference + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/team` | List all teams | +| `GET` | `/team/:name` | Get team by name | +| `GET` | `/team/:name/tasks` | Get task list for a team | +| `GET` | `/team/by-session/:sessionID` | Find team info by session | +| `POST` | `/team/:name/delegate` | Toggle delegate mode | + +--- + +## Best practices + +- **Start with a clear task breakdown.** Define tasks with dependencies before spawning teammates. +- **Use plan approval for risky changes.** Require plan approval for teammates modifying critical code. +- **Prefer direct messages over broadcasts.** Broadcasts are expensive — each one sends N separate messages. +- **Let teammates coordinate directly.** Teammates can message each other by name, reducing the lead's overhead. +- **Monitor via the team dialog.** Use `<leader>w` to check team status without interrupting agents. +- **Shut down gracefully.** Use `team_shutdown` for each teammate before `team_cleanup`. + +--- + +## Limitations + +- Teams are experimental and may have rough edges +- Each teammate consumes its own context window and API tokens +- Teammates cannot create sub-teams (no nesting) +- A session can only lead one team at a time +- Team state is persisted on disk under `.opencode/teams/<name>/`