mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10d82a60c6 | |||
| 3d0ff1c705 | |||
| 3005e8bb98 | |||
| cf3b601731 |
@@ -28,6 +28,7 @@ export const Output = Schema.Struct({
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
status: Schema.Literals(["completed", "running"]),
|
status: Schema.Literals(["completed", "running"]),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
|
background: Schema.Boolean.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const description = [
|
export const description = [
|
||||||
@@ -146,7 +147,7 @@ export const Plugin = {
|
|||||||
if (background) {
|
if (background) {
|
||||||
yield* runtime.job.background(info.id)
|
yield* runtime.job.background(info.id)
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED, background: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||||
@@ -158,7 +159,7 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED, background: true }
|
||||||
}
|
}
|
||||||
if (result?.info.status === "error")
|
if (result?.info.status === "error")
|
||||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
import { DateTime, Effect, Fiber, Layer, Schema } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
@@ -43,7 +43,9 @@ const executionNode = makeGlobalNode({
|
|||||||
const completed = new Set<SessionV2.ID>()
|
const completed = new Set<SessionV2.ID>()
|
||||||
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: SessionV2.ID) {
|
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: SessionV2.ID) {
|
||||||
if (completed.has(sessionID)) return
|
if (completed.has(sessionID)) return
|
||||||
if ((yield* store.get(sessionID))?.title.includes("fail")) {
|
const session = yield* store.get(sessionID)
|
||||||
|
if (session?.title.includes("manual background")) yield* Effect.never
|
||||||
|
if (session?.title.includes("fail")) {
|
||||||
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
|
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,6 +193,7 @@ describe("SubagentTool", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
||||||
|
expect(Schema.decodeUnknownSync(SubagentTool.Output)(settled.output?.structured).background).toBeUndefined()
|
||||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||||
expect(child).toMatchObject({
|
expect(child).toMatchObject({
|
||||||
parentID: parent.id,
|
parentID: parent.id,
|
||||||
@@ -274,7 +277,7 @@ describe("SubagentTool", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const childID = outputSessionID(settled.output?.structured)
|
const childID = outputSessionID(settled.output?.structured)
|
||||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
expect(settled.output?.structured).toMatchObject({ status: "running", background: true })
|
||||||
|
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||||
@@ -285,4 +288,49 @@ describe("SubagentTool", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("marks foreground subagents as backgrounded when blocking wait is backgrounded", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||||
|
const sessions = yield* SessionV2.Service
|
||||||
|
const jobs = yield* Job.Service
|
||||||
|
const parent = yield* sessions.create({ location })
|
||||||
|
yield* withSubagent(parent.location)
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||||
|
yield* waitForTool(registry, SubagentTool.name)
|
||||||
|
|
||||||
|
const settled = yield* settleTool(registry, {
|
||||||
|
sessionID: parent.id,
|
||||||
|
...toolIdentity,
|
||||||
|
call: {
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-manual-background-subagent",
|
||||||
|
name: SubagentTool.name,
|
||||||
|
input: { agent: "reviewer", description: "manual background review", prompt: "review" },
|
||||||
|
},
|
||||||
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
const backgrounded = yield* Effect.gen(function* () {
|
||||||
|
for (const _ of Array.from({ length: 100 })) {
|
||||||
|
const result = yield* jobs.backgroundAll({ sessionID: parent.id, type: SubagentTool.name })
|
||||||
|
if (result.length > 0) return result
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
expect(backgrounded).toHaveLength(1)
|
||||||
|
expect((yield* Fiber.join(settled)).output?.structured).toMatchObject({
|
||||||
|
status: "running",
|
||||||
|
background: true,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||||||
params: { sessionID: SessionID }
|
params: { sessionID: SessionID }
|
||||||
}) {
|
}) {
|
||||||
if (!flags.experimentalBackgroundSubagents) return false
|
if (!flags.experimentalBackgroundSubagents) return false
|
||||||
return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0
|
const tasks = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })
|
||||||
|
const subagents = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "subagent" })
|
||||||
|
return tasks.length > 0 || subagents.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { usePromptMove } from "./move"
|
|||||||
import { readLocalAttachment } from "./local-attachment"
|
import { readLocalAttachment } from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
|
import { foregroundSubagentCount } from "../../util/subagent"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -160,12 +161,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||||
const activeSubagents = createMemo(
|
const activeSubagents = createMemo(() =>
|
||||||
() =>
|
foregroundSubagentCount({
|
||||||
data.session
|
sessionID: props.sessionID,
|
||||||
.list()
|
sessions: data.session.list(),
|
||||||
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
|
messages: props.sessionID ? data.session.message.list(props.sessionID) : [],
|
||||||
.length,
|
status: (sessionID) => data.session.status(sessionID),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const runningShells = createMemo(
|
const runningShells = createMemo(
|
||||||
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
||||||
@@ -175,6 +177,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||||
|
const backgroundShortcut = useCommandShortcut("session.background")
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -290,20 +293,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
|
|
||||||
// When empty, the cluster falls back to the hotkey hints.
|
|
||||||
const statusItems = createMemo(() => {
|
|
||||||
const agents = activeSubagents()
|
|
||||||
const shells = runningShells()
|
|
||||||
const stats = usage()
|
|
||||||
return [
|
|
||||||
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
|
|
||||||
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
|
|
||||||
stats?.context,
|
|
||||||
stats?.cost,
|
|
||||||
].filter(Boolean)
|
|
||||||
})
|
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
prompt: PromptInfo
|
prompt: PromptInfo
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
@@ -321,6 +310,22 @@ export function Prompt(props: PromptProps) {
|
|||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
|
||||||
|
// When empty, the cluster falls back to the hotkey hints.
|
||||||
|
const statusItems = createMemo(() => {
|
||||||
|
const agents = activeSubagents()
|
||||||
|
const shells = runningShells()
|
||||||
|
const stats = usage()
|
||||||
|
const background = backgroundShortcut()
|
||||||
|
return [
|
||||||
|
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
|
||||||
|
agents && store.prompt.input === "" && background ? `${background} background` : undefined,
|
||||||
|
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
|
||||||
|
stats?.context,
|
||||||
|
stats?.cost,
|
||||||
|
].filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.sessionID,
|
() => props.sessionID,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTheme, selectedForeground } from "../../../context/theme"
|
|||||||
import { Locale } from "../../../util/locale"
|
import { Locale } from "../../../util/locale"
|
||||||
import { useBindings, useCommandShortcut } from "../../../keymap"
|
import { useBindings, useCommandShortcut } from "../../../keymap"
|
||||||
import { useComposerTab } from "./index"
|
import { useComposerTab } from "./index"
|
||||||
|
import { useTuiConfig } from "../../../config"
|
||||||
|
|
||||||
interface SubagentEntry {
|
interface SubagentEntry {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -17,54 +18,40 @@ interface SubagentEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SubagentsTab(props: { sessionID: string }) {
|
export function SubagentsTab(props: { sessionID: string }) {
|
||||||
const route = useRouteData("session")
|
const routeData = useRouteData("session")
|
||||||
|
const route = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const fg = selectedForeground(theme)
|
const fg = selectedForeground(theme)
|
||||||
const navigate = useRoute().navigate
|
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
|
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
|
||||||
const backgroundHint = useCommandShortcut("composer.background")
|
|
||||||
|
|
||||||
const session = createMemo(() => data.session.get(props.sessionID))
|
const session = createMemo(() => data.session.get(props.sessionID))
|
||||||
|
const parentID = createMemo(() => session()?.parentID ?? props.sessionID)
|
||||||
|
|
||||||
const entries = createMemo<SubagentEntry[]>(() => {
|
const entries = createMemo<SubagentEntry[]>(() => {
|
||||||
const current = session()
|
const current = session()
|
||||||
if (!current) return []
|
if (!current) return []
|
||||||
|
|
||||||
const result: SubagentEntry[] = []
|
return data.session
|
||||||
|
.list()
|
||||||
if (current.parentID) {
|
.filter((child) => child.parentID === parentID())
|
||||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
.map((child) => {
|
||||||
for (const sibling of siblings) {
|
|
||||||
const agentMatch = sibling.title.match(/@(\w+) subagent/)
|
|
||||||
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
|
||||||
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
|
|
||||||
result.push({
|
|
||||||
sessionID: sibling.id,
|
|
||||||
agent,
|
|
||||||
title: name,
|
|
||||||
status: data.session.status(sibling.id),
|
|
||||||
current: sibling.id === route.sessionID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
|
||||||
for (const child of children) {
|
|
||||||
const agentMatch = child.title.match(/@(\w+) subagent/)
|
const agentMatch = child.title.match(/@(\w+) subagent/)
|
||||||
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
const agent = child.agent
|
||||||
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
|
? Locale.titlecase(child.agent)
|
||||||
result.push({
|
: agentMatch
|
||||||
|
? Locale.titlecase(agentMatch[1])
|
||||||
|
: "Subagent"
|
||||||
|
return {
|
||||||
sessionID: child.id,
|
sessionID: child.id,
|
||||||
agent,
|
agent,
|
||||||
title: name,
|
title: agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title,
|
||||||
status: data.session.status(child.id),
|
status: data.session.status(child.id),
|
||||||
current: child.id === route.sessionID,
|
current: child.id === routeData.sessionID,
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({ selected: 0 })
|
const [store, setStore] = createStore({ selected: 0 })
|
||||||
@@ -88,10 +75,10 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = entries()
|
const list = entries()
|
||||||
if (selectedSessionID !== route.sessionID && list.length > 0) {
|
if (selectedSessionID !== routeData.sessionID && list.length > 0) {
|
||||||
const currentIdx = list.findIndex((e) => e.current)
|
const currentIdx = list.findIndex((e) => e.current)
|
||||||
const next = currentIdx >= 0 ? currentIdx : 0
|
const next = currentIdx >= 0 ? currentIdx : 0
|
||||||
selectedSessionID = route.sessionID
|
selectedSessionID = routeData.sessionID
|
||||||
setStore("selected", next)
|
setStore("selected", next)
|
||||||
const scrollCurrentIntoView = () => scrollToIndex(next, true)
|
const scrollCurrentIntoView = () => scrollToIndex(next, true)
|
||||||
scrollCurrentIntoView()
|
scrollCurrentIntoView()
|
||||||
@@ -132,14 +119,11 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
hints: () => {
|
hints: () => {
|
||||||
const entry = selectedEntry()
|
const entry = selectedEntry()
|
||||||
if (!entry || entry.status !== "running") return []
|
if (!entry || entry.status !== "running") return []
|
||||||
return [
|
return [{ label: "interrupt", shortcut: interruptHint() }]
|
||||||
{ label: "interrupt", shortcut: interruptHint() },
|
|
||||||
...(entry.current ? [{ label: "background", shortcut: backgroundHint() }] : []),
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
const parentID = session()?.parentID
|
const parentID = session()?.parentID
|
||||||
if (parentID) navigate({ type: "session", sessionID: parentID })
|
if (parentID) route.navigate({ type: "session", sessionID: parentID })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
onCleanup(cleanup)
|
onCleanup(cleanup)
|
||||||
@@ -175,7 +159,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
category: "Composer",
|
category: "Composer",
|
||||||
run() {
|
run() {
|
||||||
const entry = entries()[store.selected]
|
const entry = entries()[store.selected]
|
||||||
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
|
if (entry) route.navigate({ type: "session", sessionID: entry.sessionID })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -187,32 +171,19 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
if (!entry || entry.status !== "running") return
|
if (!entry || entry.status !== "running") return
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "composer.background",
|
|
||||||
title: "Background subagent",
|
|
||||||
category: "Composer",
|
|
||||||
run() {
|
|
||||||
const entry = selectedEntry()
|
|
||||||
if (!entry || entry.status !== "running" || !entry.current) return
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
|
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
|
||||||
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
|
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
|
||||||
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
|
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
|
||||||
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
|
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
|
||||||
{ key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.background" },
|
...tuiConfig.keybinds.gather("session.background", ["session.background"] as const),
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={composer.active("subagents")}>
|
<Show when={composer.active("subagents")}>
|
||||||
<scrollbox
|
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||||
scrollbarOptions={{ visible: false }}
|
|
||||||
maxHeight={5}
|
|
||||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
|
||||||
>
|
|
||||||
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
|
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
|
||||||
<For each={entries()}>
|
<For each={entries()}>
|
||||||
{(entry, index) => {
|
{(entry, index) => {
|
||||||
@@ -230,7 +201,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
onMouseOver={() => setStore("selected", index())}
|
onMouseOver={() => setStore("selected", index())}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
setStore("selected", index())
|
setStore("selected", index())
|
||||||
navigate({ type: "session", sessionID: entry.sessionID })
|
route.navigate({ type: "session", sessionID: entry.sessionID })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexGrow={1} minWidth={0} flexDirection="row">
|
<box flexGrow={1} minWidth={0} flexDirection="row">
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keyma
|
|||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { LocationProvider } from "../../context/location"
|
import { LocationProvider } from "../../context/location"
|
||||||
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
||||||
|
import { foregroundSubagentCount, subagentDisplayState } from "../../util/subagent"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -99,13 +100,14 @@ const sessionBindingCommands = [
|
|||||||
"messages.copy",
|
"messages.copy",
|
||||||
"session.copy",
|
"session.copy",
|
||||||
"session.export",
|
"session.export",
|
||||||
"session.background",
|
|
||||||
"session.child.first",
|
"session.child.first",
|
||||||
"session.parent",
|
"session.parent",
|
||||||
"session.child.next",
|
"session.child.next",
|
||||||
"session.child.previous",
|
"session.child.previous",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const sessionBackgroundBindingCommands = ["session.background"] as const
|
||||||
|
|
||||||
const sessionGlobalBindingCommands = [
|
const sessionGlobalBindingCommands = [
|
||||||
"session.page.up",
|
"session.page.up",
|
||||||
"session.page.down",
|
"session.page.down",
|
||||||
@@ -147,7 +149,7 @@ export function Session() {
|
|||||||
}
|
}
|
||||||
const pluginRuntime = usePluginRuntime()
|
const pluginRuntime = usePluginRuntime()
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const { navigate } = useRoute()
|
const router = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
@@ -241,6 +243,22 @@ export function Session() {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const rows = createSessionRows(() => route.sessionID)
|
const rows = createSessionRows(() => route.sessionID)
|
||||||
|
const backgroundSessionID = createMemo(() => session()?.parentID ?? route.sessionID)
|
||||||
|
const foregroundSubagents = createMemo(() =>
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: backgroundSessionID(),
|
||||||
|
sessions: data.session.list(),
|
||||||
|
messages: data.session.message.list(backgroundSessionID()),
|
||||||
|
status: (sessionID) => data.session.status(sessionID),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(backgroundSessionID, (sessionID) => {
|
||||||
|
if (sessionID === route.sessionID) return
|
||||||
|
void data.session.message.refresh(sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(descendantSessionIDs, (sessionIDs) => {
|
on(descendantSessionIDs, (sessionIDs) => {
|
||||||
@@ -263,7 +281,7 @@ export function Session() {
|
|||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
})
|
})
|
||||||
navigate({ type: "home" })
|
router.navigate({ type: "home" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +295,7 @@ export function Session() {
|
|||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
})
|
})
|
||||||
navigate({ type: "home" })
|
router.navigate({ type: "home" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -786,7 +804,33 @@ export function Session() {
|
|||||||
value: "session.background",
|
value: "session.background",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => unavailable("Backgrounding subagents"),
|
run: async () => {
|
||||||
|
const current = location()
|
||||||
|
if (!current) return
|
||||||
|
const targetSessionID = backgroundSessionID()
|
||||||
|
dialog.clear()
|
||||||
|
try {
|
||||||
|
const capabilities = await sdk.client.experimental.capabilities.get(
|
||||||
|
{ directory: current.directory, workspace: current.workspaceID },
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
if (!capabilities.data.backgroundSubagents) {
|
||||||
|
toast.show({ message: "Background subagents are not enabled", variant: "info", duration: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await sdk.client.experimental.session.background(
|
||||||
|
{ sessionID: targetSessionID, directory: current.directory, workspace: current.workspaceID },
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
toast.show({
|
||||||
|
message: result.data ? "Backgrounded running subagents" : "No running subagents to background",
|
||||||
|
variant: result.data ? "success" : "info",
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Toggle subagent picker",
|
title: "Toggle subagent picker",
|
||||||
@@ -807,7 +851,7 @@ export function Session() {
|
|||||||
run: () => {
|
run: () => {
|
||||||
const parentID = session()?.parentID
|
const parentID = session()?.parentID
|
||||||
if (parentID) {
|
if (parentID) {
|
||||||
navigate({
|
router.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: parentID,
|
sessionID: parentID,
|
||||||
})
|
})
|
||||||
@@ -862,6 +906,14 @@ export function Session() {
|
|||||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
|
enabled: () =>
|
||||||
|
foregroundSubagents() > 0 &&
|
||||||
|
(renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === "")),
|
||||||
|
bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands),
|
||||||
|
}))
|
||||||
|
|
||||||
// snap to bottom when session changes
|
// snap to bottom when session changes
|
||||||
createEffect(on(() => route.sessionID, toBottom))
|
createEffect(on(() => route.sessionID, toBottom))
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -2167,26 +2219,34 @@ function WebSearch(props: ToolProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Task(props: ToolProps) {
|
function Task(props: ToolProps) {
|
||||||
const { navigate } = useRoute()
|
const router = useRoute()
|
||||||
|
const data = useData()
|
||||||
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
|
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
|
||||||
const description = createMemo(() => stringValue(props.input.description))
|
const description = createMemo(() => stringValue(props.input.description))
|
||||||
|
const display = createMemo(() =>
|
||||||
|
subagentDisplayState({
|
||||||
|
toolStatus: props.part.state.status,
|
||||||
|
metadata: props.metadata,
|
||||||
|
sessionStatus: (sessionID) => data.session.status(sessionID),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InlineTool
|
<InlineTool
|
||||||
icon={props.part.state.status === "completed" ? "✓" : "│"}
|
icon={display().icon}
|
||||||
spinner={props.part.state.status === "running"}
|
spinner={display().running}
|
||||||
complete={description()}
|
complete={description()}
|
||||||
pending="Delegating..."
|
pending="Delegating..."
|
||||||
part={props.part}
|
part={props.part}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (id) navigate({ type: "session", sessionID: id })
|
if (id) router.navigate({ type: "session", sessionID: id })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatSubagentTitle(
|
{formatSubagentTitle(
|
||||||
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
|
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
|
||||||
description() ?? "Subagent",
|
description() ?? "Subagent",
|
||||||
props.metadata.background === true,
|
display().background,
|
||||||
)}
|
)}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
const subagentTools = new Set(["subagent", "task"])
|
||||||
|
|
||||||
|
type SubagentDisplayStateInput = {
|
||||||
|
readonly toolStatus: string
|
||||||
|
readonly metadata: Record<string, unknown>
|
||||||
|
readonly sessionStatus: (sessionID: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionSummary = {
|
||||||
|
readonly id: string
|
||||||
|
readonly parentID?: string
|
||||||
|
readonly title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelinePart = {
|
||||||
|
readonly type: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly state?: {
|
||||||
|
readonly status: string
|
||||||
|
readonly input?: unknown
|
||||||
|
readonly structured?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineMessage = {
|
||||||
|
readonly type: string
|
||||||
|
readonly content?: readonly TimelinePart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function foregroundSubagentCount(input: {
|
||||||
|
readonly sessionID?: string
|
||||||
|
readonly sessions: readonly SessionSummary[]
|
||||||
|
readonly messages: readonly TimelineMessage[]
|
||||||
|
readonly status: (sessionID: string) => string
|
||||||
|
}) {
|
||||||
|
if (!input.sessionID) return 0
|
||||||
|
|
||||||
|
const backgrounded = new Set(
|
||||||
|
input.messages.flatMap((message) =>
|
||||||
|
message.type === "assistant"
|
||||||
|
? (message.content ?? []).flatMap((part) => {
|
||||||
|
if (part.type !== "tool") return []
|
||||||
|
if (!part.name || !subagentTools.has(part.name)) return []
|
||||||
|
if (part.state?.status === "pending" || part.state?.structured?.background !== true) return []
|
||||||
|
const sessionID =
|
||||||
|
typeof part.state.structured.sessionID === "string"
|
||||||
|
? part.state.structured.sessionID
|
||||||
|
: typeof part.state.structured.sessionId === "string"
|
||||||
|
? part.state.structured.sessionId
|
||||||
|
: undefined
|
||||||
|
return sessionID ? [sessionID] : []
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runningSessionIDs = new Set(
|
||||||
|
input.sessions
|
||||||
|
.filter(
|
||||||
|
(session) =>
|
||||||
|
session.parentID === input.sessionID &&
|
||||||
|
input.status(session.id) === "running" &&
|
||||||
|
!backgrounded.has(session.id),
|
||||||
|
)
|
||||||
|
.map((session) => session.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runningSessionTitleCounts = input.sessions
|
||||||
|
.filter((session) => runningSessionIDs.has(session.id) && session.title)
|
||||||
|
.reduce((counts, session) => {
|
||||||
|
if (!session.title) return counts
|
||||||
|
return counts.set(session.title, (counts.get(session.title) ?? 0) + 1)
|
||||||
|
}, new Map<string, number>())
|
||||||
|
|
||||||
|
const runningRows = input.messages.flatMap((message) =>
|
||||||
|
message.type === "assistant"
|
||||||
|
? (message.content ?? []).filter(
|
||||||
|
(part) =>
|
||||||
|
part.type === "tool" &&
|
||||||
|
part.name !== undefined &&
|
||||||
|
subagentTools.has(part.name) &&
|
||||||
|
part.state?.status === "running" &&
|
||||||
|
part.state.structured?.background !== true,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const runningRowSessionIDs = new Set(
|
||||||
|
runningRows.flatMap((part) => {
|
||||||
|
const sessionID = subagentSessionID(part.state?.structured ?? {})
|
||||||
|
return sessionID ? [sessionID] : []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const anonymousRunningRows = runningRows.filter((part) => !subagentSessionID(part.state?.structured ?? {}))
|
||||||
|
const matchedAnonymousRows = anonymousRunningRows.filter((part) => {
|
||||||
|
const input = part.state?.input
|
||||||
|
if (typeof input !== "object" || input === null || Array.isArray(input) || !("description" in input)) return false
|
||||||
|
if (typeof input.description !== "string") return false
|
||||||
|
const remaining = runningSessionTitleCounts.get(input.description) ?? 0
|
||||||
|
if (remaining <= 0) return false
|
||||||
|
runningSessionTitleCounts.set(input.description, remaining - 1)
|
||||||
|
return true
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const runningSessions = [...runningSessionIDs].filter((session) => !runningRowSessionIDs.has(session)).length
|
||||||
|
|
||||||
|
return runningSessions + runningRowSessionIDs.size + anonymousRunningRows.length - matchedAnonymousRows
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subagentDisplayState(input: SubagentDisplayStateInput) {
|
||||||
|
const sessionID = subagentSessionID(input.metadata)
|
||||||
|
const background = input.metadata.background === true
|
||||||
|
const childRunning = background && sessionID !== undefined && input.sessionStatus(sessionID) === "running"
|
||||||
|
const running = input.toolStatus === "running" || childRunning
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
running,
|
||||||
|
icon: running ? "│" : input.toolStatus === "completed" ? "✓" : "│",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentSessionID(metadata: Record<string, unknown>) {
|
||||||
|
if (typeof metadata.sessionID === "string") return metadata.sessionID
|
||||||
|
if (typeof metadata.sessionId === "string") return metadata.sessionId
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { foregroundSubagentCount, subagentDisplayState } from "../../src/util/subagent"
|
||||||
|
|
||||||
|
describe("foregroundSubagentCount", () => {
|
||||||
|
test("counts running subagent timeline rows before child session output is available", () => {
|
||||||
|
expect(
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: "parent",
|
||||||
|
sessions: [],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool", name: "subagent", state: { status: "running", structured: {} } },
|
||||||
|
{ type: "tool", name: "task", state: { status: "running", structured: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: () => "idle",
|
||||||
|
}),
|
||||||
|
).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not double-count running rows that match known child sessions", () => {
|
||||||
|
expect(
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: "parent",
|
||||||
|
sessions: [{ id: "child-1", parentID: "parent", title: "Audit slow path" }],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
name: "subagent",
|
||||||
|
state: { status: "running", input: { description: "Audit slow path" }, structured: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: () => "running",
|
||||||
|
}),
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("counts mixed timeline-only and session-only foreground subagents", () => {
|
||||||
|
expect(
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: "parent",
|
||||||
|
sessions: [{ id: "child-1", parentID: "parent", title: "Known child" }],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
name: "subagent",
|
||||||
|
state: { status: "running", input: { description: "New child" }, structured: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: () => "running",
|
||||||
|
}),
|
||||||
|
).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not undercount duplicate descriptions when only one child session is synced", () => {
|
||||||
|
expect(
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: "parent",
|
||||||
|
sessions: [{ id: "child-1", parentID: "parent", title: "Same task" }],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
name: "subagent",
|
||||||
|
state: { status: "running", input: { description: "Same task" }, structured: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
name: "subagent",
|
||||||
|
state: { status: "running", input: { description: "Same task" }, structured: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: () => "running",
|
||||||
|
}),
|
||||||
|
).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("counts running child sessions", () => {
|
||||||
|
expect(
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: "parent",
|
||||||
|
sessions: [
|
||||||
|
{ id: "child-1", parentID: "parent" },
|
||||||
|
{ id: "child-2", parentID: "parent" },
|
||||||
|
{ id: "child-3", parentID: "other" },
|
||||||
|
],
|
||||||
|
messages: [],
|
||||||
|
status: (sessionID) => (sessionID === "child-1" || sessionID === "child-3" ? "running" : "idle"),
|
||||||
|
}),
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("excludes running subagents already marked as backgrounded in the timeline", () => {
|
||||||
|
expect(
|
||||||
|
foregroundSubagentCount({
|
||||||
|
sessionID: "parent",
|
||||||
|
sessions: [
|
||||||
|
{ id: "child-1", parentID: "parent" },
|
||||||
|
{ id: "child-2", parentID: "parent" },
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
name: "subagent",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
structured: { sessionID: "child-1", background: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
name: "task",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
structured: { sessionId: "child-2", background: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: () => "running",
|
||||||
|
}),
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("subagentDisplayState", () => {
|
||||||
|
test("keeps a backgrounded subagent spinning while the child session is running", () => {
|
||||||
|
expect(
|
||||||
|
subagentDisplayState({
|
||||||
|
toolStatus: "completed",
|
||||||
|
metadata: { sessionID: "child", status: "running", background: true },
|
||||||
|
sessionStatus: () => "running",
|
||||||
|
}),
|
||||||
|
).toEqual({ background: true, running: true, icon: "│" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows a checkmark once a backgrounded child session is no longer running", () => {
|
||||||
|
expect(
|
||||||
|
subagentDisplayState({
|
||||||
|
toolStatus: "completed",
|
||||||
|
metadata: { sessionID: "child", status: "running", background: true },
|
||||||
|
sessionStatus: () => "idle",
|
||||||
|
}),
|
||||||
|
).toEqual({ background: true, running: false, icon: "✓" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not spin forever from stale background metadata without a child session ID", () => {
|
||||||
|
expect(
|
||||||
|
subagentDisplayState({
|
||||||
|
toolStatus: "completed",
|
||||||
|
metadata: { status: "running", background: true },
|
||||||
|
sessionStatus: () => "idle",
|
||||||
|
}),
|
||||||
|
).toEqual({ background: true, running: false, icon: "✓" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not show a checkmark for errored subagent rows", () => {
|
||||||
|
expect(
|
||||||
|
subagentDisplayState({
|
||||||
|
toolStatus: "error",
|
||||||
|
metadata: { background: true },
|
||||||
|
sessionStatus: () => "idle",
|
||||||
|
}),
|
||||||
|
).toEqual({ background: true, running: false, icon: "│" })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user