瀏覽代碼

feat(tui): add sidebar debug panel for suggest feature status

Adds a temporary debug indicator in the sidebar showing the suggest
lifecycle: generating, done, refused, error. Helps diagnose whether
the suggestion pipeline is running and what results it produces.
Ryan Vogel 2 周之前
父節點
當前提交
722904fe4f

+ 13 - 0
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) {

+ 71 - 0
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 (
+    <box>
+      <text fg={theme().text}>
+        <b>Suggest</b>
+      </text>
+      {debug() ? (
+        <>
+          <text fg={color()}>
+            {debug()!.state} {age()}
+          </text>
+          {debug()!.detail ? <text fg={theme().textMuted}>{debug()!.detail!.slice(0, 38)}</text> : null}
+        </>
+      ) : (
+        <text fg={theme().textMuted}>waiting</text>
+      )}
+    </box>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.slots.register({
+    order: 50,
+    slots: {
+      sidebar_content(_ctx, props) {
+        return <View api={api} session_id={props.session_id} />
+      },
+    },
+  })
+}
+
+const plugin: TuiPluginModule & { id: string } = {
+  id,
+  tui,
+}
+
+export default plugin

+ 3 - 0
packages/opencode/src/cli/cmd/tui/plugin/api.tsx

@@ -173,6 +173,9 @@ function stateApi(sync: ReturnType<typeof useSync>): 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] ?? []
       },

+ 2 - 0
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,

+ 23 - 4
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(/<think>[\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: {

+ 11 - 0
packages/opencode/src/session/status.ts

@@ -28,6 +28,9 @@ export namespace SessionStatus {
     })
   export type Info = z.infer<typeof Info>
 
+  export const SuggestState = z.enum(["generating", "done", "refused", "error"])
+  export type SuggestState = z.infer<typeof SuggestState>
+
   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",