From 68d798d2f41f33130982b6e934b67ee471488d8d Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 14 Apr 2026 13:22:10 +0300 Subject: [PATCH] feat(prompts): add $+ required variadic, skip code in placeholders - Add internal/fences package for detecting markdown code regions (fenced blocks and inline code spans) with ReplaceOutside/StripCode - SubstituteArgs, HasArgPlaceholders, RequiredArgs now skip $ placeholders inside ``` fences and `inline` code spans - ProcessFileAttachments skips @file tokens inside code regions - Add $+ placeholder: expands like $@ but requires at least 1 argument - Add RequiredArgs() method; expandPromptTemplate validates arg count and re-populates input on failure instead of submitting - Update feature-request, file-issue, new-prompt to use $+ --- .kit/prompts/feature-request.md | 4 +- .kit/prompts/file-issue.md | 4 +- .kit/prompts/new-prompt.md | 10 +- internal/fences/fences.go | 248 +++++++++++++++++++++++ internal/fences/fences_test.go | 313 ++++++++++++++++++++++++++++++ internal/prompts/template.go | 61 +++++- internal/prompts/template_test.go | 89 +++++++++ internal/ui/fileutil/processor.go | 10 + internal/ui/model.go | 40 +++- 9 files changed, 757 insertions(+), 22 deletions(-) create mode 100644 internal/fences/fences.go create mode 100644 internal/fences/fences_test.go diff --git a/.kit/prompts/feature-request.md b/.kit/prompts/feature-request.md index 98b282d1..bcaf689e 100644 --- a/.kit/prompts/feature-request.md +++ b/.kit/prompts/feature-request.md @@ -2,7 +2,7 @@ description: Create a feature request using the GitHub template --- -Create a feature request for the Kit repository. The user wants to request: $@ +Create a feature request for the Kit repository. The user wants to request: $+ ## Feature Request Template @@ -16,7 +16,7 @@ This prompt uses the `feature_request` GitHub template which requires: ## Steps -1. **Understand the request** from `$@` +1. **Understand the request** from `$+` - What capability is missing? - What would the ideal behavior look like? diff --git a/.kit/prompts/file-issue.md b/.kit/prompts/file-issue.md index 27652558..688f171b 100644 --- a/.kit/prompts/file-issue.md +++ b/.kit/prompts/file-issue.md @@ -2,7 +2,7 @@ description: File a GitHub issue using the appropriate template --- -File a GitHub issue for the Kit repository. The user wants to create an issue about: $@ +File a GitHub issue for the Kit repository. The user wants to create an issue about: $+ ## Issue Templates Available @@ -16,7 +16,7 @@ This repository has structured issue templates. You MUST use the appropriate tem ## Steps -1. **Determine the issue type** from `$@`: +1. **Determine the issue type** from `$+`: - Bug → use `--template bug_report` - Feature → use `--template feature_request` - Documentation → use `--template documentation` diff --git a/.kit/prompts/new-prompt.md b/.kit/prompts/new-prompt.md index c5d58cdc..102f93e0 100644 --- a/.kit/prompts/new-prompt.md +++ b/.kit/prompts/new-prompt.md @@ -2,7 +2,7 @@ description: Scaffold a new prompt template in .kit/prompts/ --- -Create a new kit prompt template. The user wants a prompt that does: $@ +Create a new kit prompt template. The user wants a prompt that does: $+ ## What a prompt template is @@ -23,19 +23,21 @@ $1 $2 etc. for positional arguments. - **Filename** → slug: `commit-push.md` becomes `/commit-push` - **Frontmatter**: only `description` is recognised; keep it under ~80 chars - **Body**: plain markdown; the full text is submitted as the user's message when the template fires -- **Arguments**: `$@` expands to everything the user typed after the slash command name; +- **Arguments**: `$+` expands to everything the user typed after the slash command name + (requires at least one argument); `$@` is the same but allows zero arguments; `$1`, `$2` for individual positional args; omit entirely if no arguments are needed ## Steps -1. **Understand the workflow** the user described in `$@` — ask a clarifying question if the intent is ambiguous +1. **Understand the workflow** the user described in `$+` — ask a clarifying question if the intent is ambiguous 2. **Choose a filename**: short, lowercase, hyphen-separated, descriptive (e.g. `code-review.md`) 3. **Write the description**: one sentence, imperative, fits in autocomplete 4. **Draft the body**: - Open with a single sentence stating the goal - Use `## Steps` for multi-step workflows; use plain prose for simple prompts - Be specific: name commands, flags, and file paths where relevant - - End with `$@` on its own line if the user might want to pass context or a hint; omit if the prompt is self-contained + - End with `$+` on its own line if the user must pass context; use `$@` if arguments + are optional; omit if the prompt is self-contained 5. **Write the file** to `.kit/prompts/.md` 6. **Confirm** by showing the final file content and the slash command that activates it diff --git a/internal/fences/fences.go b/internal/fences/fences.go new file mode 100644 index 00000000..ba899228 --- /dev/null +++ b/internal/fences/fences.go @@ -0,0 +1,248 @@ +// Package fences provides utilities for detecting markdown code regions +// (fenced code blocks and inline code spans) and applying transformations +// only to text outside those regions. +// +// This prevents special tokens like $1, $@, or @file from being interpreted +// when they appear inside ``` fences, ~~~ fences, or `inline` code spans. +package fences + +import "strings" + +// Ranges returns byte ranges [start, end) of fenced code blocks in content. +// Recognises both backtick (```) and tilde (~~~) fences, with optional +// leading indentation (up to 3 spaces) and optional info strings. +// An unclosed fence extends to the end of content. +func Ranges(content string) [][2]int { + var result [][2]int + var inFence bool + var fenceChar byte + var fenceCount int + var fenceStart int + + pos := 0 + for pos < len(content) { + // Find the end of the current line. + lineEnd := strings.IndexByte(content[pos:], '\n') + var line string + var nextPos int + if lineEnd < 0 { + line = content[pos:] + nextPos = len(content) + } else { + line = content[pos : pos+lineEnd] + nextPos = pos + lineEnd + 1 + } + + trimmed := strings.TrimLeft(line, " ") + indent := len(line) - len(trimmed) + + if !inFence { + if indent <= 3 { + if ch, n := parseFenceOpen(trimmed); n > 0 { + inFence = true + fenceChar = ch + fenceCount = n + fenceStart = pos + } + } + } else { + if indent <= 3 && isFenceClose(trimmed, fenceChar, fenceCount) { + result = append(result, [2]int{fenceStart, nextPos}) + inFence = false + } + } + + pos = nextPos + } + + // Unclosed fence extends to end of content. + if inFence { + result = append(result, [2]int{fenceStart, len(content)}) + } + + return result +} + +// ReplaceOutside applies fn to each text segment that is outside fenced code +// blocks and inline code spans, leaving code content unchanged. This is the +// primary entry point for callers that need to do regex replacement only on +// non-code text. +func ReplaceOutside(content string, fn func(string) string) string { + ranges := Ranges(content) + if len(ranges) == 0 { + return replaceOutsideInline(content, fn) + } + + var b strings.Builder + b.Grow(len(content)) + pos := 0 + for _, r := range ranges { + if pos < r[0] { + // Within non-fenced segments, also skip inline code spans. + b.WriteString(replaceOutsideInline(content[pos:r[0]], fn)) + } + // Preserve fenced content verbatim. + b.WriteString(content[r[0]:r[1]]) + pos = r[1] + } + if pos < len(content) { + b.WriteString(replaceOutsideInline(content[pos:], fn)) + } + return b.String() +} + +// StripCode returns content with fenced code blocks and inline code spans +// removed. Useful for detection/matching where only non-code text matters. +func StripCode(content string) string { + // First strip fenced blocks. + stripped := StripFenced(content) + // Then strip inline code spans from what remains. + return stripInlineCode(stripped) +} + +// StripFenced returns content with fenced code block regions removed. +// Useful for detection/matching where only non-fenced text matters. +// NOTE: this does NOT strip inline code spans; use StripCode for both. +func StripFenced(content string) string { + ranges := Ranges(content) + if len(ranges) == 0 { + return content + } + + var b strings.Builder + b.Grow(len(content)) + pos := 0 + for _, r := range ranges { + b.WriteString(content[pos:r[0]]) + pos = r[1] + } + b.WriteString(content[pos:]) + return b.String() +} + +// parseFenceOpen checks whether trimmed (leading spaces already removed) +// starts a fenced code block. Returns the fence character and count, or +// (0, 0) if it is not a fence opener. +func parseFenceOpen(trimmed string) (byte, int) { + if len(trimmed) == 0 { + return 0, 0 + } + ch := trimmed[0] + if ch != '`' && ch != '~' { + return 0, 0 + } + count := 0 + for count < len(trimmed) && trimmed[count] == ch { + count++ + } + if count < 3 { + return 0, 0 + } + // Per CommonMark: backtick fences cannot have backticks in the info string. + if ch == '`' && strings.ContainsRune(trimmed[count:], '`') { + return 0, 0 + } + return ch, count +} + +// isFenceClose checks whether trimmed is a closing fence matching fenceChar +// with at least minCount characters. A closing fence line contains only the +// fence characters and optional trailing spaces. +func isFenceClose(trimmed string, fenceChar byte, minCount int) bool { + if len(trimmed) == 0 || trimmed[0] != fenceChar { + return false + } + count := 0 + for count < len(trimmed) && trimmed[count] == fenceChar { + count++ + } + if count < minCount { + return false + } + // Closing fence must contain only fence chars (and optional trailing spaces). + return strings.TrimRight(trimmed[count:], " ") == "" +} + +// -------------------------------------------------------------------------- +// Inline code span handling +// -------------------------------------------------------------------------- + +// inlineCodeRanges returns byte ranges [start, end) of inline code spans +// in segment. Per CommonMark, a code span opens with N backticks and closes +// with exactly N backticks. +func inlineCodeRanges(s string) [][2]int { + var result [][2]int + i := 0 + for i < len(s) { + if s[i] != '`' { + i++ + continue + } + // Count opening backticks. + start := i + n := 0 + for i < len(s) && s[i] == '`' { + n++ + i++ + } + // Scan for a closing run of exactly n backticks. + for j := i; j < len(s); { + if s[j] != '`' { + j++ + continue + } + m := 0 + for j < len(s) && s[j] == '`' { + m++ + j++ + } + if m == n { + result = append(result, [2]int{start, j}) + i = j + break + } + } + // If no closing run was found, i is already past the opening + // backticks so the outer loop advances naturally. + } + return result +} + +// replaceOutsideInline applies fn only to text outside inline code spans. +func replaceOutsideInline(segment string, fn func(string) string) string { + ranges := inlineCodeRanges(segment) + if len(ranges) == 0 { + return fn(segment) + } + var b strings.Builder + b.Grow(len(segment)) + pos := 0 + for _, r := range ranges { + if pos < r[0] { + b.WriteString(fn(segment[pos:r[0]])) + } + b.WriteString(segment[r[0]:r[1]]) + pos = r[1] + } + if pos < len(segment) { + b.WriteString(fn(segment[pos:])) + } + return b.String() +} + +// stripInlineCode removes inline code spans from s. +func stripInlineCode(s string) string { + ranges := inlineCodeRanges(s) + if len(ranges) == 0 { + return s + } + var b strings.Builder + b.Grow(len(s)) + pos := 0 + for _, r := range ranges { + b.WriteString(s[pos:r[0]]) + pos = r[1] + } + b.WriteString(s[pos:]) + return b.String() +} diff --git a/internal/fences/fences_test.go b/internal/fences/fences_test.go new file mode 100644 index 00000000..4f265a21 --- /dev/null +++ b/internal/fences/fences_test.go @@ -0,0 +1,313 @@ +package fences + +import ( + "testing" +) + +func TestRanges(t *testing.T) { + tests := []struct { + name string + content string + want [][2]int + }{ + { + name: "no fences", + content: "hello world\nno code here", + want: nil, + }, + { + name: "single backtick fence", + content: "before\n```\ncode\n```\nafter", + want: [][2]int{{7, 20}}, + }, + { + name: "single tilde fence", + content: "before\n~~~\ncode\n~~~\nafter", + want: [][2]int{{7, 20}}, + }, + { + name: "fence with info string", + content: "before\n```go\ncode\n```\nafter", + want: [][2]int{{7, 22}}, + }, + { + name: "multiple fences", + content: "a\n```\nx\n```\nb\n~~~\ny\n~~~\nc", + want: [][2]int{{2, 12}, {14, 24}}, + }, + { + name: "unclosed fence", + content: "before\n```\ncode\nmore code", + want: [][2]int{{7, 25}}, + }, + { + name: "longer closing fence", + content: "before\n```\ncode\n`````\nafter", + want: [][2]int{{7, 22}}, + }, + { + name: "shorter closing fence ignored", + content: "before\n`````\ncode\n```\nmore\n`````\nafter", + want: [][2]int{{7, 33}}, + }, + { + name: "indented fence up to 3 spaces", + content: "before\n ```\ncode\n ```\nafter", + want: [][2]int{{7, 26}}, + }, + { + name: "4 space indent is not a fence", + content: "before\n ```\ncode\n ```\nafter", + want: nil, + }, + { + name: "backtick in info string rejects open", + // The ```foo`bar line is not a valid opener (backtick in info). + // The standalone ``` becomes an opener with no close. + content: "before\n```foo`bar\ncode\n```\nafter", + want: [][2]int{{23, 32}}, + }, + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "fence only", + content: "```\ncode\n```", + want: [][2]int{{0, 12}}, + }, + { + name: "fence at end without trailing newline", + content: "```\ncode\n```", + want: [][2]int{{0, 12}}, + }, + { + name: "tilde fence does not close with backticks", + content: "~~~\ncode\n```\nmore\n~~~\nafter", + want: [][2]int{{0, 22}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Ranges(tt.content) + if len(got) != len(tt.want) { + t.Fatalf("Ranges() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("Ranges()[%d] = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestReplaceOutside(t *testing.T) { + upper := func(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - 32 + } + } + return string(b) + } + + tests := []struct { + name string + content string + want string + }{ + { + name: "no fences", + content: "hello world", + want: "HELLO WORLD", + }, + { + name: "text around fence", + content: "before\n```\ncode\n```\nafter", + want: "BEFORE\n```\ncode\n```\nAFTER", + }, + { + name: "multiple fences", + content: "aaa\n```\nxxx\n```\nbbb\n~~~\nyyy\n~~~\nccc", + want: "AAA\n```\nxxx\n```\nBBB\n~~~\nyyy\n~~~\nCCC", + }, + { + name: "unclosed fence preserves code", + content: "before\n```\ncode", + want: "BEFORE\n```\ncode", + }, + { + name: "only fenced content", + content: "```\ncode\n```", + want: "```\ncode\n```", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ReplaceOutside(tt.content, upper) + if got != tt.want { + t.Errorf("ReplaceOutside() =\n%s\nwant:\n%s", got, tt.want) + } + }) + } +} + +func TestStripFenced(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "no fences", + content: "hello $1 world", + want: "hello $1 world", + }, + { + name: "strips fenced code", + content: "before $1\n```\n$2 inside\n```\nafter $3", + want: "before $1\nafter $3", + }, + { + name: "multiple fences", + content: "a\n```\nx\n```\nb\n~~~\ny\n~~~\nc", + want: "a\nb\nc", + }, + { + name: "unclosed fence", + content: "before\n```\n$1 inside", + want: "before\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripFenced(tt.content) + if got != tt.want { + t.Errorf("StripFenced() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestInlineCodeRanges(t *testing.T) { + tests := []struct { + name string + s string + want [][2]int + }{ + {"no backticks", "hello world", nil}, + {"single backtick span", "use `$1` here", [][2]int{{4, 8}}}, + {"double backtick span", "use ``$1`` here", [][2]int{{4, 10}}}, + {"multiple spans", "`$1` and `$2`", [][2]int{{0, 4}, {9, 13}}}, + {"unmatched backtick", "use `$1 here", nil}, + {"mismatched backtick counts", "use ``$1` here", nil}, + {"empty inline content", "use `` `` here", [][2]int{{4, 9}}}, + {"backticks inside double", "use ``foo`bar`` here", [][2]int{{4, 15}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inlineCodeRanges(tt.s) + if len(got) != len(tt.want) { + t.Fatalf("inlineCodeRanges() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("inlineCodeRanges()[%d] = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestReplaceOutside_InlineCode(t *testing.T) { + upper := func(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - 32 + } + } + return string(b) + } + + tests := []struct { + name string + content string + want string + }{ + { + name: "inline code preserved", + content: "use `code` here", + want: "USE `code` HERE", + }, + { + name: "double backtick inline code", + content: "use ``co`de`` here", + want: "USE ``co`de`` HERE", + }, + { + name: "mixed fenced and inline", + content: "before `x` mid\n```\nfenced\n```\nafter `y` end", + want: "BEFORE `x` MID\n```\nfenced\n```\nAFTER `y` END", + }, + { + name: "only inline code", + content: "`code`", + want: "`code`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ReplaceOutside(tt.content, upper) + if got != tt.want { + t.Errorf("ReplaceOutside() =\n%s\nwant:\n%s", got, tt.want) + } + }) + } +} + +func TestStripCode(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "no code", + content: "hello $1 world", + want: "hello $1 world", + }, + { + name: "strips inline code", + content: "use `$1` and `$2` for positional args", + want: "use and for positional args", + }, + { + name: "strips fenced and inline", + content: "before `$1`\n```\n$2 inside\n```\nafter", + want: "before \nafter", + }, + { + name: "real world prompt template", + content: "Use $@ for all args.\n`$1`, `$2` for positional.\n```bash\necho $1\n```\n", + want: "Use $@ for all args.\n, for positional.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripCode(tt.content) + if got != tt.want { + t.Errorf("StripCode() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/prompts/template.go b/internal/prompts/template.go index c2846f88..cb9eb311 100644 --- a/internal/prompts/template.go +++ b/internal/prompts/template.go @@ -7,10 +7,12 @@ import ( "regexp" "strconv" "strings" + + "github.com/mark3labs/kit/internal/fences" ) // PromptTemplate is a named prompt template with shell-style argument placeholders. -// It supports Pi-style $1, $2, $@, $ARGUMENTS, ${@:N}, ${@:N:L} syntax. +// 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 @@ -120,19 +122,28 @@ func ParseCommandArgs(input string) []string { // argPlaceholder matches shell-style argument placeholders: // - $1, $2, etc. - positional arguments -// - $@ - all arguments +// - $@ - all arguments (zero or more) +// - $+ - all arguments (one or more required) // - $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`) +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 +// - $@, $+, $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 fences.ReplaceOutside(content, func(segment string) string { + return substituteArgsInSegment(segment, args) + }) +} + +// substituteArgsInSegment performs argument substitution on a single text +// segment that is known to be outside fenced code blocks. +func substituteArgsInSegment(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, "}") { @@ -191,8 +202,8 @@ func SubstituteArgs(content string, args []string) string { if strings.HasPrefix(match, "$") && !strings.HasPrefix(match, "${") { suffix := match[1:] - // $@ or $ARGUMENTS - if suffix == "@" || suffix == "ARGUMENTS" { + // $@, $+, or $ARGUMENTS + if suffix == "@" || suffix == "+" || suffix == "ARGUMENTS" { return strings.Join(args, " ") } @@ -268,8 +279,44 @@ func joinArgsRange(args []string, start, length int) string { // HasArgPlaceholders reports whether the template content contains any // argument placeholders ($1, $@, $ARGUMENTS, ${@:...}, etc.). +// Placeholders inside fenced code blocks and inline code spans are ignored. func (t *PromptTemplate) HasArgPlaceholders() bool { - return argPlaceholder.MatchString(t.Content) + return argPlaceholder.MatchString(fences.StripCode(t.Content)) +} + +// RequiredArgs returns the number of positional arguments the template +// expects. This is determined by the highest $N or ${N} placeholder found +// in the content (1-indexed, so $2 means 2 args required). The $+ +// placeholder (required variadic) ensures at least 1. Optional wildcards +// ($@, $ARGUMENTS) do not contribute to the count. +func (t *PromptTemplate) RequiredArgs() int { + content := fences.StripCode(t.Content) + maxN := 0 + hasRequiredVariadic := strings.Contains(content, "$+") + for _, match := range argPlaceholder.FindAllStringSubmatch(content, -1) { + // Group 1: ${N} format — the N value. + if match[1] != "" { + if n, err := strconv.Atoi(match[1]); err == nil && n > maxN { + maxN = n + } + } + // Group 2: ${N:M} format — the N value (start index). + if match[2] != "" { + if n, err := strconv.Atoi(match[2]); err == nil && n > maxN { + maxN = n + } + } + // Group 6: $N format (no braces) — the N value. + if match[6] != "" { + if n, err := strconv.Atoi(match[6]); err == nil && n > maxN { + maxN = n + } + } + } + if hasRequiredVariadic && maxN < 1 { + maxN = 1 + } + return maxN } // Expand substitutes arguments into the template content and returns the result. diff --git a/internal/prompts/template_test.go b/internal/prompts/template_test.go index 097487b5..c0b8b938 100644 --- a/internal/prompts/template_test.go +++ b/internal/prompts/template_test.go @@ -129,6 +129,48 @@ func TestSubstituteArgs(t *testing.T) { args: []string{}, expected: "Args: ", }, + { + name: "$1 inside code block preserved", + content: "Use $1 here\n```bash\necho $1\n```\ndone", + args: []string{"foo"}, + expected: "Use foo here\n```bash\necho $1\n```\ndone", + }, + { + name: "$@ inside code block preserved", + content: "Run $@\n```\necho $@\n```\n", + args: []string{"a", "b"}, + expected: "Run a b\n```\necho $@\n```\n", + }, + { + name: "all placeholders inside code block", + content: "Prompt\n```\n$1 $2 $@\n```\n", + args: []string{"x"}, + expected: "Prompt\n```\n$1 $2 $@\n```\n", + }, + { + name: "$1 inside inline code preserved", + content: "Use `$1` here and $1 outside", + args: []string{"foo"}, + expected: "Use `$1` here and foo outside", + }, + { + name: "$+ required variadic", + content: "Args: $+", + args: []string{"a", "b", "c"}, + expected: "Args: a b c", + }, + { + name: "$+ with empty args", + content: "Args: $+", + args: []string{}, + expected: "Args: ", + }, + { + name: "all placeholders in inline code", + content: "Use `$1` and `$@` for args", + args: []string{"x"}, + expected: "Use `$1` and `$@` for args", + }, } for _, tt := range tests { @@ -230,6 +272,14 @@ func TestHasArgPlaceholders(t *testing.T) { {"${@:1:2} placeholder", "Slice: ${@:1:2}", true}, {"dollar in text", "Cost is one hundred dollars", false}, {"empty content", "", false}, + {"$1 inside code block only", "Prompt\n```\necho $1\n```\n", false}, + {"$1 outside and inside code block", "Use $1 here\n```\necho $1\n```\n", true}, + {"$@ inside code block only", "Prompt\n```bash\necho $@\n```\n", false}, + {"$+ placeholder", "Run with args: $+", true}, + {"$+ inside inline code only", "Use `$+` for required args", false}, + {"$1 inside inline code only", "Use `$1` for positional args", false}, + {"$1 outside and in inline code", "Create $1 (see `$1` syntax)", true}, + {"$@ outside $1 in inline code", "Run $@ with `$1` syntax", true}, } for _, tt := range tests { @@ -241,3 +291,42 @@ func TestHasArgPlaceholders(t *testing.T) { }) } } + +func TestRequiredArgs(t *testing.T) { + tests := []struct { + name string + content string + want int + }{ + {"no placeholders", "Just a plain prompt", 0}, + {"$1 only", "Create a $1 component", 1}, + {"$1 and $2", "Create $1 with $2", 2}, + {"$3 skipping $2", "Use $1 and $3", 3}, + {"${1} braced", "Name: ${1}", 1}, + {"${2} braced", "Name: ${1} Desc: ${2}", 2}, + {"$@ only", "Run with: $@", 0}, + {"$ARGUMENTS only", "Features: $ARGUMENTS", 0}, + {"${ARGUMENTS} only", "All: ${ARGUMENTS}", 0}, + {"$1 and $@", "Create $1 with extras: $@", 1}, + {"${@:1} slice only", "Rest: ${@:1}", 0}, + {"${@:1:2} slice only", "Slice: ${@:1:2}", 0}, + {"mixed $1 $2 and $@", "Create $1 named $2: $@", 2}, + {"empty content", "", 0}, + {"$2 inside code block only", "Prompt\n```\n$1 $2\n```\n", 0}, + {"$1 outside $2 inside code block", "Use $1\n```\n$2 inside\n```\n", 1}, + {"$+ only", "Run with: $+", 1}, + {"$+ and $2", "Create $2 with: $+", 2}, + {"$+ inside inline code only", "Use `$+` for required args", 0}, + {"$1 and $2 in inline code only", "Use `$1` and `$2` for args", 0}, + {"$1 outside $2 in inline code", "Create $1 (see `$2`)", 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tpl := &PromptTemplate{Content: tt.content} + if got := tpl.RequiredArgs(); got != tt.want { + t.Errorf("RequiredArgs() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/ui/fileutil/processor.go b/internal/ui/fileutil/processor.go index fa2cd7f6..b8826d0c 100644 --- a/internal/ui/fileutil/processor.go +++ b/internal/ui/fileutil/processor.go @@ -6,6 +6,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/mark3labs/kit/internal/fences" ) // fileTokenPattern matches @file references in user text. Supports: @@ -20,6 +22,14 @@ var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`) // // Returns the original text unchanged if no valid @file references are found. func ProcessFileAttachments(text string, cwd string) string { + return fences.ReplaceOutside(text, func(segment string) string { + return processFileTokens(segment, cwd) + }) +} + +// processFileTokens handles @file replacement in a single text segment +// that is known to be outside fenced code blocks. +func processFileTokens(text string, cwd string) string { tokens := fileTokenPattern.FindAllString(text, -1) if len(tokens) == 0 { return text diff --git a/internal/ui/model.go b/internal/ui/model.go index b8c7fa80..28c6faa6 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1485,7 +1485,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 { + if expanded, ok, validationErr := m.expandPromptTemplate(msg.Text); validationErr != "" { + // Validation failed — re-populate the input so the user can + // append the missing arguments without retyping. + if ic, ok := m.input.(*InputComponent); ok { + ic.textarea.SetValue(msg.Text + " ") + ic.textarea.CursorEnd() + } + return m, tea.Batch(cmds...) + } else if ok { msg.Text = expanded } @@ -2886,15 +2894,20 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd { // 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) { +// +// Return values: +// - (expanded, true, "") — template matched and expanded successfully +// - (text, false, "") — no template matched; caller should treat text as-is +// - ("", false, reason) — template matched but validation failed; reason +// contains a user-facing error message (already printed to ScrollList) +func (m *AppModel) expandPromptTemplate(text string) (string, bool, string) { if len(m.promptTemplates) == 0 { - return text, false + return text, false, "" } // Only consider inputs that look like slash commands. if !strings.HasPrefix(text, "/") { - return text, false + return text, false, "" } // Split: "/templatename arg1 arg2" → name="/templatename", args="arg1 arg2" @@ -2904,11 +2917,24 @@ func (m *AppModel) expandPromptTemplate(text string) (string, bool) { // Find matching template for _, tpl := range m.promptTemplates { if tpl.Name == name { - return tpl.Expand(args), true + // Validate that enough positional arguments were provided. + required := tpl.RequiredArgs() + if required > 0 { + provided := len(prompts.ParseCommandArgs(args)) + if provided < required { + reason := fmt.Sprintf( + "/%s requires %d argument(s), got %d", + name, required, provided, + ) + m.printSystemMessage(reason) + return "", false, reason + } + } + return tpl.Expand(args), true, "" } } - return text, false + return text, false, "" } // refreshPromptTemplates reloads prompt templates from the provider callback