瀏覽代碼

feat(tui): add experimental next-prompt suggestion

Generate an ephemeral user-style next step suggestion after assistant responses and let users accept it with Right Arrow in the prompt. Keep suggestions out of message history and support NO_SUGGESTION refusal.
Ryan Vogel 3 周之前
父節點
當前提交
ba2e3c16b2

+ 22 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -841,8 +841,20 @@ export function Prompt(props: PromptProps) {
     return !!current
   })
 
+  const suggestion = createMemo(() => {
+    if (!props.sessionID) return
+    if (store.mode !== "normal") return
+    if (store.prompt.input) return
+    const current = status()
+    if (current.type !== "idle") return
+    const value = current.suggestion?.trim()
+    if (!value) return
+    return value
+  })
+
   const placeholderText = createMemo(() => {
     if (props.showPlaceholder === false) return undefined
+    if (suggestion()) return suggestion()
     if (store.mode === "shell") {
       if (!shell().length) return undefined
       const example = shell()[store.placeholder % shell().length]
@@ -933,6 +945,16 @@ export function Prompt(props: PromptProps) {
                   e.preventDefault()
                   return
                 }
+                if (!store.prompt.input && e.name === "right" && !e.ctrl && !e.meta && !e.shift && !e.super) {
+                  const value = suggestion()
+                  if (value) {
+                    input.setText(value)
+                    setStore("prompt", "input", value)
+                    input.gotoBufferEnd()
+                    e.preventDefault()
+                    return
+                  }
+                }
                 // Check clipboard for images before terminal-handled paste runs.
                 // This helps terminals that forward Ctrl+V to the app; Windows
                 // Terminal 1.25+ usually handles Ctrl+V before this path.

+ 1 - 0
packages/opencode/src/flag/flag.ts

@@ -73,6 +73,7 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
   export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
   export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
+  export const OPENCODE_EXPERIMENTAL_NEXT_PROMPT = truthy("OPENCODE_EXPERIMENTAL_NEXT_PROMPT")
   export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
   export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
   export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")

+ 81 - 1
packages/opencode/src/session/prompt.ts

@@ -20,6 +20,7 @@ import { Plugin } from "../plugin"
 import PROMPT_PLAN from "../session/prompt/plan.txt"
 import BUILD_SWITCH from "../session/prompt/build-switch.txt"
 import MAX_STEPS from "../session/prompt/max-steps.txt"
+import PROMPT_SUGGEST_NEXT from "../session/prompt/suggest-next.txt"
 import { ToolRegistry } from "../tool/registry"
 import { Runner } from "@/effect/runner"
 import { MCP } from "../mcp"
@@ -249,6 +250,77 @@ export namespace SessionPrompt {
           )
       })
 
+      const suggest = Effect.fn("SessionPrompt.suggest")(function* (input: {
+        session: Session.Info
+        sessionID: SessionID
+        message: MessageV2.WithParts
+      }) {
+        if (input.session.parentID) return
+        const message = input.message.info
+        if (message.role !== "assistant") return
+        if (message.error) return
+        if (!message.finish) return
+        if (["tool-calls", "unknown"].includes(message.finish)) return
+        if ((yield* status.get(input.sessionID)).type !== "idle") return
+
+        const ag = yield* agents.get("title")
+        if (!ag) return
+
+        const model = yield* Effect.promise(async () => {
+          const small = await Provider.getSmallModel(message.providerID).catch(() => undefined)
+          if (small) return small
+          return Provider.getModel(message.providerID, message.modelID).catch(() => undefined)
+        })
+        if (!model) return
+
+        const msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(input.sessionID)))
+        const history = msgs.slice(-8)
+        const real = (item: MessageV2.WithParts) =>
+          item.info.role === "user" && !item.parts.every((part) => "synthetic" in part && part.synthetic)
+        const parent = msgs.find((item) => item.info.id === message.parentID)
+        const user = parent && real(parent) ? parent.info : msgs.findLast((item) => real(item))?.info
+        if (!user || user.role !== "user") return
+
+        const text = yield* Effect.promise(async (signal) => {
+          const result = await LLM.stream({
+            agent: {
+              ...ag,
+              name: "suggest-next",
+              prompt: PROMPT_SUGGEST_NEXT,
+            },
+            user,
+            system: [],
+            small: true,
+            tools: {},
+            model,
+            abort: signal,
+            sessionID: input.sessionID,
+            retries: 1,
+            toolChoice: "none",
+            messages: await MessageV2.toModelMessages(history, model),
+          })
+          return result.text
+        })
+
+        const line = text
+          .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
+          .split("\n")
+          .map((item) => item.trim())
+          .find((item) => item.length > 0)
+          ?.replace(/^["'`]+|["'`]+$/g, "")
+        if (!line) return
+
+        const tag = line
+          .toUpperCase()
+          .replace(/[\s-]+/g, "_")
+          .replace(/[^A-Z_]/g, "")
+        if (tag === "NO_SUGGESTION") return
+
+        const suggestion = line.length > 240 ? line.slice(0, 237) + "..." : line
+        if ((yield* status.get(input.sessionID)).type !== "idle") return
+        yield* status.set(input.sessionID, { type: "idle", suggestion })
+      })
+
       const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
         messages: MessageV2.WithParts[]
         agent: Agent.Info
@@ -1319,7 +1391,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           }
 
           if (input.noReply === true) return message
-          return yield* loop({ sessionID: input.sessionID })
+          const result = yield* loop({ sessionID: input.sessionID })
+          if (Flag.OPENCODE_EXPERIMENTAL_NEXT_PROMPT) {
+            yield* suggest({
+              session,
+              sessionID: input.sessionID,
+              message: result,
+            }).pipe(Effect.ignore, Effect.forkIn(scope))
+          }
+          return result
         },
       )
 

+ 21 - 0
packages/opencode/src/session/prompt/suggest-next.txt

@@ -0,0 +1,21 @@
+You are generating a suggested next user message for the current conversation.
+
+Goal:
+- Suggest a useful next step that keeps momentum.
+
+Rules:
+- Output exactly one line.
+- Write as the user speaking to the assistant (for example: "Can you...", "Help me...", "Let's...").
+- Match the user's tone and language; keep it natural and human.
+- Prefer a concrete action over a broad question.
+- If the conversation is vague or small-talk, steer toward a practical starter request.
+- If there is no meaningful or appropriate next step to suggest, output exactly: NO_SUGGESTION
+- Avoid corporate or robotic phrasing.
+- Avoid asking multiple discovery questions in one sentence.
+- Do not include quotes, labels, markdown, or explanations.
+
+Examples:
+- Greeting context -> "Can you scan this repo and suggest the best first task to tackle?"
+- Bug-fix context -> "Can you reproduce this bug and propose the smallest safe fix?"
+- Feature context -> "Let's implement this incrementally; start with the MVP version first."
+- Conversation is complete -> "NO_SUGGESTION"

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

@@ -11,6 +11,7 @@ export namespace SessionStatus {
     .union([
       z.object({
         type: z.literal("idle"),
+        suggestion: z.string().optional(),
       }),
       z.object({
         type: z.literal("retry"),

+ 1 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -126,6 +126,7 @@ export type EventPermissionReplied = {
 export type SessionStatus =
   | {
       type: "idle"
+      suggestion?: string
     }
   | {
       type: "retry"