mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
migrate the session transport
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import "opentui-spinner/solid"
|
||||
import { For, createMemo } from "solid-js"
|
||||
import { createMemo, mapArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
||||
import { RunEntryContent, sameEntryGroup } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
@@ -35,21 +35,21 @@ function statusIcon(status: FooterSubagentTab["status"]) {
|
||||
return "◔"
|
||||
}
|
||||
|
||||
function tabText(input: { tab: FooterSubagentTab; slot: string; count: number; width: number }) {
|
||||
function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
|
||||
const perTab = Math.max(
|
||||
1,
|
||||
Math.floor((input.width - 4 - Math.max(0, input.count - 1) * 3) / Math.max(1, input.count)),
|
||||
Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)),
|
||||
)
|
||||
if (input.count >= 8 || perTab < 12) {
|
||||
return `[${input.slot}]`
|
||||
if (count >= 8 || perTab < 12) {
|
||||
return `[${slot}]`
|
||||
}
|
||||
|
||||
const label = `[${input.slot}] ${input.tab.label}`
|
||||
if (input.count >= 5 || perTab < 24) {
|
||||
const label = `[${slot}] ${tab.label}`
|
||||
if (count >= 5 || perTab < 24) {
|
||||
return label
|
||||
}
|
||||
|
||||
const detail = input.tab.description || input.tab.title
|
||||
const detail = tab.description || tab.title
|
||||
if (!detail) {
|
||||
return label
|
||||
}
|
||||
@@ -63,6 +63,32 @@ export function RunFooterSubagentTabs(props: {
|
||||
theme: RunFooterTheme
|
||||
width: number
|
||||
}) {
|
||||
const items = mapArray(
|
||||
() => props.tabs,
|
||||
(tab, index) => {
|
||||
const active = () => props.selected === tab.sessionID
|
||||
const slot = () => String(index() + 1)
|
||||
return (
|
||||
<box paddingRight={1}>
|
||||
<box flexDirection="row" gap={1} width="100%">
|
||||
{tab.status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(tab.status)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
|
||||
{tabText(tab, slot(), props.tabs.length, props.width)}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-subagent-tabs"
|
||||
@@ -74,35 +100,7 @@ export function RunFooterSubagentTabs(props: {
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>
|
||||
{props.tabs.map((tab, index) => {
|
||||
const active = () => props.selected === tab.sessionID
|
||||
const slot = String(index + 1)
|
||||
return (
|
||||
<box paddingRight={1}>
|
||||
<box flexDirection="row" gap={1} width="100%">
|
||||
{tab.status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(tab.status)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
|
||||
{tabText({
|
||||
tab,
|
||||
slot,
|
||||
count: props.tabs.length,
|
||||
width: props.width,
|
||||
})}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
})}
|
||||
</box>
|
||||
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>{items()}</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -116,13 +114,22 @@ export function RunFooterSubagentBody(props: {
|
||||
onCycle: (dir: -1 | 1) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||
const entries = createMemo(() => {
|
||||
return commits().map((commit, index, list) => ({
|
||||
commit,
|
||||
gap: index > 0 && !sameEntryGroup(list[index - 1], commit),
|
||||
}))
|
||||
})
|
||||
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
||||
const scrollbar = createMemo(() => ({
|
||||
trackOptions: {
|
||||
backgroundColor: footer().surface,
|
||||
foregroundColor: footer().line,
|
||||
},
|
||||
}))
|
||||
const rows = mapArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
{index() > 0 && !sameEntryGroup(commits()[index() - 1], commit) ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit} theme={theme()} opts={opts()} width={props.width()} />
|
||||
</box>
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
useKeyboard((event) => {
|
||||
@@ -160,7 +167,7 @@ export function RunFooterSubagentBody(props: {
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme().footer.surface}
|
||||
backgroundColor={footer().surface}
|
||||
>
|
||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||
<scrollbox
|
||||
@@ -168,35 +175,19 @@ export function RunFooterSubagentBody(props: {
|
||||
height="100%"
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme().footer.surface,
|
||||
foregroundColor: props.theme().footer.line,
|
||||
},
|
||||
}}
|
||||
verticalScrollbarOptions={scrollbar()}
|
||||
ref={(item) => {
|
||||
scroll = item as ScrollBoxRenderable
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<For each={entries()}>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
{item.gap ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent
|
||||
commit={item.commit}
|
||||
theme={props.theme()}
|
||||
opts={{ diffStyle: props.diffStyle }}
|
||||
width={props.width()}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
{entries().length === 0 ? (
|
||||
<text fg={props.theme().footer.muted} wrapMode="word">
|
||||
{commits().length > 0 ? (
|
||||
rows()
|
||||
) : (
|
||||
<text fg={footer().muted} wrapMode="word">
|
||||
No subagent activity yet
|
||||
</text>
|
||||
) : null}
|
||||
)}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
@@ -156,7 +157,7 @@ export class RunFooter implements FooterApi {
|
||||
private view: Accessor<FooterView>
|
||||
private setView: Setter<FooterView>
|
||||
private subagent: Accessor<FooterSubagentState>
|
||||
private setSubagent: Setter<FooterSubagentState>
|
||||
private setSubagent: (next: FooterSubagentState) => void
|
||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||
private tabsVisible = false
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
@@ -190,9 +191,14 @@ export class RunFooter implements FooterApi {
|
||||
const [resources, setResources] = createSignal<RunResource[]>(options.resources)
|
||||
this.resources = resources
|
||||
this.setResources = setResources
|
||||
const [subagent, setSubagent] = createSignal<FooterSubagentState>(createEmptySubagentState())
|
||||
this.subagent = subagent
|
||||
this.setSubagent = setSubagent
|
||||
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
|
||||
this.subagent = () => subagent as FooterSubagentState
|
||||
this.setSubagent = (next) => {
|
||||
setSubagent("tabs", reconcile(next.tabs, { key: "sessionID" }))
|
||||
setSubagent("details", reconcile(next.details))
|
||||
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
|
||||
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
||||
}
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,46 @@ function feed() {
|
||||
}
|
||||
}
|
||||
|
||||
function blockingFeed() {
|
||||
let done = false
|
||||
let wake: (() => void) | undefined
|
||||
const started = defer()
|
||||
|
||||
const stream: AsyncIterableIterator<unknown> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return this
|
||||
},
|
||||
next() {
|
||||
started.resolve()
|
||||
if (done) {
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
wake = () => {
|
||||
done = true
|
||||
wake = undefined
|
||||
resolve({ done: true, value: undefined })
|
||||
}
|
||||
})
|
||||
},
|
||||
return() {
|
||||
done = true
|
||||
wake?.()
|
||||
wake = undefined
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
},
|
||||
throw(error) {
|
||||
done = true
|
||||
wake?.()
|
||||
wake = undefined
|
||||
return Promise.reject(error)
|
||||
},
|
||||
}
|
||||
|
||||
return { stream, started }
|
||||
}
|
||||
|
||||
function footer(fn?: (commit: StreamCommit) => void) {
|
||||
const commits: StreamCommit[] = []
|
||||
const events: FooterEvent[] = []
|
||||
@@ -580,6 +620,96 @@ describe("run stream transport", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("closes an active turn without rejecting it", async () => {
|
||||
const src = feed()
|
||||
const ui = footer()
|
||||
const ready = defer()
|
||||
let aborted = false
|
||||
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk(src, {
|
||||
promptAsync: async (_input, opt) => {
|
||||
ready.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
aborted = true
|
||||
opt?.signal?.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}
|
||||
|
||||
opt?.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
const task = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { text: "hello", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
|
||||
await ready.promise
|
||||
await transport.close()
|
||||
await task
|
||||
|
||||
expect(aborted).toBe(true)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("closes while the event stream is waiting for the next item", async () => {
|
||||
const src = blockingFeed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: {
|
||||
event: {
|
||||
subscribe: async () => ({
|
||||
stream: src.stream,
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
promptAsync: async () => {},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
children: async () => ({ data: [] }),
|
||||
},
|
||||
permission: {
|
||||
list: async () => ({ data: [] }),
|
||||
},
|
||||
question: {
|
||||
list: async () => ({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient,
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
await src.started.promise
|
||||
await Promise.race([
|
||||
transport.close(),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("close timed out")), 100)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores stale idle events from an earlier turn", async () => {
|
||||
const src = feed()
|
||||
const ui = footer()
|
||||
|
||||
Reference in New Issue
Block a user