feat: hooks

This commit is contained in:
Gab
2026-03-27 14:00:04 +11:00
parent 8e05565e84
commit c39a97bb7d
12 changed files with 208 additions and 10 deletions

View File

@@ -381,7 +381,7 @@
},
"packages/tfcode": {
"name": "tfcode",
"version": "1.0.11",
"version": "1.0.14",
"bin": {
"tfcode": "./bin/tfcode",
},

View File

@@ -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 (
<Dialog
title={language.t("dialog.prompt.title")}
description={language.t("dialog.prompt.description", {
agent: currentAgent()?.name ?? "",
})}
>
<Show
when={!promptsQuery.isLoading}
fallback={<div class="p-4 text-center">{language.t("common.loading.ellipsis")}</div>}
>
<List
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.prompt.empty")}
key={(x) => x?.id ?? ""}
items={prompts}
filterKeys={["label", "description"]}
sortBy={(a, b) => a.label.localeCompare(b.label)}
onSelect={(x) => {
if (x) applyPrompt(x)
}}
>
{(i) => (
<div class="w-full flex flex-col gap-0.5 min-w-0">
<span class="truncate text-13-medium">{i.label}</span>
<Show when={i.description}>
<span class="text-11-regular text-text-weaker truncate">{i.description}</span>
</Show>
</div>
)}
</List>
</Show>
</Dialog>
)
}

View File

@@ -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",

View File

@@ -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(() => <DialogSelectPrompt />),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),

View File

@@ -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",

View File

@@ -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<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<AppPromptsResponses, unknown, ThrowOnError>({
url: "/prompts",
...options,
...params,
})
}
}
export class Lsp extends HeyApiClient {

View File

@@ -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<string>
description?: string
}>
}
export type AppPromptsResponse = AppPromptsResponses[keyof AppPromptsResponses]
export type LspStatusData = {
body?: never
path?: never

View File

@@ -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",

View File

@@ -313,7 +313,7 @@ export namespace Agent {
available_to_agents?: string[]
}
async function loadTFPrompts(): Promise<TFPrompt[]> {
export async function loadTFPrompts(): Promise<TFPrompt[]> {
const toolsPath = path.join(os.homedir(), ".tfcode", "tools.json")
try {
const content = await Bun.file(toolsPath).text()

View File

@@ -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<DialogSelectRef<unknown>>()
@@ -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)
}}
/>
)

View File

@@ -374,7 +374,22 @@ export function Prompt(props: PromptProps) {
name: "hooks",
},
onSelect: () => {
dialog.replace(() => <DialogTfHooks />)
dialog.replace(() => (
<DialogTfHooks
onSelect={(hook) => {
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()
}}
/>
))
},
},
]

View File

@@ -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({