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:
Ed Zynda
2026-03-22 19:09:15 +03:00
parent 15a1550205
commit 017eb99d44
8 changed files with 969 additions and 5 deletions
+38 -5
View File
@@ -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,
+4
View File
@@ -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
+25
View File
@@ -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
}
+217
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+289
View File
@@ -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)
}
+215
View File
@@ -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)
}
})
}
}
+55
View File
@@ -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" +