Skip to content
Open
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
179 changes: 179 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-team.tsx
Original file line number Diff line number Diff line change
@@ -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<string>[] => {
const info = teamInfo()
if (!info) return []

const memberOptions: DialogSelectOption<string>[] = info.members.map((m) => ({
title: `${m.name} (@${m.agent})`,
value: `member:${m.sessionID}`,
category: "Teammates",
footer: `Status: ${m.status}`,
gutter: <text fg={statusColor(m.status, theme)}>{statusIcon(m.status)}</text>,
}))

const taskOptions: DialogSelectOption<string>[] = (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: <text fg={statusColor(t.status, theme)}>{statusIcon(t.status)}</text>,
disabled: t.status === "completed" || t.status === "cancelled",
}))

return [...memberOptions, ...taskOptions]
})

const handleSelect = (option: DialogSelectOption<string>) => {
const [type, id] = option.value.split(":", 2)
if (type === "member" && id) {
dialog.clear()
nav.navigate({ type: "session", sessionID: id })
}
}

return (
<Show
when={teamInfo()}
fallback={
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={1}>
Agent Team
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<text fg={theme.textMuted}>No active team for this session.</text>
<text fg={theme.textMuted}>The lead agent can create a team using the team_create tool.</text>
</box>
}
>
<DialogSelect
title={`Team: ${teamInfo()!.teamName} (${teamInfo()!.role})`}
options={options()}
onSelect={handleSelect}
keybind={[
{
keybind: { name: "m", ctrl: false, meta: false, shift: false, leader: false },
title: "message",
onTrigger: (option) => {
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
}
}
},
},
]}
/>
</Show>
)
}
73 changes: 70 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -214,13 +228,31 @@ 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(() => {
setStore("interrupt", 0)
}, 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,
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1021,7 +1074,21 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={<text />}>
<Show
when={status().type !== "idle"}
fallback={
<Show when={teamBusy() > 0}>
<box flexDirection="row" gap={1} marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
<text fg={theme.textMuted}>
{teamBusy()} teammate{teamBusy() > 1 ? "s" : ""} working
</text>
</box>
</Show>
}
>
<box
flexDirection="row"
gap={1}
Expand Down
Loading
Loading