This commit is contained in:
Dax Raad 2025-11-17 10:55:30 -05:00
parent 857c083e35
commit e6d549497c
6 changed files with 105 additions and 91 deletions

View file

@ -3,7 +3,9 @@
"plugin": ["opencode-openai-codex-auth"],
"provider": {
"opencode": {
"options": {}
}
}
"options": {
// "baseURL": "http://localhost:8080"
},
},
},
}

View file

@ -20,21 +20,12 @@ import { useTheme } from "@tui/context/theme"
import {
BoxRenderable,
ScrollBoxRenderable,
TextAttributes,
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
AssistantMessage,
Part,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
CompactionPart,
} from "@opencode-ai/sdk"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
@ -674,13 +665,6 @@ export function Session() {
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
const status = createMemo(
() =>
sync.data.session_status[route.sessionID] ?? {
type: "idle",
},
)
return (
<context.Provider
value={{
@ -820,7 +804,7 @@ export function Session() {
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={index() === messages().length - 1}
last={pending() === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
@ -829,17 +813,6 @@ export function Session() {
)}
</For>
</scrollbox>
<Show when={status().type !== "idle"}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={local.agent.color(lastUserMessage().agent)}>{Locale.titlecase(lastUserMessage().agent)}</text>
<Shimmer text={lastUserMessage().model.modelID} color={theme.text} />
<Show when={status().type === "retry"}>
<text fg={theme.error}>
{(status() as any).message} [retry #{(status() as any).attempt}]
</text>
</Show>
</box>
</Show>
<box flexShrink={0}>
<Prompt
ref={(r) => (prompt = r)}
@ -957,6 +930,13 @@ function UserMessage(props: {
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
const status = createMemo(
() =>
sync.data.session_status[props.message.sessionID] ?? {
type: "idle",
},
)
return (
<>
<For each={props.parts}>
@ -974,9 +954,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}}
</For>
<Show
when={props.message.error && (props.message.error.name !== "APIError" || !props.message.error.data.isRetryable)}
>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
@ -990,6 +968,17 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
</box>
</Show>
<Show when={props.last && status().type !== "idle"}>
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
<Shimmer text={props.message.modelID} color={theme.text} />
<Show when={status().type === "retry"}>
<text fg={theme.error}>
{(status() as any).message} [attempt #{(status() as any).attempt}]
</text>
</Show>
</box>
</Show>
<Show
when={
props.message.time.completed &&

View file

@ -40,6 +40,7 @@ import { TuiEvent } from "@/cli/cmd/tui/event"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { GlobalBus } from "@/bus/global"
import { SessionStatus } from "@/session/status"
const ERRORS = {
400: {
@ -376,7 +377,7 @@ export namespace Server {
description: "Get session status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), SessionPrompt.Status)),
schema: resolver(z.record(z.string(), SessionStatus.Info)),
},
},
},
@ -384,7 +385,7 @@ export namespace Server {
},
}),
async (c) => {
const result = SessionPrompt.status()
const result = SessionStatus.list()
return c.json(result)
},
)

View file

@ -10,6 +10,7 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "./summary"
import { Bus } from "@/bus"
import { SessionRetry } from "./retry"
import { SessionStatus } from "./status"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@ -49,6 +50,7 @@ export namespace SessionProcessor {
input.abort.throwIfAborted()
switch (value.type) {
case "start":
SessionStatus.set(input.sessionID, { type: "busy" })
break
case "reasoning-start":
@ -325,7 +327,12 @@ export namespace SessionProcessor {
attempt++
const delay = SessionRetry.getRetryDelayInMs(error, attempt)
if (delay) {
await SessionRetry.sleep(delay, input.abort)
SessionStatus.set(input.sessionID, {
type: "retry",
attempt,
message: error.data.message,
})
await SessionRetry.sleep(delay, input.abort).catch(() => {})
continue
}
}

View file

@ -28,9 +28,8 @@ import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import { ModelsDev } from "../provider/models"
import { defer } from "../util/defer"
import { mapValues, mergeDeep, pipe } from "remeda"
import { mergeDeep, pipe } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { Wildcard } from "../util/wildcard"
import { MCP } from "../mcp"
@ -48,39 +47,13 @@ import { NamedError } from "@/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import type { Message } from "vscode-jsonrpc"
import { SessionStatus } from "./status"
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
export const OUTPUT_TOKEN_MAX = 32_000
export const Status = z
.union([
z.object({
type: z.literal("idle"),
}),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Status = z.infer<typeof Status>
export const Event = {
Status: Bus.event(
"session.status",
z.object({
sessionID: z.string(),
status: Status,
}),
),
Idle: Bus.event(
"session.idle",
z.object({
@ -95,7 +68,6 @@ export namespace SessionPrompt {
string,
{
abort: AbortController
status: Status
callbacks: {
resolve(input: MessageV2.WithParts): void
reject(): void
@ -111,21 +83,9 @@ export namespace SessionPrompt {
},
)
export function status() {
return mapValues(state(), (item) => item.status)
}
export function getStatus(sessionID: string) {
return (
state()[sessionID]?.status ?? {
type: "idle",
}
)
}
export function assertNotBusy(sessionID: string) {
const status = getStatus(sessionID)
if (status?.type !== "idle") throw new Session.BusyError(sessionID)
const match = state()[sessionID]
if (match) throw new Session.BusyError(sessionID)
}
export const PromptInput = z.object({
@ -252,13 +212,8 @@ export namespace SessionPrompt {
const controller = new AbortController()
s[sessionID] = {
abort: controller,
status: { type: "busy" },
callbacks: [],
}
Bus.publish(Event.Status, {
sessionID,
status: s[sessionID].status,
})
return controller.signal
}
@ -272,10 +227,7 @@ export namespace SessionPrompt {
item.reject()
}
delete s[sessionID]
Bus.publish(Event.Status, {
sessionID,
status: { type: "idle" },
})
SessionStatus.set(sessionID, { type: "idle" })
return
}

View file

@ -0,0 +1,63 @@
import { Bus } from "@/bus"
import { Instance } from "@/project/instance"
import z from "zod"
export namespace SessionStatus {
export const Info = z
.union([
z.object({
type: z.literal("idle"),
}),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Info = z.infer<typeof Info>
export const Event = {
Status: Bus.event(
"session.status",
z.object({
sessionID: z.string(),
status: Info,
}),
),
}
const state = Instance.state(() => {
const data: Record<string, Info> = {}
return data
})
export function get(sessionID: string) {
return (
state()[sessionID] ?? {
type: "idle",
}
)
}
export function list() {
return Object.values(state())
}
export function set(sessionID: string, status: Info) {
Bus.publish(Event.Status, {
sessionID,
status,
})
if (status.type === "idle") {
delete state()[sessionID]
return
}
state()[sessionID] = status
}
}