diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index d29e03672a20..8ac420bd8937 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -23,6 +23,37 @@ type Deferred = { reject: (error?: unknown) => void } +/** + * Hook for plugins to intercept footer prompt submissions before they enter + * the queue. Handlers receive context about the active turn and can either + * consume the prompt (output.consumed = true) or transform the text. + * + * Registered plugins' "tui.prompt.submit" handlers are wired here during + * Plugin.init(). Handlers run in FIFO registration order. + */ +type FooterSubmitHandler = ( + input: { + text: string + sessionID: string + active: boolean + mode?: "shell" | "text" + command?: { name: string; arguments: string } + }, + output: { text: string; consumed: boolean }, +) => Promise + +const footerSubmitHandlers: FooterSubmitHandler[] = [] + +export const footerSubmit = { + register(handler: FooterSubmitHandler) { + footerSubmitHandlers.push(handler) + return () => { + const idx = footerSubmitHandlers.indexOf(handler) + if (idx >= 0) footerSubmitHandlers.splice(idx, 1) + } + }, +} + export type QueueInput = { footer: FooterApi initialInput?: string @@ -30,6 +61,8 @@ export type QueueInput = { onSend?: (prompt: RunPrompt) => void onNewSession?: () => void | Promise run: (prompt: RunPrompt, signal: AbortSignal) => Promise + /** The current session ID, passed to tui.prompt.submit handlers. */ + sessionID?: string } type State = { @@ -38,6 +71,7 @@ type State = { active?: RunPrompt ctrl?: AbortController closed: boolean + sessionID: string } function defer(): Deferred { @@ -63,6 +97,7 @@ export async function runPromptQueue(input: QueueInput): Promise { queue: [], queued: [], closed: input.footer.isClosed, + sessionID: "", } let draining: Promise | undefined @@ -265,7 +300,30 @@ export async function runPromptQueue(input: QueueInput): Promise { })() } - const submit = (prompt: RunPrompt) => { + const submit = async (prompt: RunPrompt) => { + // Dispatch to plugin-registered footer submit handlers. + // Plugins may transform or consume the prompt before queuing. + const turnActive = !!(state.active && state.active.mode !== "shell" && !state.active.command) + for (const handler of footerSubmitHandlers) { + const output = { text: prompt.text, consumed: false } + try { + await handler( + { + text: prompt.text, + sessionID: state.sessionID, + active: turnActive, + mode: prompt.mode, + command: prompt.command ? { name: prompt.command.name, arguments: prompt.command.arguments } : undefined, + }, + output, + ) + } catch { + // swallow per-handler errors to keep the queue alive + } + if (output.consumed) return + prompt.text = output.text + } + if (!prompt.text.trim() || state.closed) { return } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1558c707c3f7..6f61fe24f5b9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -248,6 +248,23 @@ const layer = Layer.effect( ) } + // Wire tui.prompt.submit handlers into the footer submit chain. + // These handlers fire in the TUI process when the user submits a prompt. + // The import is dynamic because runtime.queue is a TUI module that isn't + // available in server-only (headless) mode. + yield* Effect.promise(async () => { + try { + const { footerSubmit } = await import("../cli/cmd/run/runtime.queue") + for (const hook of hooks) { + const handler = (hook as any)["tui.prompt.submit"] + if (handler) footerSubmit.register(handler) + } + } catch { + // runtime.queue not available (server-only mode) — tui.prompt.submit + // is CLI-only in the initial implementation. + } + }) + const unsubscribe = yield* events.listen((event) => { if (event.location?.directory !== ctx.directory) return Effect.void return Effect.sync(() => { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..ab93d5c704b3 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -330,6 +330,27 @@ export interface Hooks { ) => Promise /** * Modify tool definitions (description and parameters) sent to LLM - */ + */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + /** + * Fires when the user submits a prompt in the TUI footer, before the prompt + * enters the queue. Handlers receive the prompt text and context (session ID, + * whether a generation turn is active, mode, command). Set `output.consumed` + * to skip queuing entirely (e.g. for mid-generation steering). Set + * `output.text` to transform the text before it reaches the queue. + * + * Handlers run in FIFO order. Each handler receives the output of the + * previous handler. CLI-only in the initial implementation (not yet + * dispatched in server mode via RPC bridge). + */ + "tui.prompt.submit"?: ( + input: { + text: string + sessionID: string + active: boolean + mode?: "shell" | "text" + command?: { name: string; arguments: string } + }, + output: { text: string; consumed: boolean }, + ) => Promise }