Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,13 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", value)
autocomplete.onInput(value)
syncExtmarksWithPromptParts()
if (props.sessionID && value.length > 0) {
fetch(`${sdk.url}/plugin/input-changed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: props.sessionID, text: value }),
}).catch(() => {})
}
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e) => {
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { PermissionNext } from "@/permission/next"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
import { Worktree } from "../worktree"
import { Plugin } from "../plugin"

// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
Expand Down Expand Up @@ -2673,6 +2674,38 @@ export namespace Server {
return c.json(true)
},
)
.post(
"/plugin/input-changed",
describeRoute({
tags: ["Plugin"],
summary: "Notify plugins of input change",
description: "Fires the tui.input.changed hook to notify plugins when TUI input text changes.",
operationId: "plugin.inputChanged",
responses: {
200: {
description: "Plugins notified successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
sessionID: z.string(),
text: z.string(),
}),
),
async (c) => {
const { sessionID, text } = c.req.valid("json")
await Plugin.trigger("tui.input.changed", { sessionID, text }, {})
return c.json(true)
},
)
.route("/tui/control", TuiRoute)
.put(
"/auth/:providerID",
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/test/server/plugin-input-changed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Log } from "../../src/util/log"

const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })

describe("/plugin/input-changed", () => {
test("returns success when called with valid input", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// #given
const app = Server.App()

// #when
const response = await app.request("/plugin/input-changed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: "test-session", text: "git status" }),
})

// #then
expect(response.status).toBe(200)
const result = await response.json()
expect(result).toBe(true)
},
})
})

test("returns 400 when sessionID is missing", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// #given
const app = Server.App()

// #when
const response = await app.request("/plugin/input-changed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "git status" }),
})

// #then
expect(response.status).toBe(400)
},
})
})

test("returns 400 when text is missing", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// #given
const app = Server.App()

// #when
const response = await app.request("/plugin/input-changed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: "test-session" }),
})

// #then
expect(response.status).toBe(400)
},
})
})
})
8 changes: 8 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,12 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Called when the TUI input text changes. Useful for plugins that want to
* observe user typing behavior (e.g., intent detection, analytics).
*
* - `input.sessionID`: Current session ID
* - `input.text`: The current text in the TUI input
*/
"tui.input.changed"?: (input: { sessionID: string; text: string }, output: {}) => Promise<void>
}
43 changes: 43 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PluginInputChangedErrors,
PluginInputChangedResponses,
ProjectCurrentResponses,
ProjectListResponses,
ProjectUpdateErrors,
Expand Down Expand Up @@ -2808,6 +2810,45 @@ export class Tui extends HeyApiClient {
control = new Control({ client: this.client })
}

export class Plugin extends HeyApiClient {
/**
* Notify plugins of input change
*
* Fires the tui.input.changed hook to notify plugins when TUI input text changes.
*/
public inputChanged<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
sessionID?: string
text?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "body", key: "sessionID" },
{ in: "body", key: "text" },
],
},
],
)
return (options?.client ?? this.client).post<PluginInputChangedResponses, PluginInputChangedErrors, ThrowOnError>({
url: "/plugin/input-changed",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}

export class Event extends HeyApiClient {
/**
* Subscribe to events
Expand Down Expand Up @@ -2879,6 +2920,8 @@ export class OpencodeClient extends HeyApiClient {

tui = new Tui({ client: this.client })

plugin = new Plugin({ client: this.client })

auth = new Auth({ client: this.client })

event = new Event({ client: this.client })
Expand Down
30 changes: 30 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4446,6 +4446,36 @@ export type TuiSelectSessionResponses = {

export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses]

export type PluginInputChangedData = {
body?: {
sessionID: string
text: string
}
path?: never
query?: {
directory?: string
}
url: "/plugin/input-changed"
}

export type PluginInputChangedErrors = {
/**
* Bad request
*/
400: BadRequestError
}

export type PluginInputChangedError = PluginInputChangedErrors[keyof PluginInputChangedErrors]

export type PluginInputChangedResponses = {
/**
* Plugins notified successfully
*/
200: boolean
}

export type PluginInputChangedResponse = PluginInputChangedResponses[keyof PluginInputChangedResponses]

export type TuiControlNextData = {
body?: never
path?: never
Expand Down
Loading