From 5a51e1c3480215b972fa64f463906341458f9212 Mon Sep 17 00:00:00 2001 From: "m.desoutter" Date: Mon, 6 Jul 2026 15:19:44 +0200 Subject: [PATCH] feat(plugin): add tui.prompt.submit hook for footer prompt interception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new plugin hook that fires when the user submits a prompt in the TUI footer, before the prompt enters the queue. Handlers receive the prompt text, session ID, active turn status, mode, and command context. They can transform the text or set output.consumed to skip queuing entirely — enabling mid-generation steering and other prompt interception use cases from plugins. Handlers run in FIFO registration order. Initially CLI-only; a server-mode RPC bridge can be added as a follow-up. Changes: - plugin/src/index.ts: add tui.prompt.submit to Hooks interface - opencode/src/cli/cmd/run/runtime.queue.ts: footerSubmit registry, sessionID field, invocation in submit() - opencode/src/plugin/index.ts: wire plugin hooks into footerSubmit --- .../opencode/src/cli/cmd/run/runtime.queue.ts | 60 ++++++++++++++++++- packages/opencode/src/plugin/index.ts | 17 ++++++ packages/plugin/src/index.ts | 23 ++++++- 3 files changed, 98 insertions(+), 2 deletions(-) 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 }