Compare commits
5 Commits
dev
...
opencode-s
| Author | SHA1 | Date |
|---|---|---|
|
|
3a47b0ed90 | |
|
|
722904fe4f | |
|
|
93cef701c0 | |
|
|
0c3ff84f44 | |
|
|
ba2e3c16b2 |
|
|
@ -128,7 +128,6 @@ function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
externalOutputMode: "passthrough",
|
|
||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
gatherStats: false,
|
gatherStats: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
|
|
|
||||||
|
|
@ -841,8 +841,20 @@ export function Prompt(props: PromptProps) {
|
||||||
return !!current
|
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(() => {
|
const placeholderText = createMemo(() => {
|
||||||
if (props.showPlaceholder === false) return undefined
|
if (props.showPlaceholder === false) return undefined
|
||||||
|
if (suggestion()) return suggestion()
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
if (!shell().length) return undefined
|
if (!shell().length) return undefined
|
||||||
const example = shell()[store.placeholder % shell().length]
|
const example = shell()[store.placeholder % shell().length]
|
||||||
|
|
@ -933,6 +945,16 @@ export function Prompt(props: PromptProps) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
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.
|
// Check clipboard for images before terminal-handled paste runs.
|
||||||
// This helps terminals that forward Ctrl+V to the app; Windows
|
// This helps terminals that forward Ctrl+V to the app; Windows
|
||||||
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
setStore("session_status", event.properties.sessionID, event.properties.status)
|
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export namespace Flag {
|
||||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
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_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
||||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
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_URL = process.env["OPENCODE_MODELS_URL"]
|
||||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||||
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
|
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Plugin } from "../plugin"
|
||||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||||
import MAX_STEPS from "../session/prompt/max-steps.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 { ToolRegistry } from "../tool/registry"
|
||||||
import { Runner } from "@/effect/runner"
|
import { Runner } from "@/effect/runner"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
|
|
@ -249,6 +250,80 @@ 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
|
||||||
|
|
||||||
|
// Use the same model for prompt-cache hit on the conversation prefix
|
||||||
|
const model = yield* Effect.promise(async () =>
|
||||||
|
Provider.getModel(message.providerID, message.modelID).catch(() => undefined),
|
||||||
|
)
|
||||||
|
if (!model) return
|
||||||
|
|
||||||
|
const ag = yield* agents.get(message.agent ?? "code")
|
||||||
|
if (!ag) return
|
||||||
|
|
||||||
|
// 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) =>
|
||||||
|
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
|
||||||
|
|
||||||
|
// Rebuild system prompt identical to the main loop for cache hit
|
||||||
|
const skills = yield* Effect.promise(() => SystemPrompt.skills(ag))
|
||||||
|
const env = yield* Effect.promise(() => SystemPrompt.environment(model))
|
||||||
|
const instructions = yield* instruction.system().pipe(Effect.orDie)
|
||||||
|
const modelMsgs = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model))
|
||||||
|
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
||||||
|
|
||||||
|
const text = yield* Effect.promise(async (signal) => {
|
||||||
|
const result = await LLM.stream({
|
||||||
|
agent: ag,
|
||||||
|
user,
|
||||||
|
system,
|
||||||
|
small: false,
|
||||||
|
tools: {},
|
||||||
|
model,
|
||||||
|
abort: signal,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
retries: 1,
|
||||||
|
toolChoice: "none",
|
||||||
|
// Append suggestion instruction after the full conversation
|
||||||
|
messages: [...modelMsgs, { role: "user" as const, content: PROMPT_SUGGEST_NEXT }],
|
||||||
|
})
|
||||||
|
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 > 110 ? line.slice(0, 107) + "..." : line
|
||||||
|
if ((yield* status.get(input.sessionID)).type !== "idle") return
|
||||||
|
yield* status.suggest(input.sessionID, suggestion)
|
||||||
|
})
|
||||||
|
|
||||||
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
|
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
|
||||||
messages: MessageV2.WithParts[]
|
messages: MessageV2.WithParts[]
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
|
|
@ -1319,7 +1394,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.noReply === true) return message
|
if (input.noReply === true) return message
|
||||||
return yield* loop({ sessionID: input.sessionID })
|
const result = yield* loop({ sessionID: input.sessionID })
|
||||||
|
yield* suggest({
|
||||||
|
session,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
message: result,
|
||||||
|
}).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, 110 characters max. Be concise.
|
||||||
|
- 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"
|
||||||
|
|
@ -11,6 +11,7 @@ export namespace SessionStatus {
|
||||||
.union([
|
.union([
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("idle"),
|
type: z.literal("idle"),
|
||||||
|
suggestion: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("retry"),
|
type: z.literal("retry"),
|
||||||
|
|
@ -48,6 +49,7 @@ export namespace SessionStatus {
|
||||||
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
||||||
readonly list: () => Effect.Effect<Map<SessionID, Info>>
|
readonly list: () => Effect.Effect<Map<SessionID, Info>>
|
||||||
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
||||||
|
readonly suggest: (sessionID: SessionID, suggestion: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
||||||
|
|
@ -81,7 +83,17 @@ export namespace SessionStatus {
|
||||||
data.set(sessionID, status)
|
data.set(sessionID, status)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ get, list, set })
|
const suggest = Effect.fn("SessionStatus.suggest")(function* (sessionID: SessionID, suggestion: string) {
|
||||||
|
const data = yield* InstanceState.get(state)
|
||||||
|
const current = data.get(sessionID)
|
||||||
|
if (current && current.type !== "idle") return
|
||||||
|
const status: Info = { type: "idle", suggestion }
|
||||||
|
// only publish Status so the TUI sees the suggestion;
|
||||||
|
// skip Event.Idle to avoid spurious plugin notifications
|
||||||
|
yield* bus.publish(Event.Status, { sessionID, status })
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, list, set, suggest })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -99,4 +111,8 @@ export namespace SessionStatus {
|
||||||
export async function set(sessionID: SessionID, status: Info) {
|
export async function set(sessionID: SessionID, status: Info) {
|
||||||
return runPromise((svc) => svc.set(sessionID, status))
|
return runPromise((svc) => svc.set(sessionID, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function suggest(sessionID: SessionID, suggestion: string) {
|
||||||
|
return runPromise((svc) => svc.suggest(sessionID, suggestion))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ export type EventPermissionReplied = {
|
||||||
export type SessionStatus =
|
export type SessionStatus =
|
||||||
| {
|
| {
|
||||||
type: "idle"
|
type: "idle"
|
||||||
|
suggestion?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "retry"
|
type: "retry"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue