feat: prompts

This commit is contained in:
Gab 2026-03-28 16:48:01 +11:00
parent 3cc0f7401a
commit 5b9cc6c0de
17 changed files with 237 additions and 69 deletions

View File

@ -30,8 +30,18 @@ export const DialogSelectPrompt: Component = () => {
const prompts = createMemo(() => {
const all = promptsQuery.data ?? []
const agentId = tfAgentId()
console.log("[DialogSelectPrompt] All prompts:", all.length, "agentId:", agentId, "all:", all)
if (!agentId) return []
return all.filter((p) => p.available_to_agents?.includes(agentId))
const filtered = all.filter((p) => p.available_to_agents?.includes(agentId))
console.log(
"[DialogSelectPrompt] Filtered prompts:",
filtered.length,
"for agentId:",
agentId,
"filtered:",
filtered,
)
return filtered
})
const applyPrompt = (p: { interpolation_string: string }) => {

View File

@ -617,6 +617,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
source: cmd.source,
}))
console.log("[slashCommands] builtin:", builtin.length, "custom:", custom.length)
const promptsBuiltin = builtin.filter((c) => c.trigger === "prompts")
const promptsCustom = custom.filter((c) => c.trigger === "prompts")
if (promptsBuiltin.length > 0) console.log("[slashCommands] promptsBuiltin:", promptsBuiltin)
if (promptsCustom.length > 0) console.log("[slashCommands] promptsCustom:", promptsCustom)
if (sync.data.command.length > 0)
console.log(
"[slashCommands] sync.data.command:",
sync.data.command.map((c) => ({ name: c.name, source: c.source })),
)
return [...custom, ...builtin]
})

View File

@ -14,7 +14,7 @@ export interface SlashCommand {
description?: string
keybind?: string
type: "builtin" | "custom"
source?: "command" | "mcp" | "skill"
source?: "command" | "mcp" | "skill" | "prompt"
}
type PromptPopoverProps = {
@ -122,7 +122,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
: cmd.source === "prompt"
? props.t("prompt.slash.badge.prompt")
: props.t("prompt.slash.badge.custom")}
</span>
</Show>
<Show when={props.commandKeybind(cmd.id)}>

View File

@ -150,7 +150,12 @@ export async function bootstrapDirectory(input: {
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.command.list().then((x) => {
console.log("[bootstrap] command.list result:", x.data?.length, "commands")
const promptsCmd = x.data?.find((c) => c.name === "prompts")
if (promptsCmd) console.log("[bootstrap] Found 'prompts' command in server response:", promptsCmd)
return input.setStore("command", x.data ?? [])
}),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),

View File

@ -274,6 +274,7 @@ export const dict = {
"prompt.slash.badge.custom": "custom",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.slash.badge.prompt": "prompt",
"prompt.context.active": "active",
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",

View File

@ -201,25 +201,25 @@ def sync_tools(config: TFConfig) -> ToolSyncResult:
try:
client = config.get_client()
# Sync agent functions (includes API Functions and Agent Skills)
# Sync agent functions (API auto-paginates up to 5000)
func_result = client.agent_functions.list()
tools = [parse_function(f) for f in func_result.items]
# Sync coder agents
# Sync coder agents (API auto-paginates up to 5000)
try:
agents_result = client.agents.list()
for agent in agents_result.items:
if getattr(agent, 'mode', None) == 'coder':
tools.append(parse_agent(agent))
except Exception as e:
except Exception:
pass
# Sync prompts
# Sync prompts (API auto-paginates up to 5000)
prompts = []
try:
prompts_result = client.prompts.list()
prompts = [parse_prompt(p) for p in prompts_result.items]
except Exception as e:
except Exception:
pass
by_type = {}

View File

@ -265,8 +265,7 @@ export namespace Agent {
}
async function loadTFCoderAgents(): Promise<Info[]> {
// tools.json is synced to ~/.tfcode/tools.json by the CLI
const toolsPath = path.join(os.homedir(), ".tfcode", "tools.json")
const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json")
try {
const content = await Bun.file(toolsPath).text()
const data = JSON.parse(content)
@ -309,18 +308,27 @@ export namespace Agent {
export interface TFPrompt {
id: string
label: string
description?: string
interpolation_string: string
available_to_agents?: string[]
}
const debugFile = (msg: string) => {
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${msg}\n`
Bun.write("/tmp/tfcode-debug.log", line).catch(() => {})
}
export async function loadTFPrompts(): Promise<TFPrompt[]> {
const toolsPath = path.join(os.homedir(), ".tfcode", "tools.json")
const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json")
try {
const content = await Bun.file(toolsPath).text()
const data = JSON.parse(content)
debugFile(`[loadTFPrompts] File loaded, success: ${data.success}, prompts count: ${data.prompts?.length ?? 0}`)
if (!data.success || !data.prompts) return []
return data.prompts as TFPrompt[]
} catch {
} catch (e) {
debugFile(`[loadTFPrompts] Error loading: ${e}`)
return []
}
}

View File

@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { DialogChangelog } from "./ui/dialog-changelog"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogPrompts } from "@tui/component/dialog-prompts"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
@ -156,17 +157,17 @@ export function tui(input: {
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
@ -491,6 +492,17 @@ function App() {
dialog.replace(() => <DialogMcp />)
},
},
{
title: "Switch prompts",
value: "prompt.list",
category: "Agent",
slash: {
name: "prompts",
},
onSelect: () => {
dialog.replace(() => <DialogPrompts />)
},
},
{
title: "Agent cycle",
value: "agent.cycle",

View File

@ -0,0 +1,56 @@
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { createResource, createMemo } from "solid-js"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useLocal } from "@tui/context/local"
export type DialogPromptProps = {
onSelect: (prompt: string) => void
}
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const sdk = useSDK()
const local = useLocal()
dialog.setSize("large")
const [prompts] = createResource(async () => {
const result = await sdk.client.app.prompts()
return result.data ?? []
})
const currentAgent = createMemo(() => local.agent.current())
const tfAgentId = createMemo(() => currentAgent()?.options?.tf_agent_id as string | undefined)
const filteredPrompts = createMemo(() => {
const all = prompts() ?? []
const agentId = tfAgentId()
const debugFile = (msg: string) => {
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${msg}\n`
Bun.write("/tmp/tfcode-debug.log", line).catch(() => {})
}
debugFile(`[DialogPrompt] All prompts: ${all.length}, agentId: ${agentId}`)
if (!agentId) return []
const filtered = all.filter((p) => p.available_to_agents?.includes(agentId))
debugFile(`[DialogPrompt] Filtered prompts: ${filtered.length} for agentId: ${agentId}`)
return filtered
})
const options = createMemo<DialogSelectOption<string>[]>(() => {
const list = filteredPrompts()
const maxWidth = Math.max(0, ...list.map((p) => p.label.length))
return list.map((prompt) => ({
title: prompt.label.padEnd(maxWidth),
description: prompt.description?.replace(/\s+/g, " ").trim(),
value: prompt.interpolation_string,
category: "Prompts",
onSelect: () => {
props.onSelect(prompt.interpolation_string)
dialog.clear()
},
}))
})
return <DialogSelect title="Prompts" placeholder="Search prompts..." options={options()} />
}

View File

@ -0,0 +1,51 @@
import { createMemo } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { usePromptRef } from "@tui/context/prompt"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogPrompts() {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const promptRef = usePromptRef()
const currentAgent = local.agent.current()
const agentId = currentAgent.options?.tf_agent_id as string | undefined
const options = createMemo(() => {
const prompts = sync.data.prompts || []
return prompts
.filter((prompt) => {
if (!agentId) return false
return (
!prompt.available_to_agents ||
prompt.available_to_agents.length === 0 ||
prompt.available_to_agents.includes(agentId)
)
})
.map((prompt) => ({
value: prompt.label,
title: prompt.label,
description: prompt.description || "Prompt template",
}))
})
return (
<DialogSelect
title="Select prompt"
options={options()}
onSelect={(option) => {
const prompt = sync.data.prompts.find((p) => p.label === option.value)
if (prompt && promptRef.current) {
promptRef.current.set({
input: prompt.interpolation_string,
parts: [],
})
dialog.clear()
}
}}
/>
)
}

View File

@ -356,42 +356,6 @@ export function Autocomplete(props: {
)
})
const tfPrompts = createMemo(() => {
if (!store.visible || store.visible === "/") return []
const currentAgent = local.agent.current()
const agentId = currentAgent.options?.tf_agent_id as string | undefined
if (!agentId) return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
const prompts = sync.data.prompts || []
for (const prompt of prompts) {
const isAvailable =
!prompt.available_to_agents ||
prompt.available_to_agents.length === 0 ||
prompt.available_to_agents.includes(agentId)
if (isAvailable) {
options.push({
display: Locale.truncateMiddle("@" + prompt.label, width),
value: prompt.label,
description: "Prompt template",
onSelect: () => {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(prompt.interpolation_string)
props.input().cursorOffset = Bun.stringWidth(prompt.interpolation_string)
},
})
}
}
return options
})
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...command.slashes()]
@ -425,12 +389,9 @@ export function Autocomplete(props: {
const filesValue = files()
const agentsValue = agents()
const commandsValue = commands()
const promptsValue = tfPrompts()
const mixed: AutocompleteOption[] =
store.visible === "@"
? [...agentsValue, ...promptsValue, ...(filesValue || []), ...mcpResources()]
: [...commandsValue]
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
const searchValue = search()

View File

@ -35,6 +35,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogPrompt } from "../dialog-prompt"
import { DialogTfMcp } from "../dialog-tf-mcp"
import { DialogTfHooks } from "../dialog-tf-hooks"
@ -355,6 +356,28 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Prompts",
value: "prompt.prompts",
category: "Prompt",
slash: {
name: "prompts",
},
onSelect: () => {
dialog.replace(() => (
<DialogPrompt
onSelect={(prompt) => {
input.setText(prompt)
setStore("prompt", {
input: prompt,
parts: [],
})
input.gotoBufferEnd()
}}
/>
))
},
},
{
title: "TF MCP",
value: "prompt.tf_mcp",

View File

@ -31,6 +31,7 @@ import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import path from "path"
import os from "os"
import { Global } from "@/global"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@ -82,6 +83,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
label: string
interpolation_string: string
available_to_agents?: string[]
description?: string
}>
}>({
provider_next: {
@ -123,7 +125,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
async function loadTFPrompts() {
const toolsPath = path.join(os.homedir(), ".tfcode", "tools.json")
const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json")
try {
const content = await Bun.file(toolsPath).text()
const data = JSON.parse(content)

View File

@ -4,6 +4,7 @@ import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
import { Agent } from "../agent/agent"
import { Config } from "../config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
@ -75,6 +76,12 @@ export namespace Command {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const log = (msg: string) => {
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${msg}\n`
Bun.write("/tmp/tfcode-debug.log", line).catch(() => {})
}
const init = Effect.fn("Command.state")(function* (ctx) {
const cfg = yield* Effect.promise(() => Config.get())
const commands: Record<string, Info> = {}
@ -115,6 +122,7 @@ export namespace Command {
}
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
log(`[Command.init] MCP prompt: ${name}`)
commands[name] = {
name,
source: "mcp",
@ -166,7 +174,13 @@ export namespace Command {
const list = Effect.fn("Command.list")(function* () {
const state = yield* InstanceState.get(cache)
return Object.values(state.commands)
const commands = Object.values(state.commands)
log(`[Command.list] Total commands: ${commands.length}`)
const promptsCmd = commands.find((c) => c.name === "prompts")
if (promptsCmd) {
log(`[Command.list] Found 'prompts' command with source: ${promptsCmd.source}`)
}
return commands
})
return Service.of({ get, list })

View File

@ -20,6 +20,11 @@ import { Discovery } from "./discovery"
export namespace Skill {
const log = Log.create({ service: "skill" })
const debugFile = (msg: string) => {
const timestamp = new Date().toISOString()
const line = `[${timestamp}] ${msg}\n`
Bun.write("/tmp/tfcode-debug.log", line).catch(() => {})
}
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
@ -202,16 +207,21 @@ export namespace Skill {
const all = Effect.fn("Skill.all")(function* () {
const cache = yield* ensure()
const skills = Object.values(cache.skills)
// Add TF agent skills from synced tools (only agent_skill type, not coder_agent)
const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json")
try {
const content = yield* Effect.promise(() => Bun.file(toolsPath).text())
const data = JSON.parse(content) as { success: boolean; tools: Array<{ tool_type: string; name: string; description?: string; id: string }> }
const data = JSON.parse(content) as {
success: boolean
tools: Array<{ tool_type: string; name: string; description?: string; id: string }>
}
if (data.success && data.tools) {
debugFile(`[Skill.all] tools.json: ${data.tools.length} tools`)
for (const tool of data.tools) {
// Only include agent_skill (from is_agent_skill=True), not coder_agent
if (tool.tool_type === "agent_skill") {
debugFile(`[Skill.all] Found agent_skill: ${tool.name}`)
skills.push({
name: tool.name,
description: tool.description || "ToothFairyAI Agent Skill",
@ -221,10 +231,12 @@ export namespace Skill {
}
}
}
} catch {
} catch (e) {
debugFile(`[Skill.all] Error loading TF tools: ${e}`)
// Ignore errors loading TF tools
}
debugFile(`[Skill.all] Total skills: ${skills.length} names: ${skills.map((s) => s.name).join(", ")}`)
return skills
})