From 53c918c676af204c8e29c50b92ed08386e5d87d4 Mon Sep 17 00:00:00 2001 From: dongxw Date: Wed, 25 Mar 2026 22:45:43 +0800 Subject: [PATCH 1/5] feat(input): support @file prompt expansion --- README.md | 22 ++ internal/app/commands.go | 136 ++++++++--- internal/app/input_expansion.go | 185 +++++++++++++++ internal/app/input_expansion_test.go | 292 ++++++++++++++++++++++++ internal/app/run.go | 8 +- internal/workspacefile/workspacefile.go | 87 +++++++ 6 files changed, 699 insertions(+), 31 deletions(-) create mode 100644 internal/app/input_expansion.go create mode 100644 internal/app/input_expansion_test.go create mode 100644 internal/workspacefile/workspacefile.go diff --git a/README.md b/README.md index 061374e..ab2059f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,28 @@ mscli | `/model ` | Switch LLM model | | `/clear` | Clear chat | +### `@file` Input Expansion + +`ms-cli` supports inline file expansion for plain chat input and these text-tail commands: + +- `/report` +- `/diagnose` +- `/fix` +- `/skill ...` +- direct skill aliases such as `/pdf ...` + +Use a standalone workspace-relative token like `@docs/bug.md` to inline a text file into the prompt. +Use `@@name` to keep a literal `@name` token. + +v1 limits: + +- only standalone whitespace-delimited `@relative/path` tokens are expanded +- paths with spaces are not supported +- files must stay inside the current workspace +- files must be UTF-8 text without NUL bytes +- files larger than `64 KiB` are rejected +- any invalid `@file` reference fails the whole input + ### Server Setup The bug and project server runs separately: diff --git a/internal/app/commands.go b/internal/app/commands.go index 761127e..e1ae09e 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -12,14 +12,15 @@ import ( ) func (a *Application) handleCommand(input string) { - parts := strings.Fields(input) - if len(parts) == 0 { + cmd, ok := splitRawCommand(input) + if !ok { return } + args := strings.Fields(cmd.Remainder) - switch parts[0] { + switch cmd.Name { case "/model": - a.cmdModel(parts[1:]) + a.cmdModel(args) case "/exit": a.cmdExit() case "/compact": @@ -29,63 +30,128 @@ func (a *Application) handleCommand(input string) { case "/test": a.cmdTest() case "/permission": - a.cmdPermission(parts[1:]) + a.cmdPermission(args) case "/yolo": a.cmdYolo() case "/train": - a.cmdTrain(parts[1:]) + a.cmdTrain(args) case "/project": - a.cmdProjectInput(strings.TrimSpace(strings.TrimPrefix(input, "/project"))) + a.cmdProjectInput(cmd.Remainder) case "/login": - a.cmdLogin(parts[1:]) + a.cmdLogin(args) case "/report": - a.cmdUnifiedReport(strings.TrimSpace(strings.TrimPrefix(input, "/report"))) + expanded, err := a.expandReportInput(cmd.Remainder) + if err != nil { + a.emitInputExpansionError(err) + return + } + a.cmdUnifiedReport(expanded) case "/issues": - a.cmdIssues(parts[1:]) + a.cmdIssues(args) case "/__issue_detail": - a.cmdIssueDetail(parts[1:]) + a.cmdIssueDetail(args) case "/__issue_note": - a.cmdIssueNoteInput(strings.TrimSpace(strings.TrimPrefix(input, "/__issue_note"))) + a.cmdIssueNoteInput(cmd.Remainder) case "/__issue_claim": - a.cmdIssueClaim(parts[1:]) + a.cmdIssueClaim(args) case "/status": - a.cmdIssueStatus(parts[1:]) + a.cmdIssueStatus(args) case "/diagnose": - a.cmdDiagnose(strings.TrimSpace(strings.TrimPrefix(input, "/diagnose"))) + expanded, err := a.expandIssueCommandInput(cmd.Remainder) + if err != nil { + a.emitInputExpansionError(err) + return + } + a.cmdDiagnose(expanded) case "/fix": - a.cmdFix(strings.TrimSpace(strings.TrimPrefix(input, "/fix"))) + expanded, err := a.expandIssueCommandInput(cmd.Remainder) + if err != nil { + a.emitInputExpansionError(err) + return + } + a.cmdFix(expanded) case "/bugs": - a.cmdBugs(parts[1:]) + a.cmdBugs(args) case "/__bug_detail": - a.cmdBugDetail(parts[1:]) + a.cmdBugDetail(args) case "/claim": - a.cmdClaim(parts[1:]) + a.cmdClaim(args) case "/close": - a.cmdClose(parts[1:]) + a.cmdClose(args) case "/dock": a.cmdDock() case "/skill": - a.cmdSkill(parts[1:]) + if err := a.handleRawSkillCommand(cmd.Remainder); err != nil { + a.emitInputExpansionError(err) + } case "/skill-add": - a.cmdSkillAddInput(strings.TrimSpace(strings.TrimPrefix(input, "/skill-add"))) + a.cmdSkillAddInput(cmd.Remainder) case "/skill-update": a.cmdSkillUpdate() case "/help": a.cmdHelp() default: - // Check if the command matches a skill name directly (e.g. /pdf → /skill pdf). - skillName := strings.TrimPrefix(parts[0], "/") - if a.skillLoader != nil { - if _, err := a.skillLoader.Load(skillName); err == nil { - a.cmdSkill(append([]string{skillName}, parts[1:]...)) - return + if handled, err := a.handleSkillAliasCommand(cmd.Name, cmd.Remainder); handled { + if err != nil { + a.emitInputExpansionError(err) } + return } a.EventCh <- model.Event{ Type: model.AgentReply, - Message: fmt.Sprintf("Unknown command: %s. Type /help for available commands.", parts[0]), + Message: fmt.Sprintf("Unknown command: %s. Type /help for available commands.", cmd.Name), + } + } +} + +func (a *Application) handleRawSkillCommand(rawInput string) error { + if strings.TrimSpace(rawInput) == "" { + a.cmdSkill(nil) + return nil + } + + skillName, request := splitFirstToken(rawInput) + if skillName == "" { + a.cmdSkill(nil) + return nil + } + + if request != "" { + expanded, err := a.expandInputText(request) + if err != nil { + return err } + request = expanded } + + a.runSkillCommand(skillName, request) + return nil +} + +func (a *Application) handleSkillAliasCommand(commandName, rawRemainder string) (bool, error) { + if a.skillLoader == nil { + return false, nil + } + + skillName := strings.TrimPrefix(strings.TrimSpace(commandName), "/") + if skillName == "" { + return false, nil + } + if _, err := a.skillLoader.Load(skillName); err != nil { + return false, nil + } + + request := strings.TrimSpace(rawRemainder) + if request != "" { + expanded, err := a.expandInputText(request) + if err != nil { + return true, err + } + request = expanded + } + + a.runSkillCommand(skillName, request) + return true, nil } func (a *Application) cmdModel(args []string) { @@ -320,6 +386,11 @@ func (a *Application) cmdSkill(args []string) { } skillName := args[0] + userRequest := strings.TrimSpace(strings.Join(args[1:], " ")) + a.runSkillCommand(skillName, userRequest) +} + +func (a *Application) runSkillCommand(skillName, userRequest string) { content, err := a.skillLoader.Load(skillName) if err != nil { a.EventCh <- model.Event{ @@ -365,7 +436,6 @@ func (a *Application) cmdSkill(args []string) { Summary: fmt.Sprintf("loaded skill: %s", skillName), } - userRequest := strings.TrimSpace(strings.Join(args[1:], " ")) if userRequest == "" { userRequest = defaultSkillRequest(skillName) } @@ -437,6 +507,12 @@ Keybindings: / Start a slash command ctrl+c Cancel/Quit (press twice to exit) +@file Input Expansion: + Plain chat and /report, /diagnose, /fix, /skill, / alias support standalone @relative/path + Use @@name to keep a literal @name token + Files must stay inside the workspace, be UTF-8 text, and be <= 64 KiB + Invalid @file references fail the whole input + Environment Variables: MSCLI_PROVIDER Provider (openai-completion/openai-responses/anthropic) MSCLI_BASE_URL Base URL diff --git a/internal/app/input_expansion.go b/internal/app/input_expansion.go new file mode 100644 index 0000000..cef4479 --- /dev/null +++ b/internal/app/input_expansion.go @@ -0,0 +1,185 @@ +package app + +import ( + "path/filepath" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/vigo999/ms-cli/internal/workspacefile" +) + +var atFilePathPattern = regexp.MustCompile(`^[A-Za-z0-9._/\\-]+$`) + +func (a *Application) expandInputText(text string) (string, error) { + return expandAtFiles(a.WorkDir, text) +} + +func expandAtFiles(workDir, text string) (string, error) { + var out strings.Builder + + for i := 0; i < len(text); { + r := rune(text[i]) + if r < utf8RuneSelf && !isASCIIWhitespace(byte(r)) { + j := i + 1 + for j < len(text) && !isASCIIWhitespace(text[j]) { + j++ + } + token := text[i:j] + replaced, err := replaceAtFileToken(workDir, token) + if err != nil { + return "", err + } + out.WriteString(replaced) + i = j + continue + } + + runeValue, size := utf8.DecodeRuneInString(text[i:]) + if !unicode.IsSpace(runeValue) { + j := i + size + for j < len(text) { + nextRune, nextSize := utf8.DecodeRuneInString(text[j:]) + if unicode.IsSpace(nextRune) { + break + } + j += nextSize + } + token := text[i:j] + replaced, err := replaceAtFileToken(workDir, token) + if err != nil { + return "", err + } + out.WriteString(replaced) + i = j + continue + } + + out.WriteRune(runeValue) + i += size + } + + return out.String(), nil +} + +func replaceAtFileToken(workDir, token string) (string, error) { + switch { + case token == "": + return token, nil + case strings.HasPrefix(token, "@@"): + return token[1:], nil + case !strings.HasPrefix(token, "@") || len(token) == 1: + return token, nil + } + + path := token[1:] + if !atFilePathPattern.MatchString(path) { + return token, nil + } + + content, err := workspacefile.ReadTextFile(workDir, path, workspacefile.DefaultMaxInlineBytes) + if err != nil { + return "", err + } + + return formatExpandedFileBlock(path, content), nil +} + +func formatExpandedFileBlock(path, content string) string { + displayPath := filepath.ToSlash(filepath.Clean(path)) + var b strings.Builder + b.WriteString(`[file path="`) + b.WriteString(displayPath) + b.WriteString("\"]\n") + b.WriteString(content) + if !strings.HasSuffix(content, "\n") { + b.WriteString("\n") + } + b.WriteString(`[/file]`) + return b.String() +} + +type rawCommand struct { + Name string + Remainder string +} + +func splitRawCommand(input string) (rawCommand, bool) { + trimmed := strings.TrimSpace(input) + if trimmed == "" || !strings.HasPrefix(trimmed, "/") { + return rawCommand{}, false + } + + if idx := strings.IndexAny(trimmed, " \t\r\n"); idx >= 0 { + return rawCommand{ + Name: trimmed[:idx], + Remainder: strings.TrimSpace(trimmed[idx+1:]), + }, true + } + + return rawCommand{Name: trimmed}, true +} + +func splitFirstToken(input string) (string, string) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "" + } + + for i, r := range trimmed { + if unicode.IsSpace(r) { + return trimmed[:i], strings.TrimSpace(trimmed[i:]) + } + } + + return trimmed, "" +} + +func (a *Application) expandIssueCommandInput(rawInput string) (string, error) { + first, remainder := splitFirstToken(rawInput) + if first == "" { + return strings.TrimSpace(rawInput), nil + } + if !looksLikeIssueKey(first) { + return a.expandInputText(strings.TrimSpace(rawInput)) + } + if _, err := parseIssueRef(first); err != nil { + return strings.TrimSpace(rawInput), nil + } + if remainder == "" { + return first, nil + } + expanded, err := a.expandInputText(remainder) + if err != nil { + return "", err + } + return strings.TrimSpace(first + " " + expanded), nil +} + +func (a *Application) expandReportInput(rawInput string) (string, error) { + first, remainder := splitFirstToken(rawInput) + if first == "" || remainder == "" { + return strings.TrimSpace(rawInput), nil + } + expanded, err := a.expandInputText(remainder) + if err != nil { + return "", err + } + return strings.TrimSpace(first + " " + expanded), nil +} + +func (a *Application) emitInputExpansionError(err error) { + a.emitToolError("input", "Failed to expand @file input: %v", err) +} + +func isASCIIWhitespace(b byte) bool { + switch b { + case ' ', '\t', '\n', '\r', '\f', '\v': + return true + default: + return false + } +} + +const utf8RuneSelf = 0x80 diff --git a/internal/app/input_expansion_test.go b/internal/app/input_expansion_test.go new file mode 100644 index 0000000..e540c2c --- /dev/null +++ b/internal/app/input_expansion_test.go @@ -0,0 +1,292 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + ctxmanager "github.com/vigo999/ms-cli/agent/context" + "github.com/vigo999/ms-cli/integrations/llm" + "github.com/vigo999/ms-cli/integrations/skills" + issuepkg "github.com/vigo999/ms-cli/internal/issues" + "github.com/vigo999/ms-cli/internal/project" + "github.com/vigo999/ms-cli/ui/model" +) + +func TestExpandInputTextExpandsStandaloneTokensAndEscapes(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "a.txt", "alpha") + writeTestFile(t, root, "b.txt", "beta") + + app := &Application{WorkDir: root} + got, err := app.expandInputText("read @a.txt and @@literal then @b.txt") + if err != nil { + t.Fatalf("expandInputText returned error: %v", err) + } + + if !strings.Contains(got, `[file path="a.txt"]`) || !strings.Contains(got, "alpha") { + t.Fatalf("expected a.txt contents to be expanded, got %q", got) + } + if !strings.Contains(got, `[file path="b.txt"]`) || !strings.Contains(got, "beta") { + t.Fatalf("expected b.txt contents to be expanded, got %q", got) + } + if !strings.Contains(got, "@literal") { + t.Fatalf("expected @@ escape to keep literal @, got %q", got) + } +} + +func TestExpandInputTextLeavesUnsupportedAtFormsUnchanged(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "ctx.txt", "context") + + app := &Application{WorkDir: root} + input := "see @ctx.txt, (@ctx.txt) user@ctx.txt" + got, err := app.expandInputText(input) + if err != nil { + t.Fatalf("expandInputText returned error: %v", err) + } + if got != input { + t.Fatalf("unsupported @ forms should stay unchanged, got %q", got) + } +} + +func TestExpandInputTextRejectsUnsafeFiles(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "big.txt", strings.Repeat("a", 64*1024+1)) + writeBinaryFile(t, root, "bin.dat", []byte{'a', 0, 'b'}) + if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil { + t.Fatal(err) + } + + app := &Application{WorkDir: root} + tests := []struct { + input string + want string + }{ + {"@missing.txt", "file not found"}, + {"@dir", "path is a directory"}, + {"@../escape.txt", "path escapes working directory"}, + {"@big.txt", "file too large"}, + {"@bin.dat", "not valid text"}, + } + + for _, tc := range tests { + _, err := app.expandInputText(tc.input) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expandInputText(%q) error = %v, want substring %q", tc.input, err, tc.want) + } + } +} + +func TestProcessInputExpandsPlainChatBeforeRunTask(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "ctx.txt", "context payload") + + app := &Application{ + WorkDir: root, + EventCh: make(chan model.Event, 8), + llmReady: false, + ctxManager: ctxmanager.NewManager(ctxmanager.ManagerConfig{ + MaxTokens: 24000, + ReserveTokens: 4000, + }), + } + + app.processInput("please read @ctx.txt") + + ev := drainUntilEventType(t, app, model.AgentReply) + if ev.Message != provideAPIKeyFirstMsg { + t.Fatalf("expected unavailable reply, got %q", ev.Message) + } + + msgs := app.ctxManager.GetNonSystemMessages() + if len(msgs) < 1 || msgs[0].Role != "user" { + t.Fatalf("expected recorded user message, got %#v", msgs) + } + if !strings.Contains(msgs[0].Content, `[file path="ctx.txt"]`) || !strings.Contains(msgs[0].Content, "context payload") { + t.Fatalf("expected expanded plain chat to be recorded, got %q", msgs[0].Content) + } +} + +func TestHandleCommandProjectDoesNotExpandExcludedCommand(t *testing.T) { + store := newMockProjectStore() + app := &Application{ + WorkDir: t.TempDir(), + EventCh: make(chan model.Event, 8), + projectService: project.NewService(store), + issueUser: "alice", + issueRole: "admin", + } + + app.handleCommand(`/project add tasks "@missing.txt" --owner bob --progress 30`) + + ev := drainUntilEventType(t, app, model.AgentReply) + if !strings.Contains(ev.Message, "created task #1") { + t.Fatalf("expected project command to succeed unchanged, got %q", ev.Message) + } + if got := store.tasks[0].Title; got != "@missing.txt" { + t.Fatalf("excluded command should keep literal title, got %q", got) + } +} + +func TestHandleCommandReportExpandsOnlyTitleRemainder(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "ctx.txt", "reported context") + + store := &fakeAppIssueStore{} + app := &Application{ + WorkDir: root, + EventCh: make(chan model.Event, 8), + issueService: issuepkg.NewService(store), + issueUser: "alice", + } + + app.handleCommand(`/report accuracy @ctx.txt`) + + drainUntilEventType(t, app, model.AgentReply) + if got := store.lastCreateKind; got != issuepkg.KindAccuracy { + t.Fatalf("kind = %q, want %q", got, issuepkg.KindAccuracy) + } + if !strings.Contains(store.lastCreateTitle, `[file path="ctx.txt"]`) || !strings.Contains(store.lastCreateTitle, "reported context") { + t.Fatalf("expected expanded report title, got %q", store.lastCreateTitle) + } +} + +func TestHandleCommandReportBadReferenceFailsWholeInput(t *testing.T) { + store := &fakeAppIssueStore{} + app := &Application{ + WorkDir: t.TempDir(), + EventCh: make(chan model.Event, 8), + issueService: issuepkg.NewService(store), + issueUser: "alice", + } + + app.handleCommand(`/report accuracy @missing.txt`) + + ev := drainUntilEventType(t, app, model.ToolError) + if !strings.Contains(ev.Message, "Failed to expand @file input") { + t.Fatalf("expected input expansion error, got %q", ev.Message) + } + if store.lastCreateTitle != "" { + t.Fatalf("report command should not execute on bad @file, got title %q", store.lastCreateTitle) + } +} + +func TestHandleCommandFixPreservesIssueModeAndExpandsPromptRemainder(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "ctx.txt", "fix context") + + app := &Application{ + WorkDir: root, + EventCh: make(chan model.Event, 8), + issueService: issuepkg.NewService(&fakeAppIssueStore{}), + } + + app.handleCommand(`/fix ISSUE-42 @ctx.txt`) + + ev := drainUntilEventType(t, app, model.AgentReply) + if !strings.Contains(ev.Message, "fix flow for ISSUE-42 is not wired yet") { + t.Fatalf("expected issue-target mode to be preserved, got %q", ev.Message) + } + if !strings.Contains(ev.Message, `[file path="ctx.txt"]`) || !strings.Contains(ev.Message, "fix context") { + t.Fatalf("expected expanded prompt remainder, got %q", ev.Message) + } +} + +func TestHandleCommandFixFileFirstStaysFreeTextMode(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "ctx.txt", "fix context") + + app := &Application{ + WorkDir: root, + EventCh: make(chan model.Event, 8), + } + + app.handleCommand(`/fix @ctx.txt ISSUE-42`) + + ev := drainUntilEventType(t, app, model.AgentReply) + if strings.Contains(ev.Message, "fix flow for ISSUE-42 is not wired yet") { + t.Fatalf("file-first input should not switch to issue mode, got %q", ev.Message) + } + if !strings.Contains(ev.Message, `[file path=`) || !strings.Contains(ev.Message, "fix context") || !strings.Contains(ev.Message, "ISSUE-42") { + t.Fatalf("expected free-text mode with expanded content, got %q", ev.Message) + } +} + +func TestHandleCommandSkillAndAliasExpandOnlyRequestRemainder(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "req.txt", "skill request") + skillDir := filepath.Join(root, "skills") + createTestSkill(t, skillDir, "demo") + + app := &Application{ + WorkDir: root, + EventCh: make(chan model.Event, 16), + llmReady: false, + ctxManager: ctxmanager.NewManager(ctxmanager.ManagerConfig{MaxTokens: 24000, ReserveTokens: 4000}), + skillLoader: skills.NewLoader(skillDir), + } + + app.handleCommand(`/skill demo @req.txt`) + drainUntilEventType(t, app, model.ToolSkill) + drainUntilEventType(t, app, model.AgentReply) + + msgs := app.ctxManager.GetNonSystemMessages() + if !containsUserMessage(msgs, `[file path="req.txt"]`) { + t.Fatalf("expected /skill request to be expanded, got %#v", msgs) + } + + app.ctxManager.Clear() + app.handleCommand(`/demo @req.txt`) + drainUntilEventType(t, app, model.ToolSkill) + drainUntilEventType(t, app, model.AgentReply) + + msgs = app.ctxManager.GetNonSystemMessages() + if !containsUserMessage(msgs, `[file path="req.txt"]`) { + t.Fatalf("expected skill alias request to be expanded, got %#v", msgs) + } +} + +func containsUserMessage(msgs []llm.Message, needle string) bool { + for _, msg := range msgs { + if msg.Role == "user" && strings.Contains(msg.Content, needle) { + return true + } + } + return false +} + +func writeTestFile(t *testing.T, root, relativePath, content string) { + t.Helper() + path := filepath.Join(root, relativePath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func writeBinaryFile(t *testing.T, root, relativePath string, data []byte) { + t.Helper() + path := filepath.Join(root, relativePath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func createTestSkill(t *testing.T, skillsRoot, name string) { + t.Helper() + skillPath := filepath.Join(skillsRoot, name) + if err := os.MkdirAll(skillPath, 0o755); err != nil { + t.Fatal(err) + } + content := "---\nname: " + name + "\ndescription: demo skill\n---\n\nbody" + if err := os.WriteFile(filepath.Join(skillPath, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/run.go b/internal/app/run.go index 32028c8..9d80a75 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -114,7 +114,13 @@ func (a *Application) processInput(input string) { return } - go a.runTask(trimmed) + expanded, err := a.expandInputText(trimmed) + if err != nil { + a.emitInputExpansionError(err) + return + } + + go a.runTask(expanded) } func (a *Application) runTask(description string) { diff --git a/internal/workspacefile/workspacefile.go b/internal/workspacefile/workspacefile.go new file mode 100644 index 0000000..1d68088 --- /dev/null +++ b/internal/workspacefile/workspacefile.go @@ -0,0 +1,87 @@ +package workspacefile + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "unicode/utf8" +) + +// DefaultMaxInlineBytes is the default size cap for inline text expansion. +const DefaultMaxInlineBytes = 64 * 1024 + +// ResolvePath validates a workspace-relative path and returns its absolute path. +func ResolvePath(workDir, input string) (string, error) { + if strings.TrimSpace(input) == "" { + return "", fmt.Errorf("path is required") + } + + cleaned := filepath.Clean(input) + if filepath.IsAbs(cleaned) { + return "", fmt.Errorf("absolute paths are not allowed: %s", input) + } + + baseAbs, err := filepath.Abs(workDir) + if err != nil { + return "", fmt.Errorf("resolve working directory: %w", err) + } + + fullAbs, err := filepath.Abs(filepath.Join(baseAbs, cleaned)) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + + rel, err := filepath.Rel(baseAbs, fullAbs) + if err != nil { + return "", fmt.Errorf("check path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes working directory: %s", input) + } + + return fullAbs, nil +} + +// ReadTextFile reads a validated workspace-relative file and applies text safety checks. +func ReadTextFile(workDir, input string, maxBytes int) (string, error) { + if maxBytes <= 0 { + maxBytes = DefaultMaxInlineBytes + } + + fullPath, err := ResolvePath(workDir, input) + if err != nil { + return "", err + } + + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file not found: %s", input) + } + return "", fmt.Errorf("stat file: %w", err) + } + if info.IsDir() { + return "", fmt.Errorf("path is a directory: %s", input) + } + if info.Size() > int64(maxBytes) { + return "", fmt.Errorf("file too large: %s exceeds %d bytes", input, maxBytes) + } + + data, err := os.ReadFile(fullPath) + if err != nil { + return "", fmt.Errorf("read file: %w", err) + } + if len(data) > maxBytes { + return "", fmt.Errorf("file too large: %s exceeds %d bytes", input, maxBytes) + } + if bytes.IndexByte(data, 0) >= 0 { + return "", fmt.Errorf("file is not valid text (contains NUL bytes): %s", input) + } + if !utf8.Valid(data) { + return "", fmt.Errorf("file is not valid UTF-8 text: %s", input) + } + + return string(data), nil +} From fd8def9a8ed90cceafdd1942fe8bc6e7c76815c9 Mon Sep 17 00:00:00 2001 From: dongxw Date: Wed, 25 Mar 2026 22:59:04 +0800 Subject: [PATCH 2/5] docs: add @file implementation summary --- docs/at-file-input-expansion-summary.md | 105 ++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/at-file-input-expansion-summary.md diff --git a/docs/at-file-input-expansion-summary.md b/docs/at-file-input-expansion-summary.md new file mode 100644 index 0000000..24549b5 --- /dev/null +++ b/docs/at-file-input-expansion-summary.md @@ -0,0 +1,105 @@ +# `@file` Input Expansion Summary + +## Overview + +This change adds conservative `@file` prompt expansion support on branch +`refactor-arch-4.11-support-at-file`. + +The goal is to let users inline workspace text files into prompts without +changing slash command recognition or disturbing structured command parsing. + +## Supported Surfaces + +`@relative/path` expansion is enabled for: + +- Plain chat input +- `/report` +- `/diagnose` +- `/fix` +- `/skill ...` +- Direct skill aliases such as `/pdf ...` + +It is intentionally not enabled for structured commands such as `/project`, +`/train`, `/model`, `/permission`, `/login`, `/issues`, `/status`, `/bugs`, +`/claim`, `/close`, or `/dock`. + +## Syntax and Safety Rules + +Version 1 behavior is intentionally strict: + +- Only standalone whitespace-delimited `@relative/path` tokens expand +- `@@name` keeps a literal `@name` +- Paths must remain inside the current workspace +- Absolute paths and escaping paths are rejected +- Directories and missing files are rejected +- Files must be UTF-8 text and must not contain NUL bytes +- Files larger than `64 KiB` are rejected +- Any invalid `@file` reference fails the whole input + +Expanded files are injected into prompts in this form: + +```text +[file path="relative/path.txt"] +... +[/file] +``` + +## Implementation Notes + +The change is split across three main areas: + +1. Shared file validation and text reading + +- Added `internal/workspacefile/workspacefile.go` +- Centralizes workspace-relative path validation +- Reused by input expansion and `tools/fs` path resolution + +2. Input expansion and raw command parsing + +- Added `internal/app/input_expansion.go` +- Keeps slash detection unchanged +- Expands plain chat only after confirming input is not a slash command +- Parses slash commands from raw input first, then expands only approved + command remainders + +3. Command-specific behavior preservation + +- `/diagnose` and `/fix` still use the first raw token to decide whether the + command targets `ISSUE-*` +- In issue mode, only the remainder after the issue key is expanded +- `/skill` and direct skill aliases now preserve the raw request tail so the + skill name itself is never changed by `@file` + +## Documentation Updates + +User-facing notes were added to: + +- `README.md` +- `/help` output in `internal/app/commands.go` + +## Validation + +Targeted tests were added in `internal/app/input_expansion_test.go`. + +Validated scenarios include: + +- Plain chat expansion +- Multiple `@file` tokens +- `@@` escaping +- Excluded commands remaining unchanged +- `/report`, `/diagnose`, `/fix`, `/skill`, and skill alias behavior +- Issue-target preservation for `/diagnose` and `/fix` +- Failure on missing, unsafe, oversized, or invalid files + +The targeted verification command that passed was: + +```powershell +go test ./internal/app ./tools/fs -run "Test(ExpandInputText|ProcessInput|HandleCommand|CmdIssue|ParseIssueCommandTarget|InterruptTokenCancelsActiveTask)" +``` + +## Notes + +- Some broader Windows-specific tests in the repository already fail for + unrelated path/session reasons and were not changed as part of this work. +- This implementation is intentionally conservative and leaves punctuation- + adjacent `@file` forms and paths with spaces for future expansion if needed. From 325fc9407c8924b9dde54b998058ba959e2e37e5 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:54:05 +0800 Subject: [PATCH 3/5] test: fix absolute path assertion on windows --- tools/fs/pathutil_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/fs/pathutil_test.go b/tools/fs/pathutil_test.go index 2ef87ba..437aef9 100644 --- a/tools/fs/pathutil_test.go +++ b/tools/fs/pathutil_test.go @@ -35,8 +35,9 @@ func TestResolveSafePathRejectsEscapeFromWorkDir(t *testing.T) { func TestResolveSafePathRejectsAbsolutePathOutsideAllowedRoots(t *testing.T) { workDir := t.TempDir() + outsidePath := filepath.Join(filepath.Dir(workDir), "outside.txt") - _, err := resolveSafePath(workDir, filepath.Join(string(os.PathSeparator), "tmp", "outside.txt")) + _, err := resolveSafePath(workDir, outsidePath) if err == nil { t.Fatal("resolveSafePath returned nil error, want absolute path rejection") } From c4c3f8061c4987bd67440528f7cf4eb6b1d71154 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:49:44 +0800 Subject: [PATCH 4/5] feat: at-filename function --- internal/app/commands.go | 1 + internal/app/input_expansion_test.go | 22 ++ internal/app/run.go | 2 +- ui/app.go | 99 ++++++--- ui/app_issue.go | 18 +- ui/app_train_test.go | 88 ++++++++ ui/components/file_suggestions.go | 83 +++++++ ui/components/textinput.go | 310 +++++++++++++++++++-------- ui/components/textinput_test.go | 135 +++++++++++- 9 files changed, 623 insertions(+), 135 deletions(-) create mode 100644 ui/components/file_suggestions.go diff --git a/internal/app/commands.go b/internal/app/commands.go index e1ae09e..bd74300 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -509,6 +509,7 @@ Keybindings: @file Input Expansion: Plain chat and /report, /diagnose, /fix, /skill, / alias support standalone @relative/path + Typing @path in the composer shows file completion candidates before submit Use @@name to keep a literal @name token Files must stay inside the workspace, be UTF-8 text, and be <= 64 KiB Invalid @file references fail the whole input diff --git a/internal/app/input_expansion_test.go b/internal/app/input_expansion_test.go index e540c2c..7857d19 100644 --- a/internal/app/input_expansion_test.go +++ b/internal/app/input_expansion_test.go @@ -109,6 +109,28 @@ func TestProcessInputExpandsPlainChatBeforeRunTask(t *testing.T) { } } +func TestProcessInputEmitsExpandedUserInputEvent(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "ctx.txt", "context payload") + + app := &Application{ + WorkDir: root, + EventCh: make(chan model.Event, 8), + llmReady: false, + ctxManager: ctxmanager.NewManager(ctxmanager.ManagerConfig{ + MaxTokens: 24000, + ReserveTokens: 4000, + }), + } + + app.processInput("please read @ctx.txt") + + ev := drainUntilEventType(t, app, model.UserInput) + if !strings.Contains(ev.Message, `[file path="ctx.txt"]`) || !strings.Contains(ev.Message, "context payload") { + t.Fatalf("expected expanded user input event, got %q", ev.Message) + } +} + func TestHandleCommandProjectDoesNotExpandExcludedCommand(t *testing.T) { store := newMockProjectStore() app := &Application{ diff --git a/internal/app/run.go b/internal/app/run.go index 9d80a75..e508024 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -119,6 +119,7 @@ func (a *Application) processInput(input string) { a.emitInputExpansionError(err) return } + a.EventCh <- model.Event{Type: model.UserInput, Message: expanded} go a.runTask(expanded) } @@ -419,4 +420,3 @@ func convertLoopEvent(ev loop.Event) *model.Event { func generateTaskID() string { return time.Now().Format("20060102-150405-000") } - diff --git a/ui/app.go b/ui/app.go index 9e8a979..19cf40a 100644 --- a/ui/app.go +++ b/ui/app.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "regexp" "strings" "time" @@ -35,6 +36,7 @@ var ( trainSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("114")) trainWorkingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) queueBannerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).PaddingLeft(2) + atFileCandidateRE = regexp.MustCompile(`^[A-Za-z0-9._/\\-]+$`) ) // agentMsg formats an agent message with a status marker and fixed-width source prefix. @@ -112,6 +114,9 @@ type App struct { userCh chan<- string // sends user input to the engine bridge lastInterrupt time.Time // track last ctrl+c for double-press exit mouseEnabled bool + followBottom bool + unreadCount int + lastMsgCount int // Train mode trainView model.TrainViewState @@ -127,12 +132,13 @@ type App struct { // userCh may be nil — user input won't be forwarded. func New(ch <-chan model.Event, userCh chan<- string, version, workDir, repoURL, modelName string, ctxMax int) App { return App{ - state: model.NewState(version, workDir, repoURL, modelName, ctxMax), - input: components.NewTextInput(), - thinking: components.NewThinkingSpinner(), - eventCh: ch, - userCh: userCh, - bootActive: true, + state: model.NewState(version, workDir, repoURL, modelName, ctxMax), + input: components.NewTextInput().WithFileSuggestions(workDir), + thinking: components.NewThinkingSpinner(), + eventCh: ch, + userCh: userCh, + bootActive: true, + followBottom: true, } } @@ -196,6 +202,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: var cmd tea.Cmd a.viewport, cmd = a.viewport.Update(msg) + a.syncViewportScrollState() return a, cmd case tea.WindowSizeMsg: @@ -259,6 +266,7 @@ func (a *App) resizeInput() { func (a *App) resizeActiveLayout() { a.resizeInput() a.viewport = a.viewport.SetSize(a.chatWidth()-4, a.chatHeight()) + a.syncViewportScrollState() } func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -293,21 +301,17 @@ func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a.handleBugKey(msg) } - // Check if we're in slash suggestion mode - if a.input.IsSlashMode() { + if a.input.HasSuggestions() { switch msg.String() { - case "tab", "esc": + case "tab", "esc", "enter": var cmd tea.Cmd a.input, cmd = a.input.Update(msg) a.resizeActiveLayout() return a, cmd case "up", "down": - // Only capture for suggestions if there are visible candidates - if a.input.HasSuggestions() { - var cmd tea.Cmd - a.input, cmd = a.input.Update(msg) - return a, cmd - } + var cmd tea.Cmd + a.input, cmd = a.input.Update(msg) + return a, cmd } } @@ -382,14 +386,6 @@ func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, cmd case "enter": - // Don't process enter if in slash mode (handled above) - if a.input.IsSlashMode() { - var cmd tea.Cmd - a.input, cmd = a.input.Update(msg) - a.resizeActiveLayout() - return a, cmd - } - val := strings.TrimSpace(a.input.Value()) if val == "" { if a.trainView.Active && len(a.trainView.GlobalActions.Items) > 0 { @@ -407,7 +403,7 @@ func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Reset stats for new task a.state = a.state.ResetStats() a.state = a.state.WithThinking(false) - if !strings.HasPrefix(val, "/") { + if !strings.HasPrefix(val, "/") && !shouldDeferUserEcho(val) { a.state = a.state.WithMessage(model.Message{Kind: model.MsgUser, Content: val}) } a.input = a.input.PushHistory(val) @@ -482,7 +478,9 @@ func (a App) maybeDispatchQueuedInput() App { a.queuedInputs = append([]string{}, a.queuedInputs[1:]...) a.state = a.state.ResetStats() a.state = a.state.WithThinking(false) - a.state = a.state.WithMessage(model.Message{Kind: model.MsgUser, Content: next}) + if !strings.HasPrefix(next, "/") && !shouldDeferUserEcho(next) { + a.state = a.state.WithMessage(model.Message{Kind: model.MsgUser, Content: next}) + } select { case a.userCh <- next: default: @@ -498,7 +496,13 @@ func (a App) handleEvent(ev model.Event) (tea.Model, tea.Cmd) { switch ev.Type { case model.UserInput: - a.state = a.state.WithMessage(model.Message{Kind: model.MsgUser, Content: ev.Message}) + if last := len(a.state.Messages) - 1; last >= 0 && a.state.Messages[last].Kind == model.MsgUser { + msgs := append([]model.Message{}, a.state.Messages...) + msgs[last].Content = ev.Message + a.state.Messages = msgs + } else { + a.state = a.state.WithMessage(model.Message{Kind: model.MsgUser, Content: ev.Message}) + } case model.IssueIndexOpen: a.openIssueIndex(ev.IssueView) @@ -2026,7 +2030,7 @@ func (a *App) agentStatus() string { func (a *App) updateViewport() { // Check if user is at (or near) bottom before updating content. - atBottom := a.viewport.AtBottom() || a.viewport.TotalLines() <= a.viewport.Model.Height + atBottom := a.viewport.AtBottom() || a.viewport.TotalLines() <= a.viewport.VisibleHeight() width := a.viewport.Model.Width if width <= 0 { width = a.chatWidth() - 4 @@ -2102,6 +2106,26 @@ func (a App) View() string { return trimViewHeight(layout, a.height) } +func (a *App) syncViewportScrollState() { + if a.viewport.AtBottom() { + a.followBottom = true + a.unreadCount = 0 + return + } + a.followBottom = false +} + +func (a *App) syncUnreadState(prevCount int, wasAtBottom bool) { + currentCount := len(a.state.Messages) + if currentCount > prevCount && !wasAtBottom { + a.unreadCount += currentCount - prevCount + } + if a.viewport.AtBottom() { + a.unreadCount = 0 + a.followBottom = true + } +} + func trimViewHeight(content string, height int) string { if height <= 0 { return content @@ -2116,6 +2140,27 @@ func trimViewHeight(content string, height int) string { return strings.Join(lines, "\n") } +func shouldDeferUserEcho(input string) bool { + for _, token := range strings.Fields(input) { + if isAtFileCandidateToken(token) { + return true + } + } + return false +} + +func isAtFileCandidateToken(token string) bool { + switch { + case token == "": + return false + case strings.HasPrefix(token, "@@"): + return false + case !strings.HasPrefix(token, "@") || len(token) == 1: + return false + } + return atFileCandidateRE.MatchString(token[1:]) +} + // overlayPopup centers a popup box on top of existing rendered content. func overlayPopup(bg, popup string, width, height int) string { bgLines := strings.Split(bg, "\n") diff --git a/ui/app_issue.go b/ui/app_issue.go index 0742c16..170f8e4 100644 --- a/ui/app_issue.go +++ b/ui/app_issue.go @@ -98,19 +98,17 @@ func (a App) handleIssueIndexKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (a App) handleIssueDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if a.input.IsSlashMode() { + if a.input.HasSuggestions() { switch msg.String() { - case "tab", "esc": + case "tab", "esc", "enter": var cmd tea.Cmd a.input, cmd = a.input.Update(msg) a.resizeActiveLayout() return a, cmd case "up", "down": - if a.input.HasSuggestions() { - var cmd tea.Cmd - a.input, cmd = a.input.Update(msg) - return a, cmd - } + var cmd tea.Cmd + a.input, cmd = a.input.Update(msg) + return a, cmd } } @@ -145,12 +143,6 @@ func (a App) handleIssueDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.resizeActiveLayout() return a, cmd case "enter": - if a.input.IsSlashMode() { - var cmd tea.Cmd - a.input, cmd = a.input.Update(msg) - a.resizeActiveLayout() - return a, cmd - } val := strings.TrimSpace(a.input.Value()) if val == "" { return a, nil diff --git a/ui/app_train_test.go b/ui/app_train_test.go index 7743603..542fee3 100644 --- a/ui/app_train_test.go +++ b/ui/app_train_test.go @@ -1,6 +1,8 @@ package ui import ( + "os" + "path/filepath" "strings" "testing" @@ -422,6 +424,92 @@ func TestLargePastedUserMessageRendersAsSummary(t *testing.T) { } } +func TestAtFileInputWaitsForBackendEchoBeforeShowingUserMessage(t *testing.T) { + userCh := make(chan string, 1) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "ctx.txt"), []byte("context payload"), 0o644); err != nil { + t.Fatal(err) + } + app := New(nil, userCh, "test", root, "", "demo-model", 4096) + app.bootActive = false + + next, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) + app = next.(App) + + app.input.Model.SetValue("read @ctx.txt") + next, _ = app.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + app = next.(App) + + if got := len(app.state.Messages); got != 0 { + t.Fatalf("expected ui to wait for backend-confirmed echo, got %#v", app.state.Messages) + } + + select { + case msg := <-userCh: + if msg != "read @ctx.txt" { + t.Fatalf("expected raw input to reach backend, got %q", msg) + } + default: + t.Fatal("expected enter to submit at-file input to backend") + } + + next, _ = app.handleEvent(model.Event{ + Type: model.UserInput, + Message: "read [file path=\"ctx.txt\"]\ncontext payload\n[/file]", + }) + app = next.(App) + + if got := len(app.state.Messages); got != 1 { + t.Fatalf("expected one backend-confirmed user message, got %d", got) + } + if !strings.Contains(app.state.Messages[0].Content, `[file path="ctx.txt"]`) { + t.Fatalf("expected expanded user message content, got %#v", app.state.Messages[0]) + } +} + +func TestEnterAcceptsAtFileSuggestionBeforeSubmittingToBackend(t *testing.T) { + userCh := make(chan string, 1) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "ctx.txt"), []byte("context payload"), 0o644); err != nil { + t.Fatal(err) + } + app := New(nil, userCh, "test", root, "", "demo-model", 4096) + app.bootActive = false + + next, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) + app = next.(App) + + next, _ = app.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("read @ct")}) + app = next.(App) + if !app.input.HasSuggestions() { + t.Fatal("expected @file suggestions before submit") + } + + next, _ = app.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + app = next.(App) + + select { + case msg := <-userCh: + t.Fatalf("expected first enter to accept suggestion, got backend submit %q", msg) + default: + } + if got := app.input.Value(); got != "read @ctx.txt " { + t.Fatalf("expected first enter to accept suggestion, got %q", got) + } + + next, _ = app.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + app = next.(App) + + select { + case msg := <-userCh: + if msg != "read @ctx.txt" { + t.Fatalf("expected accepted suggestion to submit on second enter, got %q", msg) + } + default: + t.Fatal("expected second enter to submit accepted suggestion") + } +} + func TestUpDoesNotRecallHistoryWhileInsideMultilineComposer(t *testing.T) { app := New(nil, nil, "test", ".", "", "demo-model", 4096) app.bootActive = false diff --git a/ui/components/file_suggestions.go b/ui/components/file_suggestions.go new file mode 100644 index 0000000..362945e --- /dev/null +++ b/ui/components/file_suggestions.go @@ -0,0 +1,83 @@ +package components + +import ( + "io/fs" + "path/filepath" + "sort" + "strings" +) + +type fileSuggestionProvider struct { + workDir string + cached []string + loaded bool +} + +func newFileSuggestionProvider(workDir string) *fileSuggestionProvider { + workDir = strings.TrimSpace(workDir) + if workDir == "" { + return nil + } + return &fileSuggestionProvider{workDir: workDir} +} + +func (p *fileSuggestionProvider) suggestions(prefix string) []suggestionItem { + if p == nil { + return nil + } + if err := p.load(); err != nil { + return nil + } + + prefix = normalizeSuggestionPath(prefix) + items := make([]suggestionItem, 0, len(p.cached)) + for _, path := range p.cached { + if prefix != "" && !strings.HasPrefix(path, prefix) { + continue + } + items = append(items, suggestionItem{ + Value: path, + Display: path, + Description: "file", + Kind: suggestionKindFile, + }) + } + return items +} + +func (p *fileSuggestionProvider) load() error { + if p.loaded { + return nil + } + + paths := make([]string, 0, 256) + err := filepath.WalkDir(p.workDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if d.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + rel, err := filepath.Rel(p.workDir, path) + if err != nil { + return nil + } + paths = append(paths, normalizeSuggestionPath(rel)) + return nil + }) + if err != nil { + return err + } + + sort.Strings(paths) + p.cached = paths + p.loaded = true + return nil +} + +func normalizeSuggestionPath(path string) string { + return strings.ReplaceAll(filepath.ToSlash(filepath.Clean(path)), "//", "/") +} diff --git a/ui/components/textinput.go b/ui/components/textinput.go index 8f52790..bf3b752 100644 --- a/ui/components/textinput.go +++ b/ui/components/textinput.go @@ -2,6 +2,7 @@ package components import ( "strings" + "unicode" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" @@ -27,15 +28,37 @@ const ( composerContinue = " " ) +type suggestionKind int + +const ( + suggestionKindNone suggestionKind = iota + suggestionKindSlash + suggestionKindFile +) + +type suggestionItem struct { + Value string + Display string + Description string + Kind suggestionKind +} + +type tokenRange struct { + start int + end int +} + // TextInput wraps a multiline textarea for the chat composer. type TextInput struct { Model textarea.Model slashRegistry *slash.Registry + fileSuggestion *fileSuggestionProvider showSuggestions bool - slashMode bool // true once suggestions have been shown, until submit/esc - suggestions []string + suggestionKind suggestionKind + suggestionItems []suggestionItem selectedIdx int suggestionOffset int + activeToken tokenRange history []string historyIndex int historyDraft string @@ -78,6 +101,12 @@ func NewTextInput() TextInput { return input } +// WithFileSuggestions enables @file suggestions from the given workspace root. +func (t TextInput) WithFileSuggestions(workDir string) TextInput { + t.fileSuggestion = newFileSuggestionProvider(workDir) + return t +} + // Value returns the current input text. func (t TextInput) Value() string { value := t.Model.Value() @@ -91,11 +120,7 @@ func (t TextInput) Value() string { func (t TextInput) Reset() TextInput { t.Model.Reset() t.syncHeight() - t.showSuggestions = false - // Keep slashMode — it gets cleared when the command result arrives. - t.suggestions = nil - t.selectedIdx = 0 - t.suggestionOffset = 0 + t = t.clearSuggestions() t.historyIndex = -1 t.historyDraft = "" t.maskedPasteRaw = "" @@ -144,47 +169,31 @@ func (t TextInput) Update(msg tea.Msg) (TextInput, tea.Cmd) { t.Model.SetHeight(t.editorHeight() + 1) } - // Handle slash command suggestions navigation - if t.showSuggestions && len(t.suggestions) > 0 { + if t.showSuggestions && len(t.suggestionItems) > 0 { switch msg.String() { case "up": if t.selectedIdx > 0 { t.selectedIdx-- } else { - // Wrap to last - t.selectedIdx = len(t.suggestions) - 1 + t.selectedIdx = len(t.suggestionItems) - 1 } t.syncSuggestionWindow() return t, nil case "down": - if t.selectedIdx < len(t.suggestions)-1 { + if t.selectedIdx < len(t.suggestionItems)-1 { t.selectedIdx++ } else { - // Wrap to first t.selectedIdx = 0 } t.syncSuggestionWindow() return t, nil case "tab", "enter": - // Accept selected suggestion - if t.selectedIdx < len(t.suggestions) { - val := t.suggestions[t.selectedIdx] + " " - t.Model.SetValue(val) - t.Model.SetCursor(len(val)) - t.syncHeight() - t.maskedPasteRaw = "" - t.maskedPasteLabel = "" - t.showSuggestions = false - t.suggestions = nil - t.suggestionOffset = 0 + if t.selectedIdx < len(t.suggestionItems) { + t = t.applySuggestion(t.suggestionItems[t.selectedIdx]) } return t, nil case "esc": - // Cancel suggestions - t.showSuggestions = false - t.slashMode = false - t.suggestions = nil - t.suggestionOffset = 0 + t = t.clearSuggestions() return t, nil } } @@ -236,10 +245,7 @@ func (t TextInput) PrevHistory() TextInput { t.syncHeight() t.maskedPasteRaw = "" t.maskedPasteLabel = "" - t.showSuggestions = false - t.slashMode = false - t.suggestions = nil - t.suggestionOffset = 0 + t = t.clearSuggestions() return t } @@ -254,10 +260,7 @@ func (t TextInput) NextHistory() TextInput { t.syncHeight() t.maskedPasteRaw = "" t.maskedPasteLabel = "" - t.showSuggestions = false - t.slashMode = false - t.suggestions = nil - t.suggestionOffset = 0 + t = t.clearSuggestions() return t } t.historyIndex = -1 @@ -266,44 +269,28 @@ func (t TextInput) NextHistory() TextInput { t.maskedPasteRaw = "" t.maskedPasteLabel = "" t.historyDraft = "" - t.showSuggestions = false - t.slashMode = false - t.suggestions = nil - t.suggestionOffset = 0 + t = t.clearSuggestions() return t } -// updateSuggestions updates the slash command suggestions based on current input. +// updateSuggestions chooses between slash and @file suggestions based on the current token. func (t *TextInput) updateSuggestions() { - val := t.Model.Value() - val = strings.TrimSpace(val) - - // Only show suggestions if input starts with "/" - if !strings.HasPrefix(val, "/") { - t.showSuggestions = false - t.slashMode = false - t.suggestions = nil - t.selectedIdx = 0 - t.suggestionOffset = 0 + token, span, ok := t.currentToken() + if !ok { + *t = t.clearSuggestions() return } - // Get suggestions - t.suggestions = t.slashRegistry.Suggestions(val) - t.showSuggestions = len(t.suggestions) > 0 - if t.showSuggestions { - t.slashMode = true - } - - // Reset selection if it's out of bounds - if t.selectedIdx >= len(t.suggestions) { - t.selectedIdx = 0 + if items, ok := t.slashSuggestionItems(token, span); ok { + t.setSuggestions(suggestionKindSlash, span, items) + return } - if len(t.suggestions) == 0 { - t.suggestionOffset = 0 + if items, ok := t.fileSuggestionItems(token, span); ok { + t.setSuggestions(suggestionKindFile, span, items) return } - t.syncSuggestionWindow() + + *t = t.clearSuggestions() } func (t TextInput) separator() string { @@ -319,8 +306,8 @@ func (t TextInput) View() string { sep := t.separator() inputView := composerStyle.Render(t.Model.View()) - if !t.showSuggestions || len(t.suggestions) == 0 { - if t.slashMode { + if !t.showSuggestions || len(t.suggestionItems) == 0 { + if t.suggestionKind != suggestionKindNone { return sep + "\n" + inputView + strings.Repeat("\n", maxVisibleSuggestions) + "\n" + sep } return sep + "\n" + inputView + "\n" + sep @@ -338,29 +325,23 @@ func (t TextInput) View() string { start = 0 } end := start + maxVisibleSuggestions - if end > len(t.suggestions) { - end = len(t.suggestions) + if end > len(t.suggestionItems) { + end = len(t.suggestionItems) } for i := start; i < end; i++ { - sug := t.suggestions[i] - - // Get command description - cmd, ok := t.slashRegistry.Get(sug) - if !ok { - continue - } + item := t.suggestionItems[i] if i == t.selectedIdx { sb.WriteString(" ") - sb.WriteString(sugSelCmdStyle.Render(sug)) + sb.WriteString(sugSelCmdStyle.Render(item.Display)) sb.WriteString(" ") - sb.WriteString(sugSelDescStyle.Render(cmd.Description)) + sb.WriteString(sugSelDescStyle.Render(item.Description)) } else { sb.WriteString(" ") - sb.WriteString(sugCmdStyle.Render(sug)) + sb.WriteString(sugCmdStyle.Render(item.Display)) sb.WriteString(" ") - sb.WriteString(sugDescStyle.Render(cmd.Description)) + sb.WriteString(sugDescStyle.Render(item.Description)) } sb.WriteString("\n") @@ -378,7 +359,7 @@ func (t TextInput) View() string { // Height returns the total height including suggestions area. func (t TextInput) Height() int { height := t.editorHeight() + 2 - if t.slashMode { + if t.suggestionKind != suggestionKindNone { return height + maxVisibleSuggestions } return height @@ -386,21 +367,20 @@ func (t TextInput) Height() int { // IsSlashMode returns true if showing slash suggestions. func (t TextInput) IsSlashMode() bool { - return t.showSuggestions + return t.showSuggestions && t.suggestionKind == suggestionKindSlash } // ClearSlashMode exits the slash suggestion reserved area. func (t TextInput) ClearSlashMode() TextInput { - t.slashMode = false - t.showSuggestions = false - t.suggestions = nil - t.suggestionOffset = 0 + if t.suggestionKind == suggestionKindSlash { + return t.clearSuggestions() + } return t } // HasSuggestions returns true if there are visible suggestion candidates. func (t TextInput) HasSuggestions() bool { - return t.showSuggestions && len(t.suggestions) > 0 + return t.showSuggestions && len(t.suggestionItems) > 0 } // HasPasteSummary returns true when the composer is showing a collapsed paste preview. @@ -432,7 +412,7 @@ func (t TextInput) CanNavigateHistory(direction string) bool { } func (t *TextInput) syncSuggestionWindow() { - if len(t.suggestions) == 0 { + if len(t.suggestionItems) == 0 { t.suggestionOffset = 0 return } @@ -440,8 +420,8 @@ func (t *TextInput) syncSuggestionWindow() { if t.selectedIdx < 0 { t.selectedIdx = 0 } - if t.selectedIdx >= len(t.suggestions) { - t.selectedIdx = len(t.suggestions) - 1 + if t.selectedIdx >= len(t.suggestionItems) { + t.selectedIdx = len(t.suggestionItems) - 1 } if t.selectedIdx < t.suggestionOffset { @@ -451,7 +431,7 @@ func (t *TextInput) syncSuggestionWindow() { t.suggestionOffset = t.selectedIdx - maxVisibleSuggestions + 1 } - maxOffset := len(t.suggestions) - maxVisibleSuggestions + maxOffset := len(t.suggestionItems) - maxVisibleSuggestions if maxOffset < 0 { maxOffset = 0 } @@ -463,6 +443,154 @@ func (t *TextInput) syncSuggestionWindow() { } } +func (t TextInput) clearSuggestions() TextInput { + t.showSuggestions = false + t.suggestionKind = suggestionKindNone + t.suggestionItems = nil + t.selectedIdx = 0 + t.suggestionOffset = 0 + t.activeToken = tokenRange{} + return t +} + +func (t *TextInput) setSuggestions(kind suggestionKind, span tokenRange, items []suggestionItem) { + if len(items) == 0 { + *t = t.clearSuggestions() + return + } + t.showSuggestions = true + t.suggestionKind = kind + t.suggestionItems = items + t.activeToken = span + if t.selectedIdx >= len(items) { + t.selectedIdx = 0 + } + t.syncSuggestionWindow() +} + +func (t TextInput) applySuggestion(item suggestionItem) TextInput { + replacement := item.Value + " " + switch item.Kind { + case suggestionKindFile: + replacement = "@" + item.Value + " " + } + + t.Model.SetValue(replaceRunesInRange(t.Model.Value(), t.activeToken, replacement)) + t.Model.SetCursor(t.activeToken.start + len([]rune(replacement))) + t.syncHeight() + t.maskedPasteRaw = "" + t.maskedPasteLabel = "" + t = t.clearSuggestions() + return t +} + +func (t TextInput) slashSuggestionItems(token string, span tokenRange) ([]suggestionItem, bool) { + if span.start != 0 || !strings.HasPrefix(token, "/") { + return nil, false + } + + names := t.slashRegistry.Suggestions(token) + items := make([]suggestionItem, 0, len(names)) + for _, name := range names { + cmd, ok := t.slashRegistry.Get(name) + if !ok { + continue + } + items = append(items, suggestionItem{ + Value: name, + Display: name, + Description: cmd.Description, + Kind: suggestionKindSlash, + }) + } + return items, true +} + +func (t TextInput) fileSuggestionItems(token string, span tokenRange) ([]suggestionItem, bool) { + if !isFileSuggestionToken(token) { + return nil, false + } + return t.fileSuggestion.suggestions(token[1:]), true +} + +func (t TextInput) currentToken() (string, tokenRange, bool) { + value := t.Model.Value() + runes := []rune(value) + cursor := t.absoluteCursorPosition() + if cursor < 0 { + cursor = 0 + } + if cursor > len(runes) { + cursor = len(runes) + } + + start := cursor + for start > 0 && !unicode.IsSpace(runes[start-1]) { + start-- + } + end := cursor + for end < len(runes) && !unicode.IsSpace(runes[end]) { + end++ + } + if start == end { + return "", tokenRange{}, false + } + return string(runes[start:end]), tokenRange{start: start, end: end}, true +} + +func (t TextInput) absoluteCursorPosition() int { + row, col, lines := t.cursorPosition() + offset := 0 + for i := 0; i < row && i < len(lines); i++ { + offset += len([]rune(lines[i])) + 1 + } + return offset + col +} + +func replaceRunesInRange(value string, span tokenRange, replacement string) string { + runes := []rune(value) + prefix := string(runes[:span.start]) + suffix := string(runes[span.end:]) + if strings.HasSuffix(replacement, " ") { + suffix = strings.TrimLeftFunc(suffix, unicode.IsSpace) + } + return prefix + replacement + suffix +} + +func isFileSuggestionToken(token string) bool { + switch { + case token == "": + return false + case strings.HasPrefix(token, "@@"): + return false + case !strings.HasPrefix(token, "@") || len(token) == 1: + return false + } + for _, r := range token[1:] { + if !isAllowedFileSuggestionRune(r) { + return false + } + } + return true +} + +func isAllowedFileSuggestionRune(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + } + switch r { + case '.', '_', '/', '\\', '-': + return true + default: + return false + } +} + func (t *TextInput) syncHeight() { t.Model.SetHeight(t.editorHeight()) } diff --git a/ui/components/textinput_test.go b/ui/components/textinput_test.go index 588c7e1..3e9b15e 100644 --- a/ui/components/textinput_test.go +++ b/ui/components/textinput_test.go @@ -2,6 +2,8 @@ package components import ( "fmt" + "os" + "path/filepath" "strings" "testing" @@ -302,13 +304,124 @@ func TestTextInputSuggestionsWrapUpToLastPage(t *testing.T) { } } +func TestTextInputShowsFileSuggestionsForAtToken(t *testing.T) { + root := t.TempDir() + writeSuggestionFile(t, root, "ctx.txt") + writeSuggestionFile(t, root, "sub/first.md") + writeSuggestionFile(t, root, "sub/final.md") + + input := NewTextInput().WithFileSuggestions(root) + input.Model.SetValue("read @sub/f") + input.Model.SetCursor(len("read @sub/f")) + input.updateSuggestions() + + if !input.HasSuggestions() { + t.Fatal("expected file suggestions for @ token") + } + if input.IsSlashMode() { + t.Fatal("expected @file suggestions not to report slash mode") + } + if len(input.suggestionItems) != 2 { + t.Fatalf("expected 2 matching file suggestions, got %d", len(input.suggestionItems)) + } + if got := input.suggestionItems[0].Value; got != "sub/final.md" { + t.Fatalf("expected lexicographic file suggestion, got %q", got) + } +} + +func TestTextInputEnterAcceptsFileSuggestionWithoutSubmitting(t *testing.T) { + root := t.TempDir() + writeSuggestionFile(t, root, "ctx.txt") + + input := NewTextInput().WithFileSuggestions(root) + input.Model.SetValue("read @ct") + input.Model.SetCursor(len("read @ct")) + input.updateSuggestions() + + input, _ = input.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if got := input.Value(); got != "read @ctx.txt " { + t.Fatalf("expected enter to accept file suggestion, got %q", got) + } + if input.HasSuggestions() { + t.Fatal("expected suggestions cleared after accepting file suggestion") + } +} + +func TestTextInputReplacesOnlyCurrentTokenWhenApplyingFileSuggestion(t *testing.T) { + root := t.TempDir() + writeSuggestionFile(t, root, "ctx.txt") + + input := NewTextInput().WithFileSuggestions(root) + input.Model.SetValue("before @ct after") + input.Model.SetCursor(len("before @ct")) + input.updateSuggestions() + + input, _ = input.Update(tea.KeyMsg{Type: tea.KeyTab}) + if got := input.Value(); got != "before @ctx.txt after" { + t.Fatalf("expected current token replacement only, got %q", got) + } +} + +func TestTextInputLeavesInvalidAtFormsWithoutSuggestions(t *testing.T) { + root := t.TempDir() + writeSuggestionFile(t, root, "ctx.txt") + + cases := []string{ + "read @@ctx", + "read @ctx.txt,", + "read (@ctx.txt)", + } + + for _, tc := range cases { + input := NewTextInput().WithFileSuggestions(root) + input.Model.SetValue(tc) + input.Model.SetCursor(len(tc)) + input.updateSuggestions() + if input.HasSuggestions() { + t.Fatalf("expected no suggestions for %q", tc) + } + } +} + +func TestTextInputReplacesCurrentTokenOnSecondLine(t *testing.T) { + root := t.TempDir() + writeSuggestionFile(t, root, "ctx.txt") + + input := NewTextInput().WithFileSuggestions(root) + input.Model.SetValue("line one\nread @ct") + input.Model.SetCursor(len("line one\nread @ct")) + input.updateSuggestions() + + input, _ = input.Update(tea.KeyMsg{Type: tea.KeyTab}) + if got := input.Value(); got != "line one\nread @ctx.txt " { + t.Fatalf("expected second-line token replacement, got %q", got) + } +} + +func TestTextInputPrefersFileSuggestionsInSlashCommandArguments(t *testing.T) { + root := t.TempDir() + writeSuggestionFile(t, root, "ctx.txt") + + input := NewTextInput().WithFileSuggestions(root) + input.Model.SetValue("/report accuracy @ct") + input.Model.SetCursor(len("/report accuracy @ct")) + input.updateSuggestions() + + if !input.HasSuggestions() { + t.Fatal("expected file suggestions in slash command arguments") + } + if input.IsSlashMode() { + t.Fatal("expected file suggestions to override slash suggestions outside the command token") + } +} + func newSlashSuggestionInput(count int) TextInput { input := NewTextInput() registry := slash.NewRegistry() input.slashRegistry = registry input.showSuggestions = true - input.slashMode = true - input.suggestions = make([]string, 0, count) + input.suggestionKind = suggestionKindSlash + input.suggestionItems = make([]suggestionItem, 0, count) for i := 0; i < count; i++ { name := fmt.Sprintf("/cmd%02d", i) @@ -317,8 +430,24 @@ func newSlashSuggestionInput(count int) TextInput { Description: fmt.Sprintf("Command %02d", i), Usage: name, }) - input.suggestions = append(input.suggestions, name) + input.suggestionItems = append(input.suggestionItems, suggestionItem{ + Value: name, + Display: name, + Description: fmt.Sprintf("Command %02d", i), + Kind: suggestionKindSlash, + }) } return input } + +func writeSuggestionFile(t *testing.T, root, relative string) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(relative), 0o644); err != nil { + t.Fatal(err) + } +} From 72404789f13bf6c7bf257483990a33af70236646 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:14:28 +0800 Subject: [PATCH 5/5] fix: at_function only add filepath to the context --- docs/at-file-input-expansion-summary.md | 18 ++++----- internal/app/commands.go | 3 +- internal/app/input_expansion.go | 18 ++------- internal/app/input_expansion_test.go | 51 +++++++++++++------------ internal/workspacefile/workspacefile.go | 27 ++++++++++--- tools/fs/pathutil.go | 16 +++++--- tools/fs/pathutil_test.go | 13 +++++++ ui/app_train_test.go | 4 +- 8 files changed, 87 insertions(+), 63 deletions(-) diff --git a/docs/at-file-input-expansion-summary.md b/docs/at-file-input-expansion-summary.md index 24549b5..e5312f3 100644 --- a/docs/at-file-input-expansion-summary.md +++ b/docs/at-file-input-expansion-summary.md @@ -5,7 +5,7 @@ This change adds conservative `@file` prompt expansion support on branch `refactor-arch-4.11-support-at-file`. -The goal is to let users inline workspace text files into prompts without +The goal is to let users reference workspace files in prompts without changing slash command recognition or disturbing structured command parsing. ## Supported Surfaces @@ -32,27 +32,23 @@ Version 1 behavior is intentionally strict: - Paths must remain inside the current workspace - Absolute paths and escaping paths are rejected - Directories and missing files are rejected -- Files must be UTF-8 text and must not contain NUL bytes -- Files larger than `64 KiB` are rejected - Any invalid `@file` reference fails the whole input -Expanded files are injected into prompts in this form: +Expanded file references are injected into prompts in this form: ```text -[file path="relative/path.txt"] -... -[/file] +[file path="/absolute/workspace/path.txt"] ``` ## Implementation Notes The change is split across three main areas: -1. Shared file validation and text reading +1. Shared file validation and file-path resolution - Added `internal/workspacefile/workspacefile.go` - Centralizes workspace-relative path validation -- Reused by input expansion and `tools/fs` path resolution +- Reused by input expansion 2. Input expansion and raw command parsing @@ -83,13 +79,13 @@ Targeted tests were added in `internal/app/input_expansion_test.go`. Validated scenarios include: -- Plain chat expansion +- Plain chat path expansion - Multiple `@file` tokens - `@@` escaping - Excluded commands remaining unchanged - `/report`, `/diagnose`, `/fix`, `/skill`, and skill alias behavior - Issue-target preservation for `/diagnose` and `/fix` -- Failure on missing, unsafe, oversized, or invalid files +- Failure on missing, unsafe, or directory paths The targeted verification command that passed was: diff --git a/internal/app/commands.go b/internal/app/commands.go index bd74300..f0f8928 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -511,7 +511,8 @@ Keybindings: Plain chat and /report, /diagnose, /fix, /skill, / alias support standalone @relative/path Typing @path in the composer shows file completion candidates before submit Use @@name to keep a literal @name token - Files must stay inside the workspace, be UTF-8 text, and be <= 64 KiB + @path injects a workspace file reference as an absolute path marker; the agent can read it if needed + Referenced paths must stay inside the workspace and point to an existing file Invalid @file references fail the whole input Environment Variables: diff --git a/internal/app/input_expansion.go b/internal/app/input_expansion.go index cef4479..9d59059 100644 --- a/internal/app/input_expansion.go +++ b/internal/app/input_expansion.go @@ -78,26 +78,16 @@ func replaceAtFileToken(workDir, token string) (string, error) { return token, nil } - content, err := workspacefile.ReadTextFile(workDir, path, workspacefile.DefaultMaxInlineBytes) + fullPath, err := workspacefile.ResolveExistingFilePath(workDir, path) if err != nil { return "", err } - return formatExpandedFileBlock(path, content), nil + return formatExpandedFilePath(fullPath), nil } -func formatExpandedFileBlock(path, content string) string { - displayPath := filepath.ToSlash(filepath.Clean(path)) - var b strings.Builder - b.WriteString(`[file path="`) - b.WriteString(displayPath) - b.WriteString("\"]\n") - b.WriteString(content) - if !strings.HasSuffix(content, "\n") { - b.WriteString("\n") - } - b.WriteString(`[/file]`) - return b.String() +func formatExpandedFilePath(path string) string { + return `[file path="` + filepath.ToSlash(filepath.Clean(path)) + `"]` } type rawCommand struct { diff --git a/internal/app/input_expansion_test.go b/internal/app/input_expansion_test.go index 7857d19..373780e 100644 --- a/internal/app/input_expansion_test.go +++ b/internal/app/input_expansion_test.go @@ -25,12 +25,15 @@ func TestExpandInputTextExpandsStandaloneTokensAndEscapes(t *testing.T) { t.Fatalf("expandInputText returned error: %v", err) } - if !strings.Contains(got, `[file path="a.txt"]`) || !strings.Contains(got, "alpha") { + if !strings.Contains(got, `[file path="`+filepath.ToSlash(filepath.Join(root, "a.txt"))+`"]`) { t.Fatalf("expected a.txt contents to be expanded, got %q", got) } - if !strings.Contains(got, `[file path="b.txt"]`) || !strings.Contains(got, "beta") { + if !strings.Contains(got, `[file path="`+filepath.ToSlash(filepath.Join(root, "b.txt"))+`"]`) { t.Fatalf("expected b.txt contents to be expanded, got %q", got) } + if strings.Contains(got, "alpha") || strings.Contains(got, "beta") { + t.Fatalf("expected file contents not to be inlined, got %q", got) + } if !strings.Contains(got, "@literal") { t.Fatalf("expected @@ escape to keep literal @, got %q", got) } @@ -53,8 +56,6 @@ func TestExpandInputTextLeavesUnsupportedAtFormsUnchanged(t *testing.T) { func TestExpandInputTextRejectsUnsafeFiles(t *testing.T) { root := t.TempDir() - writeTestFile(t, root, "big.txt", strings.Repeat("a", 64*1024+1)) - writeBinaryFile(t, root, "bin.dat", []byte{'a', 0, 'b'}) if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil { t.Fatal(err) } @@ -67,8 +68,6 @@ func TestExpandInputTextRejectsUnsafeFiles(t *testing.T) { {"@missing.txt", "file not found"}, {"@dir", "path is a directory"}, {"@../escape.txt", "path escapes working directory"}, - {"@big.txt", "file too large"}, - {"@bin.dat", "not valid text"}, } for _, tc := range tests { @@ -104,9 +103,12 @@ func TestProcessInputExpandsPlainChatBeforeRunTask(t *testing.T) { if len(msgs) < 1 || msgs[0].Role != "user" { t.Fatalf("expected recorded user message, got %#v", msgs) } - if !strings.Contains(msgs[0].Content, `[file path="ctx.txt"]`) || !strings.Contains(msgs[0].Content, "context payload") { + if !strings.Contains(msgs[0].Content, `[file path="`+filepath.ToSlash(filepath.Join(root, "ctx.txt"))+`"]`) { t.Fatalf("expected expanded plain chat to be recorded, got %q", msgs[0].Content) } + if strings.Contains(msgs[0].Content, "context payload") { + t.Fatalf("expected file content not to be recorded inline, got %q", msgs[0].Content) + } } func TestProcessInputEmitsExpandedUserInputEvent(t *testing.T) { @@ -126,9 +128,12 @@ func TestProcessInputEmitsExpandedUserInputEvent(t *testing.T) { app.processInput("please read @ctx.txt") ev := drainUntilEventType(t, app, model.UserInput) - if !strings.Contains(ev.Message, `[file path="ctx.txt"]`) || !strings.Contains(ev.Message, "context payload") { + if !strings.Contains(ev.Message, `[file path="`+filepath.ToSlash(filepath.Join(root, "ctx.txt"))+`"]`) { t.Fatalf("expected expanded user input event, got %q", ev.Message) } + if strings.Contains(ev.Message, "context payload") { + t.Fatalf("expected user input event not to inline file content, got %q", ev.Message) + } } func TestHandleCommandProjectDoesNotExpandExcludedCommand(t *testing.T) { @@ -170,9 +175,12 @@ func TestHandleCommandReportExpandsOnlyTitleRemainder(t *testing.T) { if got := store.lastCreateKind; got != issuepkg.KindAccuracy { t.Fatalf("kind = %q, want %q", got, issuepkg.KindAccuracy) } - if !strings.Contains(store.lastCreateTitle, `[file path="ctx.txt"]`) || !strings.Contains(store.lastCreateTitle, "reported context") { + if !strings.Contains(store.lastCreateTitle, `[file path="`+filepath.ToSlash(filepath.Join(root, "ctx.txt"))+`"]`) { t.Fatalf("expected expanded report title, got %q", store.lastCreateTitle) } + if strings.Contains(store.lastCreateTitle, "reported context") { + t.Fatalf("expected report title not to inline file content, got %q", store.lastCreateTitle) + } } func TestHandleCommandReportBadReferenceFailsWholeInput(t *testing.T) { @@ -211,9 +219,12 @@ func TestHandleCommandFixPreservesIssueModeAndExpandsPromptRemainder(t *testing. if !strings.Contains(ev.Message, "fix flow for ISSUE-42 is not wired yet") { t.Fatalf("expected issue-target mode to be preserved, got %q", ev.Message) } - if !strings.Contains(ev.Message, `[file path="ctx.txt"]`) || !strings.Contains(ev.Message, "fix context") { + if !strings.Contains(ev.Message, `[file path="`+filepath.ToSlash(filepath.Join(root, "ctx.txt"))+`"]`) { t.Fatalf("expected expanded prompt remainder, got %q", ev.Message) } + if strings.Contains(ev.Message, "fix context") { + t.Fatalf("expected fix prompt not to inline file content, got %q", ev.Message) + } } func TestHandleCommandFixFileFirstStaysFreeTextMode(t *testing.T) { @@ -231,9 +242,12 @@ func TestHandleCommandFixFileFirstStaysFreeTextMode(t *testing.T) { if strings.Contains(ev.Message, "fix flow for ISSUE-42 is not wired yet") { t.Fatalf("file-first input should not switch to issue mode, got %q", ev.Message) } - if !strings.Contains(ev.Message, `[file path=`) || !strings.Contains(ev.Message, "fix context") || !strings.Contains(ev.Message, "ISSUE-42") { + if !strings.Contains(ev.Message, filepath.ToSlash(filepath.Join(root, "ctx.txt"))) || !strings.Contains(ev.Message, "ISSUE-42") { t.Fatalf("expected free-text mode with expanded content, got %q", ev.Message) } + if strings.Contains(ev.Message, "fix context") { + t.Fatalf("expected free-text mode not to inline file content, got %q", ev.Message) + } } func TestHandleCommandSkillAndAliasExpandOnlyRequestRemainder(t *testing.T) { @@ -255,7 +269,7 @@ func TestHandleCommandSkillAndAliasExpandOnlyRequestRemainder(t *testing.T) { drainUntilEventType(t, app, model.AgentReply) msgs := app.ctxManager.GetNonSystemMessages() - if !containsUserMessage(msgs, `[file path="req.txt"]`) { + if !containsUserMessage(msgs, `[file path="`+filepath.ToSlash(filepath.Join(root, "req.txt"))+`"]`) { t.Fatalf("expected /skill request to be expanded, got %#v", msgs) } @@ -265,7 +279,7 @@ func TestHandleCommandSkillAndAliasExpandOnlyRequestRemainder(t *testing.T) { drainUntilEventType(t, app, model.AgentReply) msgs = app.ctxManager.GetNonSystemMessages() - if !containsUserMessage(msgs, `[file path="req.txt"]`) { + if !containsUserMessage(msgs, `[file path="`+filepath.ToSlash(filepath.Join(root, "req.txt"))+`"]`) { t.Fatalf("expected skill alias request to be expanded, got %#v", msgs) } } @@ -290,17 +304,6 @@ func writeTestFile(t *testing.T, root, relativePath, content string) { } } -func writeBinaryFile(t *testing.T, root, relativePath string, data []byte) { - t.Helper() - path := filepath.Join(root, relativePath) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatal(err) - } -} - func createTestSkill(t *testing.T, skillsRoot, name string) { t.Helper() skillPath := filepath.Join(skillsRoot, name) diff --git a/internal/workspacefile/workspacefile.go b/internal/workspacefile/workspacefile.go index 1d68088..c1477c4 100644 --- a/internal/workspacefile/workspacefile.go +++ b/internal/workspacefile/workspacefile.go @@ -44,12 +44,8 @@ func ResolvePath(workDir, input string) (string, error) { return fullAbs, nil } -// ReadTextFile reads a validated workspace-relative file and applies text safety checks. -func ReadTextFile(workDir, input string, maxBytes int) (string, error) { - if maxBytes <= 0 { - maxBytes = DefaultMaxInlineBytes - } - +// ResolveExistingFilePath validates a workspace-relative path and confirms it exists as a file. +func ResolveExistingFilePath(workDir, input string) (string, error) { fullPath, err := ResolvePath(workDir, input) if err != nil { return "", err @@ -65,6 +61,25 @@ func ReadTextFile(workDir, input string, maxBytes int) (string, error) { if info.IsDir() { return "", fmt.Errorf("path is a directory: %s", input) } + + return fullPath, nil +} + +// ReadTextFile reads a validated workspace-relative file and applies text safety checks. +func ReadTextFile(workDir, input string, maxBytes int) (string, error) { + if maxBytes <= 0 { + maxBytes = DefaultMaxInlineBytes + } + + fullPath, err := ResolveExistingFilePath(workDir, input) + if err != nil { + return "", err + } + + info, err := os.Stat(fullPath) + if err != nil { + return "", fmt.Errorf("stat file: %w", err) + } if info.Size() > int64(maxBytes) { return "", fmt.Errorf("file too large: %s exceeds %d bytes", input, maxBytes) } diff --git a/tools/fs/pathutil.go b/tools/fs/pathutil.go index fbe5c27..89580d2 100644 --- a/tools/fs/pathutil.go +++ b/tools/fs/pathutil.go @@ -17,6 +17,11 @@ func resolveSafePath(workDir, input string) (string, error) { return "", fmt.Errorf("path is required") } + baseAbs, err := filepath.Abs(workDir) + if err != nil { + return "", fmt.Errorf("resolve working directory: %w", err) + } + cleaned := filepath.Clean(input) normalized, err := normalizeAllowedAbsolutePath(cleaned) if err != nil { @@ -25,6 +30,12 @@ func resolveSafePath(workDir, input string) (string, error) { cleaned = normalized if filepath.IsAbs(cleaned) { + if pathWithinBase(baseAbs, cleaned) { + if isIgnoredGitPath(cleaned) { + return "", fmt.Errorf("path is ignored: %s", input) + } + return cleaned, nil + } allowed, err := isAllowedAbsolutePath(cleaned) if err != nil { return "", err @@ -38,11 +49,6 @@ func resolveSafePath(workDir, input string) (string, error) { return cleaned, nil } - baseAbs, err := filepath.Abs(workDir) - if err != nil { - return "", fmt.Errorf("resolve working directory: %w", err) - } - fullAbs, err := filepath.Abs(filepath.Join(baseAbs, cleaned)) if err != nil { return "", fmt.Errorf("resolve path: %w", err) diff --git a/tools/fs/pathutil_test.go b/tools/fs/pathutil_test.go index 437aef9..10d9805 100644 --- a/tools/fs/pathutil_test.go +++ b/tools/fs/pathutil_test.go @@ -33,6 +33,19 @@ func TestResolveSafePathRejectsEscapeFromWorkDir(t *testing.T) { } } +func TestResolveSafePathAllowsAbsolutePathInsideWorkDir(t *testing.T) { + workDir := t.TempDir() + insidePath := filepath.Join(workDir, "nested", "inside.txt") + + got, err := resolveSafePath(workDir, insidePath) + if err != nil { + t.Fatalf("resolveSafePath returned error: %v", err) + } + if got != insidePath { + t.Fatalf("resolveSafePath = %q, want %q", got, insidePath) + } +} + func TestResolveSafePathRejectsAbsolutePathOutsideAllowedRoots(t *testing.T) { workDir := t.TempDir() outsidePath := filepath.Join(filepath.Dir(workDir), "outside.txt") diff --git a/ui/app_train_test.go b/ui/app_train_test.go index 542fee3..ceae332 100644 --- a/ui/app_train_test.go +++ b/ui/app_train_test.go @@ -455,14 +455,14 @@ func TestAtFileInputWaitsForBackendEchoBeforeShowingUserMessage(t *testing.T) { next, _ = app.handleEvent(model.Event{ Type: model.UserInput, - Message: "read [file path=\"ctx.txt\"]\ncontext payload\n[/file]", + Message: "read [file path=\"" + filepath.ToSlash(filepath.Join(root, "ctx.txt")) + "\"]", }) app = next.(App) if got := len(app.state.Messages); got != 1 { t.Fatalf("expected one backend-confirmed user message, got %d", got) } - if !strings.Contains(app.state.Messages[0].Content, `[file path="ctx.txt"]`) { + if !strings.Contains(app.state.Messages[0].Content, `[file path="`+filepath.ToSlash(filepath.Join(root, "ctx.txt"))+`"]`) { t.Fatalf("expected expanded user message content, got %#v", app.state.Messages[0]) } }