Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ mscli
| `/model <provider: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 <name> ...`
- 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:
Expand Down
101 changes: 101 additions & 0 deletions docs/at-file-input-expansion-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# `@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 reference workspace files in 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 <name> ...`
- 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
- Any invalid `@file` reference fails the whole input

Expanded file references are injected into prompts in this form:

```text
[file path="/absolute/workspace/path.txt"]
```

## Implementation Notes

The change is split across three main areas:

1. Shared file validation and file-path resolution

- Added `internal/workspacefile/workspacefile.go`
- Centralizes workspace-relative path validation
- Reused by input expansion

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 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, or directory paths

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.
138 changes: 108 additions & 30 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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) {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -437,6 +507,14 @@ Keybindings:
/ Start a slash command
ctrl+c Cancel/Quit (press twice to exit)

@file Input Expansion:
Plain chat and /report, /diagnose, /fix, /skill, /<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
@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:
MSCLI_PROVIDER Provider (openai-completion/openai-responses/anthropic)
MSCLI_BASE_URL Base URL
Expand Down
Loading