chore: generate
This commit is contained in:
@@ -18,14 +18,7 @@ import path from "path"
|
|||||||
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||||
import { writeSessionOutput } from "./stream"
|
import { writeSessionOutput } from "./stream"
|
||||||
import type {
|
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||||
FooterApi,
|
|
||||||
PermissionReply,
|
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunPrompt,
|
|
||||||
StreamCommit,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
const KINDS = [
|
const KINDS = [
|
||||||
"markdown",
|
"markdown",
|
||||||
|
|||||||
@@ -180,13 +180,7 @@ function PanelShell(props: {
|
|||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<box
|
<box id={props.id} width="100%" flexDirection="column" backgroundColor="transparent" flexShrink={0}>
|
||||||
id={props.id}
|
|
||||||
width="100%"
|
|
||||||
flexDirection="column"
|
|
||||||
backgroundColor="transparent"
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
<box
|
<box
|
||||||
width="100%"
|
width="100%"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -333,14 +327,14 @@ export function RunCommandMenuBody(props: {
|
|||||||
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
|
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
|
||||||
name: item.name,
|
name: item.name,
|
||||||
display: item.name,
|
display: item.name,
|
||||||
footer: `/${item.name}`,
|
footer: `/${item.name}`,
|
||||||
keywords:
|
keywords:
|
||||||
item.source === "mcp"
|
item.source === "mcp"
|
||||||
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
|
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
|
||||||
: `/${item.name} ${item.name} ${item.description ?? ""}`,
|
: `/${item.name} ${item.name} ${item.description ?? ""}`,
|
||||||
}) satisfies CommandEntry,
|
}) satisfies CommandEntry,
|
||||||
)
|
)
|
||||||
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
|
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
|
||||||
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -161,10 +161,7 @@ export function RunFooterMenu(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir =
|
const dir = props.selected() === previous + 1 ? 1 : props.selected() === previous - 1 ? -1 : undefined
|
||||||
props.selected() === previous + 1 ? 1
|
|
||||||
: props.selected() === previous - 1 ? -1
|
|
||||||
: undefined
|
|
||||||
setGroupOffset((value) =>
|
setGroupOffset((value) =>
|
||||||
dir
|
dir
|
||||||
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
|
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
|
||||||
@@ -175,11 +172,14 @@ export function RunFooterMenu(props: {
|
|||||||
|
|
||||||
const rows = createMemo<RunFooterMenuRow[]>(() => {
|
const rows = createMemo<RunFooterMenuRow[]>(() => {
|
||||||
if (!props.grouped) {
|
if (!props.grouped) {
|
||||||
return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({
|
return props
|
||||||
type: "item",
|
.items()
|
||||||
item,
|
.slice(props.offset(), props.offset() + limit())
|
||||||
index: index + props.offset(),
|
.map((item, index) => ({
|
||||||
}))
|
type: "item",
|
||||||
|
item,
|
||||||
|
index: index + props.offset(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = groupedRows()
|
const all = groupedRows()
|
||||||
@@ -187,7 +187,13 @@ export function RunFooterMenu(props: {
|
|||||||
return all.slice(start, start + limit())
|
return all.slice(start, start + limit())
|
||||||
})
|
})
|
||||||
const descriptionColumn = createMemo(() => {
|
const descriptionColumn = createMemo(() => {
|
||||||
const width = Math.max(0, ...props.items().filter((item) => item.description).map((item) => Bun.stringWidth(item.display)))
|
const width = Math.max(
|
||||||
|
0,
|
||||||
|
...props
|
||||||
|
.items()
|
||||||
|
.filter((item) => item.description)
|
||||||
|
.map((item) => Bun.stringWidth(item.display)),
|
||||||
|
)
|
||||||
return width === 0 ? 0 : width + 2
|
return width === 0 ? 0 : width + 2
|
||||||
})
|
})
|
||||||
const descriptionPad = (item: RunFooterMenuItem) => {
|
const descriptionPad = (item: RunFooterMenuItem) => {
|
||||||
@@ -264,7 +270,12 @@ export function RunFooterMenu(props: {
|
|||||||
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
|
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
|
||||||
>
|
>
|
||||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
||||||
<text fg={active() ? props.theme().surface : props.theme().text} wrapMode="none" truncate flexGrow={1}>
|
<text
|
||||||
|
fg={active() ? props.theme().surface : props.theme().text}
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
{row.item.display}
|
{row.item.display}
|
||||||
{row.item.description ? (
|
{row.item.description ? (
|
||||||
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
|
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
|
||||||
@@ -274,7 +285,12 @@ export function RunFooterMenu(props: {
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
</text>
|
</text>
|
||||||
{row.item.footer ? (
|
{row.item.footer ? (
|
||||||
<text fg={active() ? props.theme().surface : props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
<text
|
||||||
|
fg={active() ? props.theme().surface : props.theme().muted}
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
{row.item.footer}
|
{row.item.footer}
|
||||||
</text>
|
</text>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ function statusIcon(status: FooterSubagentTab["status"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
|
function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
|
||||||
const perTab = Math.max(
|
const perTab = Math.max(1, Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)))
|
||||||
1,
|
|
||||||
Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)),
|
|
||||||
)
|
|
||||||
if (count >= 8 || perTab < 12) {
|
if (count >= 8 || perTab < 12) {
|
||||||
return `[${slot}]`
|
return `[${slot}]`
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,9 @@ export function RunFooterSubagentTabs(props: {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>{items()}</box>
|
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>
|
||||||
|
{items()}
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -567,9 +567,9 @@ export class RunFooter implements FooterApi {
|
|||||||
? 1 + tabs + MODEL_ROWS
|
? 1 + tabs + MODEL_ROWS
|
||||||
: this.promptRoute.type === "variant"
|
: this.promptRoute.type === "variant"
|
||||||
? 1 + tabs + VARIANT_ROWS
|
? 1 + tabs + VARIANT_ROWS
|
||||||
: this.promptRoute.type === "subagent"
|
: this.promptRoute.type === "subagent"
|
||||||
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
||||||
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
||||||
|
|
||||||
if (height !== this.renderer.footerHeight) {
|
if (height !== this.renderer.footerHeight) {
|
||||||
this.renderer.footerHeight = height
|
this.renderer.footerHeight = height
|
||||||
|
|||||||
@@ -103,15 +103,13 @@ export function withRunSpan<A>(
|
|||||||
attributes: attributes(input),
|
attributes: attributes(input),
|
||||||
})
|
})
|
||||||
|
|
||||||
return context.with(
|
return context.with(trace.setSpan(context.active(), span), () =>
|
||||||
trace.setSpan(context.active(), span),
|
finish(
|
||||||
() =>
|
span,
|
||||||
finish(
|
new Promise<A>((resolve) => {
|
||||||
span,
|
resolve(fn(span))
|
||||||
new Promise<A>((resolve) => {
|
}),
|
||||||
resolve(fn(span))
|
),
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
() => fn(noop),
|
() => fn(noop),
|
||||||
|
|||||||
@@ -23,13 +23,7 @@ export type PromptHistoryState = {
|
|||||||
draft: string
|
draft: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptInfo(event: {
|
export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) {
|
||||||
name: string
|
|
||||||
ctrl?: boolean
|
|
||||||
meta?: boolean
|
|
||||||
shift?: boolean
|
|
||||||
super?: boolean
|
|
||||||
}) {
|
|
||||||
return {
|
return {
|
||||||
name: event.name === " " ? "space" : event.name,
|
name: event.name === " " ? "space" : event.name,
|
||||||
ctrl: !!event.ctrl,
|
ctrl: !!event.ctrl,
|
||||||
@@ -123,7 +117,11 @@ export function promptBindings(bindings: FooterKeybinds["commandList"], leader:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapInputBindings(bindings: FooterKeybinds["inputSubmit"], leader: string, action: "submit" | "newline"): KeyBinding[] {
|
function mapInputBindings(
|
||||||
|
bindings: FooterKeybinds["inputSubmit"],
|
||||||
|
leader: string,
|
||||||
|
action: "submit" | "newline",
|
||||||
|
): KeyBinding[] {
|
||||||
return promptBindings(bindings, leader).flatMap((key) => {
|
return promptBindings(bindings, leader).flatMap((key) => {
|
||||||
if (key.leader) {
|
if (key.leader) {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -248,7 +248,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
process.on("SIGINT", sigint)
|
process.on("SIGINT", sigint)
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }) => {
|
const close = async (next: {
|
||||||
|
showExit: boolean
|
||||||
|
sessionTitle?: string
|
||||||
|
sessionID?: string
|
||||||
|
history?: RunPrompt[]
|
||||||
|
}) => {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ type CreateSessionInput = {
|
|||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateSession = (
|
type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promise<{ id: string; title?: string }>
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
input: CreateSessionInput,
|
|
||||||
) => Promise<{ id: string; title?: string }>
|
|
||||||
|
|
||||||
type RunRuntimeInput = {
|
type RunRuntimeInput = {
|
||||||
boot: () => Promise<BootContext>
|
boot: () => Promise<BootContext>
|
||||||
@@ -548,9 +545,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
state.sessionTitle = created.sessionTitle
|
state.sessionTitle = created.sessionTitle
|
||||||
state.agent = created.agent ?? state.agent
|
state.agent = created.agent ?? state.agent
|
||||||
state.history = []
|
state.history = []
|
||||||
includeFiles = true
|
includeFiles = true
|
||||||
state.demo = input.demo
|
state.demo = input.demo
|
||||||
? createRunDemo({
|
? createRunDemo({
|
||||||
footer,
|
footer,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
|||||||
@@ -118,37 +118,37 @@ export class RunScrollbackStream {
|
|||||||
const renderable =
|
const renderable =
|
||||||
body.type === "text"
|
body.type === "text"
|
||||||
? new TextRenderable(surface.renderContext, {
|
? new TextRenderable(surface.renderContext, {
|
||||||
id,
|
|
||||||
content: "",
|
|
||||||
width: "100%",
|
|
||||||
wrapMode: "word",
|
|
||||||
fg: style.fg,
|
|
||||||
attributes: style.attrs,
|
|
||||||
})
|
|
||||||
: body.type === "code"
|
|
||||||
? new CodeRenderable(surface.renderContext, {
|
|
||||||
id,
|
id,
|
||||||
content: "",
|
content: "",
|
||||||
filetype: body.filetype,
|
|
||||||
syntaxStyle: entrySyntax(commit, this.theme),
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
wrapMode: "word",
|
wrapMode: "word",
|
||||||
drawUnstyledText: false,
|
fg: style.fg,
|
||||||
streaming: true,
|
attributes: style.attrs,
|
||||||
fg: entryColor(commit, this.theme),
|
|
||||||
treeSitterClient: this.treeSitterClient,
|
|
||||||
})
|
})
|
||||||
|
: body.type === "code"
|
||||||
|
? new CodeRenderable(surface.renderContext, {
|
||||||
|
id,
|
||||||
|
content: "",
|
||||||
|
filetype: body.filetype,
|
||||||
|
syntaxStyle: entrySyntax(commit, this.theme),
|
||||||
|
width: "100%",
|
||||||
|
wrapMode: "word",
|
||||||
|
drawUnstyledText: false,
|
||||||
|
streaming: true,
|
||||||
|
fg: entryColor(commit, this.theme),
|
||||||
|
treeSitterClient: this.treeSitterClient,
|
||||||
|
})
|
||||||
: new MarkdownRenderable(surface.renderContext, {
|
: new MarkdownRenderable(surface.renderContext, {
|
||||||
id,
|
id,
|
||||||
content: "",
|
content: "",
|
||||||
syntaxStyle: entrySyntax(commit, this.theme),
|
syntaxStyle: entrySyntax(commit, this.theme),
|
||||||
width: "100%",
|
width: "100%",
|
||||||
streaming: true,
|
streaming: true,
|
||||||
internalBlockMode: "top-level",
|
internalBlockMode: "top-level",
|
||||||
tableOptions: { widthMode: "content" },
|
tableOptions: { widthMode: "content" },
|
||||||
fg: entryColor(commit, this.theme),
|
fg: entryColor(commit, this.theme),
|
||||||
treeSitterClient: this.treeSitterClient,
|
treeSitterClient: this.treeSitterClient,
|
||||||
})
|
})
|
||||||
|
|
||||||
surface.root.add(renderable)
|
surface.root.add(renderable)
|
||||||
|
|
||||||
@@ -326,8 +326,7 @@ export class RunScrollbackStream {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
body.type !== "structured" &&
|
body.type !== "structured" &&
|
||||||
(entryCanStream(commit, body) ||
|
(entryCanStream(commit, body) || (commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
|
||||||
(commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
|
|
||||||
) {
|
) {
|
||||||
await this.writeStreaming(commit, body)
|
await this.writeStreaming(commit, body)
|
||||||
if (entryDone(commit)) {
|
if (entryDone(commit)) {
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody
|
|||||||
return "block"
|
return "block"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commit.phase === "progress" && commit.toolState === "completed" && body.type === "text" && body.content.includes("\n")) {
|
if (
|
||||||
|
commit.phase === "progress" &&
|
||||||
|
commit.toolState === "completed" &&
|
||||||
|
body.type === "text" &&
|
||||||
|
body.content.includes("\n")
|
||||||
|
) {
|
||||||
return "block"
|
return "block"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,10 +205,7 @@ function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickBlockerView(input: {
|
export function pickBlockerView(input: { permission?: PermissionRequest; question?: QuestionRequest }): FooterView {
|
||||||
permission?: PermissionRequest
|
|
||||||
question?: QuestionRequest
|
|
||||||
}): FooterView {
|
|
||||||
if (input.permission) {
|
if (input.permission) {
|
||||||
return { type: "permission", request: input.permission }
|
return { type: "permission", request: input.permission }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,15 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
|||||||
}
|
}
|
||||||
|
|
||||||
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
|
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
|
||||||
push(lines, body_left + label.length, top + 1, `opencode run -i -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD)
|
push(
|
||||||
|
lines,
|
||||||
|
body_left + label.length,
|
||||||
|
top + 1,
|
||||||
|
`opencode run -i -s ${meta.session_id}`,
|
||||||
|
right,
|
||||||
|
undefined,
|
||||||
|
TextAttributes.BOLD,
|
||||||
|
)
|
||||||
height = top + mark.length
|
height = top + mark.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,9 +189,7 @@ function waitTurn(done: Wait["done"], signal: AbortSignal) {
|
|||||||
signal.addEventListener("abort", onAbort, { once: true })
|
signal.addEventListener("abort", onAbort, { once: true })
|
||||||
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
|
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
|
||||||
}).pipe(Effect.exit),
|
}).pipe(Effect.exit),
|
||||||
]).pipe(
|
]).pipe(Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value))))
|
||||||
Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value))),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatUnknownError(error: unknown): string {
|
export function formatUnknownError(error: unknown): string {
|
||||||
@@ -380,7 +378,7 @@ function createLayer(input: StreamInput) {
|
|||||||
(events) =>
|
(events) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
void events.stream.return(undefined).catch(() => {})
|
void events.stream.return(undefined).catch(() => {})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
closeStream = () => {
|
closeStream = () => {
|
||||||
@@ -422,10 +420,7 @@ function createLayer(input: StreamInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (event.properties.sessionID !== input.sessionID && !state.subagent.tabs.has(event.properties.sessionID)) {
|
||||||
event.properties.sessionID !== input.sessionID &&
|
|
||||||
!state.subagent.tabs.has(event.properties.sessionID)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,10 +178,12 @@ function createLayer(fs = AppFileSystem.defaultLayer) {
|
|||||||
delete next[key]
|
delete next[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* file.writeJson(MODEL_FILE, {
|
yield* file
|
||||||
...current,
|
.writeJson(MODEL_FILE, {
|
||||||
variant: next,
|
...current,
|
||||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
variant: next,
|
||||||
|
})
|
||||||
|
.pipe(Effect.orElseSucceed(() => undefined))
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ describe("run entry body", () => {
|
|||||||
deletions: 0,
|
deletions: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
|
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
|
||||||
test(item.name, () => {
|
test(item.name, () => {
|
||||||
expect(structured(item.commit)).toEqual(item.snapshot)
|
expect(structured(item.commit)).toEqual(item.snapshot)
|
||||||
@@ -335,26 +335,16 @@ describe("run entry body", () => {
|
|||||||
tool: "bash",
|
tool: "bash",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
toolState: "completed",
|
toolState: "completed",
|
||||||
text: [
|
text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
|
||||||
"/tmp/demo",
|
|
||||||
"git status",
|
|
||||||
"On branch demo",
|
|
||||||
"nothing to commit, working tree clean",
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
command: "git status",
|
command: "git status",
|
||||||
workdir: "/tmp/demo",
|
workdir: "/tmp/demo",
|
||||||
},
|
},
|
||||||
output: [
|
output: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join(
|
||||||
"/tmp/demo",
|
"\n",
|
||||||
"git status",
|
),
|
||||||
"On branch demo",
|
|
||||||
"nothing to commit, working tree clean",
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
title: "git status",
|
title: "git status",
|
||||||
metadata: {
|
metadata: {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { RUN_COMMAND_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "@/cli/cmd/run/footer.command"
|
import {
|
||||||
|
RUN_COMMAND_PANEL_ROWS,
|
||||||
|
RunCommandMenuBody,
|
||||||
|
RunModelSelectBody,
|
||||||
|
RunVariantSelectBody,
|
||||||
|
} from "@/cli/cmd/run/footer.command"
|
||||||
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||||
import type { FooterKeybinds, RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
|
import type { FooterKeybinds, RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
|
||||||
@@ -117,14 +122,17 @@ test("run entry content updates when live commit text changes", async () => {
|
|||||||
tool: "bash",
|
tool: "bash",
|
||||||
})
|
})
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(
|
||||||
<box width={80} height={4}>
|
() => (
|
||||||
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
|
<box width={80} height={4}>
|
||||||
</box>
|
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
|
||||||
), {
|
</box>
|
||||||
width: 80,
|
),
|
||||||
height: 4,
|
{
|
||||||
})
|
width: 80,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
@@ -155,26 +163,29 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
])
|
])
|
||||||
const [variants] = createSignal(["high", "minimal"])
|
const [variants] = createSignal(["high", "minimal"])
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(
|
||||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
() => (
|
||||||
<RunCommandMenuBody
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
<RunCommandMenuBody
|
||||||
commands={commands}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
variants={variants}
|
commands={commands}
|
||||||
keybinds={keybinds}
|
variants={variants}
|
||||||
onClose={() => {}}
|
keybinds={keybinds}
|
||||||
onModel={() => {}}
|
onClose={() => {}}
|
||||||
onVariant={() => {}}
|
onModel={() => {}}
|
||||||
onVariantCycle={() => {}}
|
onVariant={() => {}}
|
||||||
onCommand={() => {}}
|
onVariantCycle={() => {}}
|
||||||
onNew={() => {}}
|
onCommand={() => {}}
|
||||||
onExit={() => {}}
|
onNew={() => {}}
|
||||||
/>
|
onExit={() => {}}
|
||||||
</box>
|
/>
|
||||||
), {
|
</box>
|
||||||
width: 100,
|
),
|
||||||
height: RUN_COMMAND_PANEL_ROWS,
|
{
|
||||||
})
|
width: 100,
|
||||||
|
height: RUN_COMMAND_PANEL_ROWS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
@@ -207,20 +218,23 @@ test("direct model panel renders current model selector", async () => {
|
|||||||
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
|
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
|
||||||
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
|
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(
|
||||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
() => (
|
||||||
<RunModelSelectBody
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
<RunModelSelectBody
|
||||||
providers={providers}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
current={current}
|
providers={providers}
|
||||||
onClose={() => {}}
|
current={current}
|
||||||
onSelect={() => {}}
|
onClose={() => {}}
|
||||||
/>
|
onSelect={() => {}}
|
||||||
</box>
|
/>
|
||||||
), {
|
</box>
|
||||||
width: 100,
|
),
|
||||||
height: RUN_COMMAND_PANEL_ROWS,
|
{
|
||||||
})
|
width: 100,
|
||||||
|
height: RUN_COMMAND_PANEL_ROWS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
@@ -243,20 +257,23 @@ test("direct variant panel renders current variant selector", async () => {
|
|||||||
const [variants] = createSignal(["high", "minimal"])
|
const [variants] = createSignal(["high", "minimal"])
|
||||||
const [current] = createSignal<string | undefined>("high")
|
const [current] = createSignal<string | undefined>("high")
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(
|
||||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
() => (
|
||||||
<RunVariantSelectBody
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
<RunVariantSelectBody
|
||||||
variants={variants}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
current={current}
|
variants={variants}
|
||||||
onClose={() => {}}
|
current={current}
|
||||||
onSelect={() => {}}
|
onClose={() => {}}
|
||||||
/>
|
onSelect={() => {}}
|
||||||
</box>
|
/>
|
||||||
), {
|
</box>
|
||||||
width: 100,
|
),
|
||||||
height: RUN_COMMAND_PANEL_ROWS,
|
{
|
||||||
})
|
width: 100,
|
||||||
|
height: RUN_COMMAND_PANEL_ROWS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
|
|||||||
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
|
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
|
||||||
import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema"
|
import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema"
|
||||||
import { ConfigKeybinds } from "@/config/keybinds"
|
import { ConfigKeybinds } from "@/config/keybinds"
|
||||||
import {
|
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
|
||||||
resolveDiffStyle,
|
|
||||||
resolveFooterKeybinds,
|
|
||||||
resolveModelInfo,
|
|
||||||
} from "@/cli/cmd/run/runtime.boot"
|
|
||||||
|
|
||||||
type RunBinding = Binding<Renderable, KeyEvent>
|
type RunBinding = Binding<Renderable, KeyEvent>
|
||||||
|
|
||||||
@@ -299,5 +295,4 @@ describe("run runtime boot", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ function destroy(commits: ClaimedCommit[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setup(input: {
|
async function setup(
|
||||||
width?: number
|
input: {
|
||||||
wrote?: boolean
|
width?: number
|
||||||
} = {}) {
|
wrote?: boolean
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const out = await createTestRenderer({
|
const out = await createTestRenderer({
|
||||||
width: input.width ?? 80,
|
width: input.width ?? 80,
|
||||||
screenMode: "split-footer",
|
screenMode: "split-footer",
|
||||||
@@ -150,7 +152,8 @@ function toolCommit(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("finalizes markdown tables for streamed and coalesced input", async () => {
|
test("finalizes markdown tables for streamed and coalesced input", async () => {
|
||||||
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
const text =
|
||||||
|
"| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
||||||
|
|
||||||
for (const chunks of [[text], [...text]]) {
|
for (const chunks of [[text], [...text]]) {
|
||||||
const out = await setup()
|
const out = await setup()
|
||||||
@@ -182,7 +185,9 @@ test("holds markdown code blocks until final commit and keeps newline ownership"
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await out.scrollback.append(
|
await out.scrollback.append(
|
||||||
assistant('# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```'),
|
assistant(
|
||||||
|
'# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const progress = claim(out.renderer)
|
const progress = claim(out.renderer)
|
||||||
@@ -543,7 +548,7 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
|
|||||||
take()
|
take()
|
||||||
|
|
||||||
const output = lines.join("\n")
|
const output = lines.join("\n")
|
||||||
expect(output).toContain("total 4\n\n✱ Glob \"**/*tool*\" in src/cli/cmd")
|
expect(output).toContain('total 4\n\n✱ Glob "**/*tool*" in src/cli/cmd')
|
||||||
} finally {
|
} finally {
|
||||||
out.scrollback.destroy()
|
out.scrollback.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ function user(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(input: {
|
function text(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
|
||||||
id: string
|
|
||||||
messageID: string
|
|
||||||
text: string
|
|
||||||
time?: Record<string, number>
|
|
||||||
}) {
|
|
||||||
return {
|
return {
|
||||||
type: "message.part.updated",
|
type: "message.part.updated",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -98,13 +93,7 @@ function delta(messageID: string, partID: string, value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tool(input: {
|
function tool(input: { id: string; messageID: string; tool: string; state: Record<string, unknown>; callID?: string }) {
|
||||||
id: string
|
|
||||||
messageID: string
|
|
||||||
tool: string
|
|
||||||
state: Record<string, unknown>
|
|
||||||
callID?: string
|
|
||||||
}) {
|
|
||||||
return {
|
return {
|
||||||
type: "message.part.updated",
|
type: "message.part.updated",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -144,11 +144,7 @@ function statusMap(busy: boolean): SessionStatusMap {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assistantMessage(input: {
|
function assistantMessage(input: { sessionID: string; id: string; parts: SessionMessage["parts"] }): SessionMessage {
|
||||||
sessionID: string
|
|
||||||
id: string
|
|
||||||
parts: SessionMessage["parts"]
|
|
||||||
}): SessionMessage {
|
|
||||||
return {
|
return {
|
||||||
info: {
|
info: {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
@@ -334,16 +330,18 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|||||||
return { api, commits, events }
|
return { api, commits, events }
|
||||||
}
|
}
|
||||||
|
|
||||||
function sdk(input: {
|
function sdk(
|
||||||
stream?: EventStream
|
input: {
|
||||||
subscribe?: OpencodeClient["event"]["subscribe"]
|
stream?: EventStream
|
||||||
promptAsync?: OpencodeClient["session"]["promptAsync"]
|
subscribe?: OpencodeClient["event"]["subscribe"]
|
||||||
status?: OpencodeClient["session"]["status"]
|
promptAsync?: OpencodeClient["session"]["promptAsync"]
|
||||||
messages?: OpencodeClient["session"]["messages"]
|
status?: OpencodeClient["session"]["status"]
|
||||||
children?: OpencodeClient["session"]["children"]
|
messages?: OpencodeClient["session"]["messages"]
|
||||||
permissions?: OpencodeClient["permission"]["list"]
|
children?: OpencodeClient["session"]["children"]
|
||||||
questions?: OpencodeClient["question"]["list"]
|
permissions?: OpencodeClient["permission"]["list"]
|
||||||
} = {}) {
|
questions?: OpencodeClient["question"]["list"]
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const client = new OpencodeClient()
|
const client = new OpencodeClient()
|
||||||
|
|
||||||
const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream()))
|
const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream()))
|
||||||
@@ -375,51 +373,52 @@ describe("run stream transport", () => {
|
|||||||
messages: async ({ sessionID }) => {
|
messages: async ({ sessionID }) => {
|
||||||
if (sessionID === "session-1") {
|
if (sessionID === "session-1") {
|
||||||
return ok([
|
return ok([
|
||||||
assistantMessage({
|
|
||||||
sessionID: "session-1",
|
|
||||||
id: "msg-1",
|
|
||||||
parts: [
|
|
||||||
runningTool({
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg-1",
|
|
||||||
id: "task-1",
|
|
||||||
callID: "call-1",
|
|
||||||
tool: "task",
|
|
||||||
body: {
|
|
||||||
description: "Explore run folder",
|
|
||||||
subagent_type: "explore",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
sessionId: "child-1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok([
|
|
||||||
assistantMessage({
|
assistantMessage({
|
||||||
sessionID: "child-1",
|
sessionID: "session-1",
|
||||||
id: "msg-child-1",
|
id: "msg-1",
|
||||||
parts: [
|
parts: [
|
||||||
runningTool({
|
runningTool({
|
||||||
sessionID: "child-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg-child-1",
|
messageID: "msg-1",
|
||||||
id: "edit-1",
|
id: "task-1",
|
||||||
callID: "call-edit-1",
|
callID: "call-1",
|
||||||
tool: "edit",
|
tool: "task",
|
||||||
body: {
|
body: {
|
||||||
filePath: "src/run/subagent-data.ts",
|
description: "Explore run folder",
|
||||||
diff: "@@ -1 +1 @@",
|
subagent_type: "explore",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
sessionId: "child-1",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "child-1",
|
||||||
|
id: "msg-child-1",
|
||||||
|
parts: [
|
||||||
|
runningTool({
|
||||||
|
sessionID: "child-1",
|
||||||
|
messageID: "msg-child-1",
|
||||||
|
id: "edit-1",
|
||||||
|
callID: "call-edit-1",
|
||||||
|
tool: "edit",
|
||||||
|
body: {
|
||||||
|
filePath: "src/run/subagent-data.ts",
|
||||||
|
diff: "@@ -1 +1 @@",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
])
|
||||||
},
|
},
|
||||||
children: async () => ok([child("child-1")]),
|
children: async () => ok([child("child-1")]),
|
||||||
permissions: async () => ok([
|
permissions: async () =>
|
||||||
|
ok([
|
||||||
{
|
{
|
||||||
id: "perm-1",
|
id: "perm-1",
|
||||||
sessionID: "child-1",
|
sessionID: "child-1",
|
||||||
@@ -743,7 +742,8 @@ describe("run stream transport", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
ui.events.some(
|
ui.events.some(
|
||||||
(event) => event.type === "stream.view" && event.view.type === "question" && event.view.request.id === request.id,
|
(event) =>
|
||||||
|
event.type === "stream.view" && event.view.type === "question" && event.view.request.id === request.id,
|
||||||
),
|
),
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
|
|
||||||
@@ -982,11 +982,14 @@ describe("run stream transport", () => {
|
|||||||
|
|
||||||
const transport = await createSessionTransport({
|
const transport = await createSessionTransport({
|
||||||
sdk: sdk({
|
sdk: sdk({
|
||||||
subscribe: () => sse((async function* (): AsyncGenerator<SdkEvent> {
|
subscribe: () =>
|
||||||
await ready.promise
|
sse(
|
||||||
yield busy()
|
(async function* (): AsyncGenerator<SdkEvent> {
|
||||||
throw new Error("boom")
|
await ready.promise
|
||||||
})()),
|
yield busy()
|
||||||
|
throw new Error("boom")
|
||||||
|
})(),
|
||||||
|
),
|
||||||
promptAsync: async () => {
|
promptAsync: async () => {
|
||||||
ready.resolve()
|
ready.resolve()
|
||||||
return ok(undefined)
|
return ok(undefined)
|
||||||
|
|||||||
@@ -2,16 +2,7 @@ import { expect, test } from "bun:test"
|
|||||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||||
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
|
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
|
||||||
|
|
||||||
const palette = [
|
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
|
||||||
"#15161e",
|
|
||||||
"#f7768e",
|
|
||||||
"#9ece6a",
|
|
||||||
"#e0af68",
|
|
||||||
"#7aa2f7",
|
|
||||||
"#bb9af7",
|
|
||||||
"#7dcfff",
|
|
||||||
"#c0caf5",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
|
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
|
||||||
return {
|
return {
|
||||||
@@ -28,11 +19,13 @@ function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderer(input: {
|
function renderer(
|
||||||
themeMode?: "dark" | "light"
|
input: {
|
||||||
colors?: TerminalColors
|
themeMode?: "dark" | "light"
|
||||||
fail?: boolean
|
colors?: TerminalColors
|
||||||
} = {}) {
|
fail?: boolean
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
themeMode: input.themeMode,
|
themeMode: input.themeMode,
|
||||||
getPalette: async () => {
|
getPalette: async () => {
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ const providers: RunProvider[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function userMessage(id: string, input: { providerID: string; modelID: string; variant?: string }): SessionMessages[number] {
|
function userMessage(
|
||||||
|
id: string,
|
||||||
|
input: { providerID: string; modelID: string; variant?: string },
|
||||||
|
): SessionMessages[number] {
|
||||||
return {
|
return {
|
||||||
info: {
|
info: {
|
||||||
id,
|
id,
|
||||||
|
|||||||
Reference in New Issue
Block a user