From c39a97bb7d6abd4ba7169722f205c5f781ca2f86 Mon Sep 17 00:00:00 2001 From: Gab Date: Fri, 27 Mar 2026 14:00:04 +1100 Subject: [PATCH] feat: hooks --- bun.lock | 2 +- .../src/components/dialog-select-prompt.tsx | 78 +++++++++++++++++++ packages/app/src/i18n/en.ts | 6 ++ .../pages/session/use-session-commands.tsx | 8 ++ packages/sdk/js/script/build.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 26 +++++++ packages/tfcode/package.json | 2 +- packages/tfcode/src/agent/agent.ts | 2 +- .../cli/cmd/tui/component/dialog-tf-hooks.tsx | 12 +-- .../cli/cmd/tui/component/prompt/index.tsx | 17 +++- packages/tfcode/src/server/server.ts | 32 ++++++++ 12 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/components/dialog-select-prompt.tsx diff --git a/bun.lock b/bun.lock index c4901cf7a..808e9f64c 100644 --- a/bun.lock +++ b/bun.lock @@ -381,7 +381,7 @@ }, "packages/tfcode": { "name": "tfcode", - "version": "1.0.11", + "version": "1.0.14", "bin": { "tfcode": "./bin/tfcode", }, diff --git a/packages/app/src/components/dialog-select-prompt.tsx b/packages/app/src/components/dialog-select-prompt.tsx new file mode 100644 index 000000000..8851905d4 --- /dev/null +++ b/packages/app/src/components/dialog-select-prompt.tsx @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/solid-query" +import { Component, createMemo, Show } from "solid-js" +import { useSDK } from "@/context/sdk" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { useLanguage } from "@/context/language" +import { useLocal } from "@/context/local" +import { usePrompt, type Prompt } from "@/context/prompt" +import type { AppPromptsResponse } from "@opencode-ai/sdk/v2" + +export const DialogSelectPrompt: Component = () => { + const sdk = useSDK() + const dialog = useDialog() + const language = useLanguage() + const local = useLocal() + const prompt = usePrompt() + + const promptsQuery = useQuery(() => ({ + queryKey: ["prompts"], + queryFn: async () => { + const result = await sdk.client.app.prompts() + return result.data as AppPromptsResponse + }, + })) + + const currentAgent = createMemo(() => local.agent.current()) + const tfAgentId = createMemo(() => currentAgent()?.options?.tf_agent_id as string | undefined) + + const prompts = createMemo(() => { + const all = promptsQuery.data ?? [] + const agentId = tfAgentId() + if (!agentId) return [] + return all.filter((p) => p.available_to_agents?.includes(agentId)) + }) + + const applyPrompt = (p: { interpolation_string: string }) => { + const text = p.interpolation_string + const parts: Prompt = [{ type: "text", content: text, start: 0, end: text.length }] + prompt.set(parts, text.length) + dialog.close() + } + + return ( + + {language.t("common.loading.ellipsis")}} + > + x?.id ?? ""} + items={prompts} + filterKeys={["label", "description"]} + sortBy={(a, b) => a.label.localeCompare(b.label)} + onSelect={(x) => { + if (x) applyPrompt(x) + }} + > + {(i) => ( +
+ {i.label} + + {i.description} + +
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 579b740d3..fb1064ab6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -69,6 +69,8 @@ export const dict = { "command.agent.cycle.description": "Switch to the next agent", "command.agent.cycle.reverse": "Cycle agent backwards", "command.agent.cycle.reverse.description": "Switch to the previous agent", + "command.prompts.select": "Select prompt", + "command.prompts.select.description": "Select a ToothFairyAI prompt for the current agent", "command.model.variant.cycle": "Cycle thinking effort", "command.model.variant.cycle.description": "Switch to the next effort level", "command.prompt.mode.shell": "Shell", @@ -296,6 +298,10 @@ export const dict = { "dialog.mcp.description": "{{enabled}} of {{total}} enabled", "dialog.mcp.empty": "No MCPs configured", + "dialog.prompt.title": "Select prompt", + "dialog.prompt.description": "Prompts for {{agent}}", + "dialog.prompt.empty": "No prompts available for this agent", + "dialog.lsp.empty": "LSPs auto-detected from file types", "dialog.plugins.empty": "Plugins configured in opencode.json", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f17e3f7a1..78df0d2f2 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -14,6 +14,7 @@ import { useTerminal } from "@/context/terminal" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogSelectPrompt } from "@/components/dialog-select-prompt" import { DialogFork } from "@/components/dialog-fork" import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/util/array" @@ -376,6 +377,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { keybind: "shift+mod+.", onSelect: () => local.agent.move(-1), }), + agentCommand({ + id: "prompts.select", + title: language.t("command.prompts.select"), + description: language.t("command.prompts.select.description"), + slash: "prompts", + onSelect: () => dialog.show(() => ), + }), modelCommand({ id: "model.variant.cycle", title: language.t("command.model.variant.cycle"), diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 268233a01..b3e95b8d2 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,7 +9,7 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode")) +await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../tfcode")) await createClient({ input: "./openapi.json", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7a4f4e40c..4ffe9702b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -7,6 +7,7 @@ import type { AppAgentsResponses, AppLogErrors, AppLogResponses, + AppPromptsResponses, AppSkillsResponses, Auth as Auth3, AuthRemoveErrors, @@ -3858,6 +3859,36 @@ export class App extends HeyApiClient { ...params, }) } + + /** + * List ToothFairyAI prompts + * + * Get prompts assigned to a specific agent or all prompts. + */ + public prompts( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/prompts", + ...options, + ...params, + }) + } } export class Lsp extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86a0c7e42..0fa64ca41 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1919,6 +1919,7 @@ export type Agent = { } variant?: string prompt?: string + goals?: string options: { [key: string]: unknown } @@ -4996,6 +4997,31 @@ export type AppSkillsResponses = { export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] +export type AppPromptsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/prompts" +} + +export type AppPromptsResponses = { + /** + * List of prompts + */ + 200: Array<{ + id: string + label: string + interpolation_string: string + available_to_agents?: Array + description?: string + }> +} + +export type AppPromptsResponse = AppPromptsResponses[keyof AppPromptsResponses] + export type LspStatusData = { body?: never path?: never diff --git a/packages/tfcode/package.json b/packages/tfcode/package.json index d8ce3ca5b..439165b3f 100644 --- a/packages/tfcode/package.json +++ b/packages/tfcode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.11", + "version": "1.0.14", "name": "tfcode", "type": "module", "license": "MIT", diff --git a/packages/tfcode/src/agent/agent.ts b/packages/tfcode/src/agent/agent.ts index 906e6469b..e1a9e014a 100644 --- a/packages/tfcode/src/agent/agent.ts +++ b/packages/tfcode/src/agent/agent.ts @@ -313,7 +313,7 @@ export namespace Agent { available_to_agents?: string[] } - async function loadTFPrompts(): Promise { + export async function loadTFPrompts(): Promise { const toolsPath = path.join(os.homedir(), ".tfcode", "tools.json") try { const content = await Bun.file(toolsPath).text() diff --git a/packages/tfcode/src/cli/cmd/tui/component/dialog-tf-hooks.tsx b/packages/tfcode/src/cli/cmd/tui/component/dialog-tf-hooks.tsx index f510c1d11..cc080d097 100644 --- a/packages/tfcode/src/cli/cmd/tui/component/dialog-tf-hooks.tsx +++ b/packages/tfcode/src/cli/cmd/tui/component/dialog-tf-hooks.tsx @@ -54,7 +54,7 @@ function HookStatus(props: { hook: Hook }) { ) } -export function DialogTfHooks() { +export function DialogTfHooks(props: { onSelect?: (hook: Hook) => void }) { const toast = useToast() const { theme } = useTheme() const [, setRef] = createSignal>() @@ -86,7 +86,9 @@ export function DialogTfHooks() { const region = creds.region || TF_DEFAULT_REGION const baseUrl = REGION_API_URLS[region] || REGION_API_URLS[TF_DEFAULT_REGION] - const response = await fetch(`${baseUrl}/hook/list`, { + const url = new URL(`${baseUrl}/hook/list`) + url.searchParams.set("workspaceid", creds.workspace_id) + const response = await fetch(url.toString(), { method: "GET", headers: { "x-api-key": creds.api_key, @@ -98,8 +100,8 @@ export function DialogTfHooks() { throw new Error(`HTTP ${response.status}: ${errorText}`) } - const data = (await response.json()) as { success?: boolean; hooks?: Hook[] } - return data.hooks || [] + const data = (await response.json()) as Hook[] + return Array.isArray(data) ? data : [] } catch (error) { toast.show({ variant: "error", @@ -172,7 +174,7 @@ export function DialogTfHooks() { keybind={keybinds()} onSelect={(option) => { if (option.value.id === "loading" || option.value.id === "empty") return - // Don't close on select - just show the hook details + props.onSelect?.(option.value) }} /> ) diff --git a/packages/tfcode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/tfcode/src/cli/cmd/tui/component/prompt/index.tsx index ab059e20c..21a0c0b61 100644 --- a/packages/tfcode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/tfcode/src/cli/cmd/tui/component/prompt/index.tsx @@ -374,7 +374,22 @@ export function Prompt(props: PromptProps) { name: "hooks", }, onSelect: () => { - dialog.replace(() => ) + dialog.replace(() => ( + { + const parts = [] + if (hook.code_execution_instructions) parts.push(hook.code_execution_instructions) + if (hook.predefined_code_snippet) parts.push("\n\n```python\n" + hook.predefined_code_snippet + "\n```") + const text = parts.join("") + if (text) { + input.setText(text) + setStore("prompt", { input: text, parts: [] }) + input.gotoBufferEnd() + } + dialog.clear() + }} + /> + )) }, }, ] diff --git a/packages/tfcode/src/server/server.ts b/packages/tfcode/src/server/server.ts index 7ead4df8a..bcb025da0 100644 --- a/packages/tfcode/src/server/server.ts +++ b/packages/tfcode/src/server/server.ts @@ -454,6 +454,38 @@ export namespace Server { return c.json(skills) }, ) + .get( + "/prompts", + describeRoute({ + summary: "List ToothFairyAI prompts", + description: "Get prompts assigned to a specific agent or all prompts.", + operationId: "app.prompts", + responses: { + 200: { + description: "List of prompts", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + id: z.string(), + label: z.string(), + interpolation_string: z.string(), + available_to_agents: z.array(z.string()).optional(), + description: z.string().optional(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const prompts = await Agent.loadTFPrompts() + return c.json(prompts) + }, + ) .get( "/lsp", describeRoute({