mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-01 06:42:26 +00:00
feat: hooks
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"packages/tfcode": {
|
||||
"name": "tfcode",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.14",
|
||||
"bin": {
|
||||
"tfcode": "./bin/tfcode",
|
||||
},
|
||||
|
||||
78
packages/app/src/components/dialog-select-prompt.tsx
Normal file
78
packages/app/src/components/dialog-select-prompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user