diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 11336d5002..b6e36b1953 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -54,6 +54,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ session_status: { [sessionID: string]: SessionStatus } + suggest_debug: { + [sessionID: string]: { state: string; detail?: string; time: number } + } session_diff: { [sessionID: string]: Snapshot.FileDiff[] } @@ -95,6 +98,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider_default: {}, session: [], session_status: {}, + suggest_debug: {}, session_diff: {}, todo: {}, message: {}, @@ -237,6 +241,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "session.suggest_debug": { + setStore("suggest_debug", event.properties.sessionID, { + state: event.properties.state, + detail: event.properties.detail, + time: Date.now(), + }) + break + } + case "message.updated": { const messages = store.message[event.properties.info.sessionID] if (!messages) { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/suggest.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/suggest.tsx new file mode 100644 index 0000000000..7a6f543d21 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/suggest.tsx @@ -0,0 +1,71 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { createMemo, createSignal, onCleanup } from "solid-js" + +const id = "internal:sidebar-suggest" + +function View(props: { api: TuiPluginApi; session_id: string }) { + const theme = () => props.api.theme.current + const debug = createMemo( + () => + (props.api.state.session as any).suggestDebug(props.session_id) as + | { state: string; detail?: string; time: number } + | undefined, + ) + + const [now, setNow] = createSignal(Date.now()) + const timer = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(timer)) + + const age = createMemo(() => { + const d = debug() + if (!d) return "" + const ms = now() - d.time + if (ms < 1000) return "just now" + return `${Math.floor(ms / 1000)}s ago` + }) + + const color = createMemo(() => { + const state = debug()?.state + if (state === "generating") return theme().brand + if (state === "done") return theme().textSuccess ?? "green" + if (state === "error") return theme().textDanger ?? "red" + if (state === "refused") return theme().textWarning ?? "yellow" + return theme().textMuted + }) + + return ( + + + Suggest + + {debug() ? ( + <> + + {debug()!.state} {age()} + + {debug()!.detail ? {debug()!.detail!.slice(0, 38)} : null} + + ) : ( + waiting + )} + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 50, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 529c50cfa3..53e690f3ec 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -173,6 +173,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { status(sessionID) { return sync.data.session_status[sessionID] }, + suggestDebug(sessionID) { + return sync.data.suggest_debug[sessionID] + }, permission(sessionID) { return sync.data.permission[sessionID] ?? [] }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 856ee0ebb1..5ffc6ffaa5 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -6,6 +6,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp" import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" +import SidebarSuggest from "../feature-plugins/sidebar/suggest" import PluginManager from "../feature-plugins/system/plugins" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" @@ -17,6 +18,7 @@ export type InternalTuiPlugin = TuiPluginModule & { export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ HomeFooter, HomeTips, + SidebarSuggest, SidebarContext, SidebarMcp, SidebarLsp, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a339deecf8..68a7ca4b66 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -250,6 +250,9 @@ export namespace SessionPrompt { ) }) + const suggestDebug = (sessionID: SessionID, state: SessionStatus.SuggestState, detail?: string) => + bus.publish(SessionStatus.Event.SuggestDebug, { sessionID, state, detail }) + const suggest = Effect.fn("SessionPrompt.suggest")(function* (input: { session: Session.Info sessionID: SessionID @@ -272,6 +275,8 @@ export namespace SessionPrompt { const ag = yield* agents.get(message.agent ?? "code") if (!ag) return + yield* suggestDebug(input.sessionID, "generating") + // Full message history so the cached KV from the main conversation is reused const msgs = yield* MessageV2.filterCompactedEffect(input.sessionID) const real = (item: MessageV2.WithParts) => @@ -287,7 +292,7 @@ export namespace SessionPrompt { const modelMsgs = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model)) const system = [...env, ...(skills ? [skills] : []), ...instructions] - const text = yield* Effect.promise(async (signal) => { + const exit = yield* Effect.promise(async (signal) => { const result = await LLM.stream({ agent: ag, user, @@ -303,7 +308,14 @@ export namespace SessionPrompt { messages: [...modelMsgs, { role: "user" as const, content: PROMPT_SUGGEST_NEXT }], }) return result.text - }) + }).pipe(Effect.exit) + + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + yield* suggestDebug(input.sessionID, "error", err instanceof Error ? err.message : String(err)) + return + } + const text = exit.value const line = text .replace(/[\s\S]*?<\/think>\s*/g, "") @@ -311,17 +323,24 @@ export namespace SessionPrompt { .map((item) => item.trim()) .find((item) => item.length > 0) ?.replace(/^["'`]+|["'`]+$/g, "") - if (!line) return + if (!line) { + yield* suggestDebug(input.sessionID, "refused", "empty response") + return + } const tag = line .toUpperCase() .replace(/[\s-]+/g, "_") .replace(/[^A-Z_]/g, "") - if (tag === "NO_SUGGESTION") return + if (tag === "NO_SUGGESTION") { + yield* suggestDebug(input.sessionID, "refused", "NO_SUGGESTION") + return + } const suggestion = line.length > 240 ? line.slice(0, 237) + "..." : line if ((yield* status.get(input.sessionID)).type !== "idle") return yield* status.suggest(input.sessionID, suggestion) + yield* suggestDebug(input.sessionID, "done", suggestion) }) const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: { diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 6a85c77127..bc31792d2e 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -28,6 +28,9 @@ export namespace SessionStatus { }) export type Info = z.infer + export const SuggestState = z.enum(["generating", "done", "refused", "error"]) + export type SuggestState = z.infer + export const Event = { Status: BusEvent.define( "session.status", @@ -36,6 +39,14 @@ export namespace SessionStatus { status: Info, }), ), + SuggestDebug: BusEvent.define( + "session.suggest_debug", + z.object({ + sessionID: SessionID.zod, + state: SuggestState, + detail: z.string().optional(), + }), + ), // deprecated Idle: BusEvent.define( "session.idle",