diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ec048f86b2f1..6e069175eebe 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -125,7 +125,6 @@ import { DialogVariant } from "./component/dialog-variant" function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { return { - externalOutputMode: "passthrough", targetFps: 60, gatherStats: false, exitOnCtrlC: false, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 96563b884ede..a80516da1115 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,4 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" @@ -809,8 +809,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] @@ -898,6 +910,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. diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 27190f2eb24e..f6aada220782 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -71,6 +71,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") diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dbf815bd6d79..d0419f40b503 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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" @@ -243,6 +244,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(/[\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 @@ -1313,7 +1385,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 }, ) diff --git a/packages/opencode/src/session/prompt/suggest-next.txt b/packages/opencode/src/session/prompt/suggest-next.txt new file mode 100644 index 000000000000..ebcb3e5d9f7d --- /dev/null +++ b/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" diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 34a79eed112c..5b14764cee2b 100644 --- a/packages/opencode/src/session/status.ts +++ b/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"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 318b8907a91d..b2e585f40ecb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/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"