mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
feat: add Pi-style prompt templates
Add user-defined prompt templates that expand into full prompts with
shell-style argument substitution.
Features:
- Templates loaded from ~/.kit/prompts/*.md and .kit/prompts/*.md
- YAML frontmatter support for description
- Argument placeholders: $1, $2, $@, $ARGUMENTS, ${@:N}, ${@:N:L}
- Autocomplete integration (templates appear as /name commands)
- CLI flags: --prompt-template and --no-prompt-templates
- First-match-wins collision handling with logged diagnostics
Example template:
---
description: Review code for issues
---
Review the following code for bugs and security issues.
Focus on $1 specifically.
Usage: /review error handling
This commit is contained in:
+38
-5
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -66,6 +67,10 @@ var (
|
||||
// TLS configuration
|
||||
tlsSkipVerify bool
|
||||
|
||||
// Prompt templates
|
||||
promptTemplatePaths []string
|
||||
noPromptTemplates bool
|
||||
|
||||
// Preference restoration flags — set in RunE after cobra parses, used
|
||||
// in runNormalMode to decide whether to apply saved preferences.
|
||||
modelFlagChanged bool
|
||||
@@ -296,6 +301,10 @@ func init() {
|
||||
flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)")
|
||||
flags.BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)")
|
||||
|
||||
// Prompt template flags
|
||||
flags.StringArrayVar(&promptTemplatePaths, "prompt-template", nil, "load prompt template file or directory (repeatable)")
|
||||
flags.BoolVar(&noPromptTemplates, "no-prompt-templates", false, "disable prompt template discovery")
|
||||
|
||||
// Model generation parameters
|
||||
flags.IntVar(&maxTokens, "max-tokens", 4096, "maximum number of tokens in the response")
|
||||
flags.Float32Var(&temperature, "temperature", 0.7, "controls randomness in responses (0.0-1.0)")
|
||||
@@ -331,6 +340,8 @@ func init() {
|
||||
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
|
||||
_ = viper.BindPFlag("no-extensions", rootCmd.PersistentFlags().Lookup("no-extensions"))
|
||||
_ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension"))
|
||||
_ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template"))
|
||||
_ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates"))
|
||||
|
||||
// Defaults are already set in flag definitions, no need to duplicate in viper
|
||||
|
||||
@@ -1064,6 +1075,27 @@ func runNormalMode(ctx context.Context) error {
|
||||
// Convert extension commands to UI-layer type for the interactive TUI.
|
||||
extCommands := extensionCommandsForUI(kitInstance)
|
||||
|
||||
// Load prompt templates from standard locations and explicit paths.
|
||||
var promptTemplates []*prompts.PromptTemplate
|
||||
if !noPromptTemplates {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cwd, _ := os.Getwd()
|
||||
tpls, diags, err := prompts.LoadAll(prompts.LoadOptions{
|
||||
Cwd: cwd,
|
||||
HomeDir: homeDir,
|
||||
ExtraPaths: promptTemplatePaths,
|
||||
ConfigPaths: viper.GetStringSlice("prompts"),
|
||||
IncludeDefaults: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load some prompt templates: %v", err)
|
||||
}
|
||||
promptTemplates = tpls
|
||||
for _, d := range diags {
|
||||
log.Printf("Prompt template collision: /%s kept from %s, dropped from %s", d.Name, d.KeptPath, d.DroppedPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Build context/skills display metadata for the startup banner.
|
||||
var contextPaths []string
|
||||
for _, cf := range kitInstance.GetContextFiles() {
|
||||
@@ -1135,7 +1167,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -1143,7 +1175,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -1156,7 +1188,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
@@ -1199,7 +1231,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1297,7 +1329,7 @@ func writeJSONError(err error) {
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -1321,6 +1353,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
|
||||
@@ -183,6 +183,10 @@ type Config struct {
|
||||
|
||||
// TLS configuration
|
||||
TLSSkipVerify bool `json:"tls-skip-verify,omitempty" yaml:"tls-skip-verify,omitempty"`
|
||||
|
||||
// Prompt templates configuration
|
||||
Prompts []string `json:"prompts,omitempty" yaml:"prompts,omitempty"`
|
||||
NoPromptTemplates bool `json:"no-prompt-templates,omitempty" yaml:"no-prompt-templates,omitempty"`
|
||||
}
|
||||
|
||||
// GetTransportType returns the transport type for the server config, mapping
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// frontmatterSep is the YAML frontmatter delimiter.
|
||||
const frontmatterSep = "---"
|
||||
|
||||
// Frontmatter represents the YAML frontmatter in a prompt template file.
|
||||
type Frontmatter struct {
|
||||
// Description summarises what this template provides.
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
// ParseFrontmatter parses YAML frontmatter content into a Frontmatter struct.
|
||||
func ParseFrontmatter(content string) (*Frontmatter, error) {
|
||||
var fm Frontmatter
|
||||
if err := yaml.Unmarshal([]byte(content), &fm); err != nil {
|
||||
return nil, fmt.Errorf("parsing frontmatter: %w", err)
|
||||
}
|
||||
return &fm, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
// LoadOptions configures how templates are discovered and loaded.
|
||||
type LoadOptions struct {
|
||||
// Cwd is the current working directory for project-local discovery.
|
||||
// If empty, the current working directory is used.
|
||||
Cwd string
|
||||
// HomeDir is the user's home directory. If empty, os.UserHomeDir() is used.
|
||||
HomeDir string
|
||||
// ExtraPaths are additional explicit paths to search for templates.
|
||||
ExtraPaths []string
|
||||
// ConfigPaths are paths from configuration files to search.
|
||||
ConfigPaths []string
|
||||
// IncludeDefaults determines whether to include built-in default templates.
|
||||
IncludeDefaults bool
|
||||
}
|
||||
|
||||
// Diagnostic reports a template collision or loading issue.
|
||||
type Diagnostic struct {
|
||||
// Name is the template name that had a collision.
|
||||
Name string
|
||||
// KeptPath is the path of the template that was kept (higher precedence).
|
||||
KeptPath string
|
||||
// DroppedPath is the path of the template that was dropped.
|
||||
DroppedPath string
|
||||
// Reason explains why the collision occurred.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// LoadAll discovers and loads all prompt templates from standard locations
|
||||
// and any extra paths. Templates are loaded in order of precedence (lowest
|
||||
// to highest), with later templates overriding earlier ones of the same name.
|
||||
//
|
||||
// Discovery paths searched in order:
|
||||
// 1. Default templates (if IncludeDefaults)
|
||||
// 2. ~/.kit/prompts/ (global user templates)
|
||||
// 3. .kit/prompts/ (project-local templates)
|
||||
// 4. ConfigPaths (from configuration)
|
||||
// 5. ExtraPaths (explicit paths, highest precedence)
|
||||
func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
|
||||
if opts.Cwd == "" {
|
||||
opts.Cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
if opts.HomeDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting home directory: %w", err)
|
||||
}
|
||||
opts.HomeDir = home
|
||||
}
|
||||
|
||||
var all []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
seen := make(map[string]*PromptTemplate) // name -> template
|
||||
|
||||
// Helper to add templates with deduplication tracking
|
||||
addTemplates := func(templates []*PromptTemplate, source string) {
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
// Collision: report diagnostic, keep existing (lower precedence wins)
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: fmt.Sprintf("template from %s overridden by %s", source, existing.Source),
|
||||
})
|
||||
log.Debug("template collision",
|
||||
"name", tpl.Name,
|
||||
"dropped", tpl.FilePath,
|
||||
"kept", existing.FilePath)
|
||||
} else {
|
||||
tpl.Source = source
|
||||
seen[tpl.Name] = tpl
|
||||
all = append(all, tpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Default templates (lowest precedence)
|
||||
if opts.IncludeDefaults {
|
||||
defaults := loadDefaultTemplates()
|
||||
addTemplates(defaults, "default")
|
||||
}
|
||||
|
||||
// 2. Global user templates: ~/.kit/prompts/
|
||||
globalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(globalDir); err == nil {
|
||||
addTemplates(templates, "global")
|
||||
}
|
||||
|
||||
// 3. Project-local templates: .kit/prompts/
|
||||
localDir := filepath.Join(opts.Cwd, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(localDir); err == nil {
|
||||
addTemplates(templates, "local")
|
||||
}
|
||||
|
||||
// 4. Config paths
|
||||
for _, path := range opts.ConfigPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if templates, err := LoadFromDir(path); err == nil {
|
||||
addTemplates(templates, "config")
|
||||
}
|
||||
} else if strings.HasSuffix(path, ".md") {
|
||||
if tpl, err := ParseTemplate(path); err == nil {
|
||||
addTemplates([]*PromptTemplate{tpl}, "config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Extra paths (highest precedence)
|
||||
for _, path := range opts.ExtraPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if templates, err := LoadFromDir(path); err == nil {
|
||||
addTemplates(templates, "explicit")
|
||||
}
|
||||
} else if strings.HasSuffix(path, ".md") {
|
||||
if tpl, err := ParseTemplate(path); err == nil {
|
||||
addTemplates([]*PromptTemplate{tpl}, "explicit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all, diagnostics, nil
|
||||
}
|
||||
|
||||
// LoadFromDir scans a directory for .md files and loads them as templates.
|
||||
// It looks for *.md files directly in the directory.
|
||||
// Files that fail to parse are logged and skipped.
|
||||
func LoadFromDir(dir string) ([]*PromptTemplate, error) {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, nil // directory doesn't exist — not an error
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading prompts directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
var templates []*PromptTemplate
|
||||
var errs []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
full := filepath.Join(dir, name)
|
||||
tpl, err := ParseTemplate(full)
|
||||
if err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
templates = append(templates, tpl)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return templates, fmt.Errorf("some templates failed to load: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// Deduplicate removes duplicate templates by name, keeping the first occurrence.
|
||||
// It returns the deduplicated list and diagnostics for any collisions.
|
||||
// This is a standalone function for when you need to deduplicate an existing list.
|
||||
func Deduplicate(templates []*PromptTemplate) ([]*PromptTemplate, []Diagnostic) {
|
||||
seen := make(map[string]*PromptTemplate)
|
||||
var result []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: "duplicate template name (first-match-wins)",
|
||||
})
|
||||
} else {
|
||||
seen[tpl.Name] = tpl
|
||||
result = append(result, tpl)
|
||||
}
|
||||
}
|
||||
|
||||
return result, diagnostics
|
||||
}
|
||||
|
||||
// loadDefaultTemplates returns the built-in default templates.
|
||||
// These are embedded templates that ship with Kit.
|
||||
func loadDefaultTemplates() []*PromptTemplate {
|
||||
// Default templates can be added here as needed
|
||||
// For now, return an empty slice - users can define their own templates
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAll_Integration(t *testing.T) {
|
||||
// Create a temp directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create the .kit/prompts subdirectory structure
|
||||
promptsDir := filepath.Join(tempDir, ".kit", "prompts")
|
||||
if err := os.MkdirAll(promptsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create prompts dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test template file
|
||||
templateContent := `---
|
||||
description: Test template for integration
|
||||
---
|
||||
Review $1 with focus on $2`
|
||||
|
||||
testFile := filepath.Join(promptsDir, "test.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test loading from the temp directory
|
||||
tpls, diags, err := LoadAll(LoadOptions{
|
||||
HomeDir: tempDir,
|
||||
IncludeDefaults: false, // Skip default locations for this test
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAll failed: %v", err)
|
||||
}
|
||||
|
||||
if len(diags) > 0 {
|
||||
t.Logf("Got %d diagnostics", len(diags))
|
||||
}
|
||||
|
||||
if len(tpls) != 1 {
|
||||
t.Fatalf("Expected 1 template, got %d", len(tpls))
|
||||
}
|
||||
|
||||
tpl := tpls[0]
|
||||
if tpl.Name != "test" {
|
||||
t.Errorf("Expected name 'test', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
if tpl.Description != "Test template for integration" {
|
||||
t.Errorf("Expected description 'Test template for integration', got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
// Test expansion
|
||||
expanded := tpl.Expand("code security")
|
||||
expected := "Review code with focus on security"
|
||||
if expanded != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplate_WithFrontmatter(t *testing.T) {
|
||||
// Create a temp file with frontmatter
|
||||
tempDir := t.TempDir()
|
||||
templateContent := `---
|
||||
description: A test template
|
||||
---
|
||||
Create a $1 component with $2 features`
|
||||
|
||||
testFile := filepath.Join(tempDir, "component.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
tpl, err := ParseTemplate(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTemplate failed: %v", err)
|
||||
}
|
||||
|
||||
if tpl.Name != "component" {
|
||||
t.Errorf("Expected name 'component', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
if tpl.Description != "A test template" {
|
||||
t.Errorf("Expected description 'A test template', got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
expectedContent := "Create a $1 component with $2 features"
|
||||
if tpl.Content != expectedContent {
|
||||
t.Errorf("Expected content '%s', got '%s'", expectedContent, tpl.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplate_WithoutFrontmatter(t *testing.T) {
|
||||
// Create a temp file without frontmatter
|
||||
tempDir := t.TempDir()
|
||||
templateContent := `Simple template without frontmatter
|
||||
Supports $1 and $2 placeholders`
|
||||
|
||||
testFile := filepath.Join(tempDir, "simple.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
tpl, err := ParseTemplate(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTemplate failed: %v", err)
|
||||
}
|
||||
|
||||
if tpl.Name != "simple" {
|
||||
t.Errorf("Expected name 'simple', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
// Description should be empty since there's no frontmatter
|
||||
if tpl.Description != "" {
|
||||
t.Errorf("Expected empty description, got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
// Content should include everything
|
||||
if tpl.Content != templateContent {
|
||||
t.Errorf("Content mismatch\nExpected:\n%s\nGot:\n%s", templateContent, tpl.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PromptTemplate is a named prompt template with shell-style argument placeholders.
|
||||
// It supports Pi-style $1, $2, $@, $ARGUMENTS, ${@:N}, ${@:N:L} syntax.
|
||||
type PromptTemplate struct {
|
||||
// Name is the human-readable identifier for this template.
|
||||
Name string
|
||||
// Description summarises what this template provides.
|
||||
Description string
|
||||
// Content is the raw template text with placeholders.
|
||||
Content string
|
||||
// Source indicates where the template was loaded from (e.g., "default", "user").
|
||||
Source string
|
||||
// FilePath is the absolute filesystem path the template was loaded from.
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// ParseTemplate reads a template from a file. The template name is derived
|
||||
// from the filename (without extension). If the file contains YAML frontmatter,
|
||||
// the description is extracted from it.
|
||||
func ParseTemplate(path string) (*PromptTemplate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading template %s: %w", path, err)
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
tpl := &PromptTemplate{
|
||||
FilePath: abs,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
// Parse frontmatter if present
|
||||
if strings.HasPrefix(strings.TrimSpace(content), frontmatterSep) {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
rest := trimmed[len(frontmatterSep):]
|
||||
frontmatter, body, found := strings.Cut(rest, "\n"+frontmatterSep)
|
||||
if found {
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
fm, err := ParseFrontmatter(frontmatter)
|
||||
if err == nil {
|
||||
tpl.Description = fm.Description
|
||||
}
|
||||
tpl.Content = strings.TrimSpace(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive name from filename
|
||||
base := filepath.Base(path)
|
||||
ext := filepath.Ext(base)
|
||||
tpl.Name = strings.TrimSuffix(base, ext)
|
||||
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
// ParseCommandArgs splits a command line into arguments respecting quotes.
|
||||
// It handles single quotes, double quotes, and backslash escaping.
|
||||
func ParseCommandArgs(input string) []string {
|
||||
var args []string
|
||||
var current strings.Builder
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
escaped := false
|
||||
|
||||
for i, r := range input {
|
||||
if escaped {
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\\' && !inSingleQuote {
|
||||
// Backslash escapes next char, but not in single quotes
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\'' && !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '"' && !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
continue
|
||||
}
|
||||
|
||||
if r == ' ' && !inSingleQuote && !inDoubleQuote {
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current.WriteRune(r)
|
||||
_ = i // silence unused warning when we need position later
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// argPlaceholder matches shell-style argument placeholders:
|
||||
// - $1, $2, etc. - positional arguments
|
||||
// - $@ - all arguments
|
||||
// - $ARGUMENTS - all arguments (alias for $@)
|
||||
// - ${@:N} - arguments from N onwards
|
||||
// - ${@:N:L} - L arguments starting from N
|
||||
var argPlaceholder = regexp.MustCompile(`\$\{(\d+)\}|\$\{(\d+):(\d+)\}|\$\{ARGUMENTS\}|\$\{@(:\d+)?(:\d+)?\}|\$(\d+)|\$@|\$ARGUMENTS`)
|
||||
|
||||
// SubstituteArgs replaces argument placeholders in content with values from args.
|
||||
// Supported placeholders:
|
||||
// - $N, ${N} - the Nth argument (1-indexed)
|
||||
// - $@, $ARGUMENTS, ${ARGUMENTS} - all arguments joined with spaces
|
||||
// - ${@:N} - arguments from index N onwards (0-indexed)
|
||||
// - ${@:N:L} - L arguments starting from index N (0-indexed)
|
||||
func SubstituteArgs(content string, args []string) string {
|
||||
return argPlaceholder.ReplaceAllStringFunc(content, func(match string) string {
|
||||
// Check for ${N} or ${N:M} format
|
||||
if strings.HasPrefix(match, "${") && strings.Contains(match, "}") {
|
||||
inner := match[2 : len(match)-1] // Remove ${ and }
|
||||
|
||||
// Check for ${ARGUMENTS}
|
||||
if inner == "ARGUMENTS" {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// Check for ${@...} format
|
||||
if strings.HasPrefix(inner, "@") {
|
||||
return expandAtArgs(inner, args)
|
||||
}
|
||||
|
||||
// Check for ${N:M} format (positional with length)
|
||||
if colonIdx := strings.Index(inner, ":"); colonIdx > 0 {
|
||||
startStr := inner[:colonIdx]
|
||||
rest := inner[colonIdx+1:]
|
||||
|
||||
start, err := strconv.Atoi(startStr)
|
||||
if err != nil || start < 1 {
|
||||
return match
|
||||
}
|
||||
|
||||
// Check if there's a second colon for length ${N:M:L}
|
||||
if secondColonIdx := strings.Index(rest, ":"); secondColonIdx >= 0 {
|
||||
lengthStr := rest[secondColonIdx+1:]
|
||||
length, err := strconv.Atoi(lengthStr)
|
||||
if err != nil || length < 0 {
|
||||
return match
|
||||
}
|
||||
return joinArgsRange(args, start-1, length)
|
||||
}
|
||||
|
||||
// Single colon ${N:M} - M is length
|
||||
length, err := strconv.Atoi(rest)
|
||||
if err != nil || length < 0 {
|
||||
return match
|
||||
}
|
||||
return joinArgsRange(args, start-1, length)
|
||||
}
|
||||
|
||||
// Simple ${N} format
|
||||
n, err := strconv.Atoi(inner)
|
||||
if err != nil || n < 1 {
|
||||
return match
|
||||
}
|
||||
if n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for $N format (without braces)
|
||||
if strings.HasPrefix(match, "$") && !strings.HasPrefix(match, "${") {
|
||||
suffix := match[1:]
|
||||
|
||||
// $@ or $ARGUMENTS
|
||||
if suffix == "@" || suffix == "ARGUMENTS" {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// $N
|
||||
n, err := strconv.Atoi(suffix)
|
||||
if err != nil || n < 1 {
|
||||
return match
|
||||
}
|
||||
if n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// expandAtArgs handles ${@...} patterns (1-indexed like bash)
|
||||
func expandAtArgs(inner string, args []string) string {
|
||||
// Remove the @ prefix
|
||||
rest := inner[1:]
|
||||
|
||||
if rest == "" {
|
||||
// ${@} - all arguments
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// Must start with :
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
rest = rest[1:]
|
||||
|
||||
// Parse start index
|
||||
colonIdx := strings.Index(rest, ":")
|
||||
var startStr, lengthStr string
|
||||
|
||||
if colonIdx >= 0 {
|
||||
startStr = rest[:colonIdx]
|
||||
lengthStr = rest[colonIdx+1:]
|
||||
} else {
|
||||
startStr = rest
|
||||
}
|
||||
|
||||
start, err := strconv.Atoi(startStr)
|
||||
if err != nil || start < 0 {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
|
||||
// Convert from 1-indexed to 0-indexed (bash convention)
|
||||
// Treat 0 as 1 (bash convention: args start at 1)
|
||||
if start > 0 {
|
||||
start--
|
||||
}
|
||||
|
||||
if lengthStr != "" {
|
||||
length, err := strconv.Atoi(lengthStr)
|
||||
if err != nil || length < 0 {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
return joinArgsRange(args, start, length)
|
||||
}
|
||||
|
||||
// ${@:N} - from N to end
|
||||
if start >= len(args) {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(args[start:], " ")
|
||||
}
|
||||
|
||||
// joinArgsRange joins args from start index, taking up to length elements
|
||||
func joinArgsRange(args []string, start, length int) string {
|
||||
if start >= len(args) || length <= 0 {
|
||||
return ""
|
||||
}
|
||||
end := start + length
|
||||
if end > len(args) {
|
||||
end = len(args)
|
||||
}
|
||||
return strings.Join(args[start:end], " ")
|
||||
}
|
||||
|
||||
// Expand substitutes arguments into the template content and returns the result.
|
||||
// It first parses args from the input string, then substitutes them into the template.
|
||||
func (t *PromptTemplate) Expand(argsInput string) string {
|
||||
args := ParseCommandArgs(argsInput)
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
|
||||
// ExpandWithArgs substitutes the provided arguments into the template content.
|
||||
func (t *PromptTemplate) ExpandWithArgs(args []string) string {
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"", []string{}},
|
||||
{"hello", []string{"hello"}},
|
||||
{"hello world", []string{"hello", "world"}},
|
||||
{`"hello world"`, []string{"hello world"}},
|
||||
{`'hello world'`, []string{"hello world"}},
|
||||
{`hello "world foo" bar`, []string{"hello", "world foo", "bar"}},
|
||||
{`hello 'world foo' bar`, []string{"hello", "world foo", "bar"}},
|
||||
{`hello \"world\"`, []string{"hello", `"world"`}},
|
||||
{`hello \\world`, []string{"hello", `\world`}},
|
||||
{` hello world `, []string{"hello", "world"}},
|
||||
{`Button "onClick handler" "disabled support"`, []string{"Button", "onClick handler", "disabled support"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := ParseCommandArgs(tt.input)
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("ParseCommandArgs(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.expected[i] {
|
||||
t.Errorf("ParseCommandArgs(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
args []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no placeholders",
|
||||
content: "Hello world",
|
||||
args: []string{},
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "positional $1",
|
||||
content: "Hello $1",
|
||||
args: []string{"world"},
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "positional $1 $2",
|
||||
content: "$1 and $2",
|
||||
args: []string{"first", "second"},
|
||||
expected: "first and second",
|
||||
},
|
||||
{
|
||||
name: "missing arg",
|
||||
content: "Hello $1 and $2",
|
||||
args: []string{"world"},
|
||||
expected: "Hello world and ",
|
||||
},
|
||||
{
|
||||
name: "$@ wildcard",
|
||||
content: "Args: $@",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "$ARGUMENTS wildcard",
|
||||
content: "Args: $ARGUMENTS",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@} all args",
|
||||
content: "Args: ${@}",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@:2} slice from index 2",
|
||||
content: "Rest: ${@:2}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "Rest: b c d",
|
||||
},
|
||||
{
|
||||
name: "${@:1:2} slice with length",
|
||||
content: "First two: ${@:1:2}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "First two: a b",
|
||||
},
|
||||
{
|
||||
name: "${@:0} from start",
|
||||
content: "All: ${@:0}",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "All: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@:3:1} single arg",
|
||||
content: "Third: ${@:3:1}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "Third: c",
|
||||
},
|
||||
{
|
||||
name: "combined placeholders",
|
||||
content: "Create $1 with features: $ARGUMENTS",
|
||||
args: []string{"Button", "onClick", "disabled"},
|
||||
expected: "Create Button with features: Button onClick disabled",
|
||||
},
|
||||
{
|
||||
name: "slice beyond bounds",
|
||||
content: "${@:10}",
|
||||
args: []string{"a", "b"},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty args with wildcard",
|
||||
content: "Args: $@",
|
||||
args: []string{},
|
||||
expected: "Args: ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SubstituteArgs(tt.content, tt.args)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SubstituteArgs(%q, %v) = %q, want %q", tt.content, tt.args, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantDesc string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple description",
|
||||
content: "description: Review code\n",
|
||||
wantDesc: "Review code",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
content: "",
|
||||
wantDesc: "",
|
||||
},
|
||||
{
|
||||
name: "invalid yaml",
|
||||
content: "description: [unclosed",
|
||||
wantDesc: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fm, err := ParseFrontmatter(tt.content)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseFrontmatter() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if fm.Description != tt.wantDesc {
|
||||
t.Errorf("ParseFrontmatter() Description = %q, want %q", fm.Description, tt.wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptTemplateExpand(t *testing.T) {
|
||||
tpl := &PromptTemplate{
|
||||
Name: "component",
|
||||
Description: "Create a component",
|
||||
Content: "Create a React component named $1 with features: $ARGUMENTS",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "Button",
|
||||
expected: "Create a React component named Button with features: Button",
|
||||
},
|
||||
{
|
||||
input: `Button "onClick handler"`,
|
||||
expected: "Create a React component named Button with features: Button onClick handler",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := tpl.Expand(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Expand(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
@@ -249,6 +250,11 @@ type AppModelOptions struct {
|
||||
// appear in autocomplete, /help, and are dispatched when submitted.
|
||||
ExtensionCommands []ExtensionCommand
|
||||
|
||||
// PromptTemplates are user-defined prompt templates loaded from ~/.kit/prompts/,
|
||||
// .kit/prompts/, or explicit --prompt-template paths. They appear in autocomplete
|
||||
// and are expanded when submitted (e.g., /review → full prompt text).
|
||||
PromptTemplates []*prompts.PromptTemplate
|
||||
|
||||
// ContextPaths lists absolute paths of loaded context files (e.g.
|
||||
// AGENTS.md). Displayed in the [Context] startup section.
|
||||
ContextPaths []string
|
||||
@@ -444,6 +450,10 @@ type AppModel struct {
|
||||
// handleExtensionCommand when submitted.
|
||||
extensionCommands []ExtensionCommand
|
||||
|
||||
// promptTemplates are user-defined prompt templates for expansion.
|
||||
// They appear in autocomplete and are expanded when submitted.
|
||||
promptTemplates []*prompts.PromptTemplate
|
||||
|
||||
// treeSelector is the tree navigation overlay, active in stateTreeSelector.
|
||||
treeSelector *TreeSelectorComponent
|
||||
|
||||
@@ -635,6 +645,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
|
||||
// Store extension commands for dispatch.
|
||||
m.extensionCommands = opts.ExtensionCommands
|
||||
m.promptTemplates = opts.PromptTemplates
|
||||
m.getWidgets = opts.GetWidgets
|
||||
m.getHeader = opts.GetHeader
|
||||
m.getFooter = opts.GetFooter
|
||||
@@ -679,6 +690,17 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge prompt templates into the InputComponent's autocomplete source.
|
||||
if ic, ok := m.input.(*InputComponent); ok && len(opts.PromptTemplates) > 0 {
|
||||
for _, tpl := range opts.PromptTemplates {
|
||||
ic.commands = append(ic.commands, SlashCommand{
|
||||
Name: "/" + tpl.Name,
|
||||
Description: tpl.Description,
|
||||
Category: "Prompts",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
|
||||
m.stream.SetThinkingVisible(m.thinkingVisible)
|
||||
|
||||
@@ -1160,6 +1182,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Expand prompt templates. If the input matches a template name,
|
||||
// substitute arguments and use the expanded content as the prompt.
|
||||
if expanded, ok := m.expandPromptTemplate(msg.Text); ok {
|
||||
msg.Text = expanded
|
||||
}
|
||||
|
||||
// Regular prompt — forward to the app layer.
|
||||
// Preprocess @file references: expand them into XML-wrapped file
|
||||
// content before sending to the agent. The display text (shown in
|
||||
@@ -2086,6 +2114,33 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
|
||||
return func() tea.Msg { return nil }
|
||||
}
|
||||
|
||||
// expandPromptTemplate checks if the submitted text matches a prompt template
|
||||
// and returns the expanded content with arguments substituted.
|
||||
// Returns (expanded, true) if a template was found and expanded, (text, false) otherwise.
|
||||
func (m *AppModel) expandPromptTemplate(text string) (string, bool) {
|
||||
if len(m.promptTemplates) == 0 {
|
||||
return text, false
|
||||
}
|
||||
|
||||
// Only consider inputs that look like slash commands.
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return text, false
|
||||
}
|
||||
|
||||
// Split: "/templatename arg1 arg2" → name="/templatename", args="arg1 arg2"
|
||||
name, args, _ := strings.Cut(text, " ")
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
|
||||
// Find matching template
|
||||
for _, tpl := range m.promptTemplates {
|
||||
if tpl.Name == name {
|
||||
return tpl.Expand(args), true
|
||||
}
|
||||
}
|
||||
|
||||
return text, false
|
||||
}
|
||||
|
||||
// printHelpMessage renders the help text listing all available slash commands.
|
||||
func (m *AppModel) printHelpMessage() {
|
||||
help := "## Available Commands\n\n" +
|
||||
|
||||
Reference in New Issue
Block a user