Skip to content

Commit

Permalink
feat: ✨ workspace slash command (olimorris#702)
Browse files Browse the repository at this point in the history
Co-authored-by: Oli Morris <[email protected]>
  • Loading branch information
olimorris and olimorris authored Jan 25, 2025
1 parent c3d907b commit 8173b5d
Show file tree
Hide file tree
Showing 29 changed files with 911 additions and 71 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ doc/.vitepress/cache
deps/
todo.md
media/
examples/
52 changes: 52 additions & 0 deletions codecompanion-workspace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "CodeCompanion.nvim",
"version": "1.0.0",
"workspace_spec": "1.0",
"description": "CodeCompanion.nvim is an AI-powered productivity tool integrated into Neovim, designed to enhance the development workflow by seamlessly interacting with various large language models (LLMs). It offers features like inline code transformations, code creation, refactoring, and supports multiple LLMs such as OpenAI, Anthropic, and Google Gemini, among others. With tools for variable management, agents, and custom workflows, CodeCompanion.nvim streamlines coding tasks and facilitates intelligent code assistance directly within the Neovim editor",
"groups": [
{
"name": "Chat Buffer",
"description": "${workspace_description}. I've grouped a number of files together into a group I'm calling \"${group_name}\". The chat buffer is a Neovim buffer which allows a user to interact with an LLM. The buffer is formatted as Markdown with a user's content residing under a H2 header. The user types their message, saves the buffer and the plugin then uses Tree-sitter to parse the buffer, extracting the contents and sending to an adapter which connects to the user's chosen LLM. The response back from the LLM is streamed into the buffer under another H2 header. The user is then free to respond back to the LLM.\n\nBelow are the relevant files which we will be discussing:\n\n${hierarchy}",
"vars": {
"base_dir": "lua/codecompanion/strategies/chat"
},
"files": [
{
"description": "The `${filename}` file is the entry point for the chat strategy. All methods directly relating to the chat buffer reside here.",
"path": "${base_dir}/init.lua"
}
],
"symbols": [
{
"description": "References are files, buffers, symbols or URLs that are shared with an LLM to provide additional context. The `${filename}` is where this logic sits and I've shared its symbolic outline below.",
"path": "${base_dir}/references.lua"
},
{
"description": "A watcher is when a user has toggled a specific buffer to be watched. When a message is sent to the LLM by the user, any changes made to the watched buffer are also sent, giving the LLM up to date context. The `${filename}` is where this logic sits and I've shared its symbolic outline below.",
"path": "${base_dir}/watchers.lua"
}
]
},
{
"name": "Test",
"description": "This is a test group",
"vars": {
"base_dir": "tests/stubs"
},
"files": [
{
"description": "Test description for the file ${filename} located at ${path}",
"path": "${base_dir}/stub.go"
},
"${base_dir}/stub.txt"
],
"symbols": [
{
"description": "Test symbol description for the file ${filename} located at ${path}",
"path": "${base_dir}/stub.lua"
},
"${base_dir}/stub.py"
]
}
]
}
1 change: 1 addition & 0 deletions doc/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export default defineConfig({
{ text: "Creating Adapters", link: "/extending/adapters" },
{ text: "Creating Prompts", link: "/extending/prompts" },
{ text: "Creating Tools", link: "/extending/tools" },
{ text: "Creating Workspaces", link: "/extending/workspace" },
],
},
],
Expand Down
102 changes: 102 additions & 0 deletions doc/extending/workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Creating Workspaces

Workspaces act as a context management system for your project. This context sits in a `codecompanion-workspace.json` file in the root of the current working directory. For the purposes of this guide, the file will be referred to as the _workspace file_.

## Structure

Taking CodeCompanion's own workspace file as an example:

```json
{
"name": "CodeCompanion.nvim",
"version": "1.0.0",
"workspace_spec": "1.0",
"description": "CodeCompanion.nvim is an AI-powered productivity tool integrated into Neovim, designed to enhance the development workflow by seamlessly interacting with various large language models (LLMs). It offers features like inline code transformations, code creation, refactoring, and supports multiple LLMs such as OpenAI, Anthropic, and Google Gemini, among others. With tools for variable management, agents, and custom workflows, CodeCompanion.nvim streamlines coding tasks and facilitates intelligent code assistance directly within the Neovim editor",
"groups": [
{
"name": "Chat Buffer",
"description": "...",
"files": [
{
"description": "...",
"path": "..."
}
],
"symbols": [
{
"description": "...",
"path": "..."
},
]
},
]
}
```

- The `description` value contains the high-level description of the project. This is **not** sent to the LLM by default
- The `groups` array contains the grouping of files and symbols that can be shared with the LLM. In this example we just have one group, the _Chat Buffer_
- The `version` and `workspace_spec` are currently unused

> [!INFO]
> When a user selects a group to load, the workspace slash command will iterate through the group adding the description first and then sequentially adding the files and symbols. For the latter two, their description is added first, before their content.
## Groups

Groups are the core of the workspace file. They are where logical groupings of files and/or symbols are defined. Exploring the _Chat Buffer_ group in detail:

```json
{
"name": "Chat Buffer",
"description": "${workspace_description}. I've grouped a number of files together into a group I'm calling \"${group_name}\". The chat buffer is a Neovim buffer which allows a user to interact with an LLM. The buffer is formatted as Markdown with a user's content residing under a H2 header. The user types their message, saves the buffer and the plugin then uses Tree-sitter to parse the buffer, extracting the contents and sending to an adapter which connects to the user's chosen LLM. The response back from the LLM is streamed into the buffer under another H2 header. The user is then free to respond back to the LLM.\n\nBelow are the relevant files which we will be discussing:\n\n${hierarchy}",
"vars": {
"base_dir": "lua/codecompanion/strategies/chat"
},
"files": [
{
"description": "The `${filename}` file is the entry point for the chat strategy. All methods directly relating to the chat buffer reside here.",
"path": "${base_dir}/init.lua"
}
],
"symbols": [
{
"description": "References are files, buffers, symbols or URLs that are shared with an LLM to provide additional context. The `${filename}` is where this logic sits and I've shared its symbolic outline below.",
"path": "${base_dir}/references.lua"
},
{
"description": "A watcher is when a user has toggled a specific buffer to be watched. When a message is sent to the LLM by the user, any changes made to the watched buffer are also sent, giving the LLM up to date context. The `${filename}` is where this logic sits and I've shared its symbolic outline below.",
"path": "${base_dir}/watchers.lua"
}
]
}
```

There's a lot going on in there:

- Firstly, the `${workspace_description}` is a way of including the description from the top of the workspace file
- The `${group_name}` provides the name of the current group
- The `${hierarchy}` variable contains a list of all the files and symbols in the group
- The `vars` object is a way of creating variables that can be referenced throughout the files and symbols arrays
- Each object in the files/symbols array can be a string which defaults to a path, or can be an object containing a
description and the path

### Files

When _files_ are defined, their entire content is shared with the LLM alongside the description. This is useful for files where a deep understanding of how they function is required. Of course, this can consume significant tokens.

### Symbols

When _symbols_ are defined, a symbolic outline of the file, as per the Tree-sitter [queries](https://github.com/olimorris/codecompanion.nvim/tree/main/queries) in the plugin, is shared with the LLM. This will typically include class, method, interface and function names, alongside any file or library imports. The start and end line of each symbol is also shared.

During conversation with the LLM, it can be useful to also tag the `@files` tool, giving the LLM the ability to fetch content between specific lines. This can be a cost-effective way for an LLM to get more information without sharing the whole file.

## Variables

A list of all the variables available in workspace files:

- `${workspace_description}` - The description at the top of the workspace file
- `${group_name}` - The name of the group that is being processed by the slash command
- `${hierarchy}` - A list of all the files and symbols in the group
- `${filename}` - The name of the current file/symbol that is being processed
- `${cwd}` - The current working directory of the workspace file
- `${path}` - The path to the current file/symbol

7 changes: 7 additions & 0 deletions doc/usage/chat-buffer/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ The _symbols_ slash command uses Tree-sitter to create a symbolic outline of a f

The _terminal_ slash command shares the output from the last terminal buffer with the chat buffer.

## /workspace

The _workspace_ slash command allows users to share defined groups of files and/or symbols with an LLM, alongside some pre-written context. The slash command uses a [codecompanion-workspace.json](https://github.com/olimorris/codecompanion.nvim/blob/main/codecompanion-workspace.json) file, stored in the current working directory, to house this context. It is, in essence, a context management system for your repository.

Whilst LLMs are incredibly powerful, they have no knowledge of the architectural decisions yourself or your team have made on a project. They have no context as to why you've selected the dependencies that you have. And, they can't see how your codebase has evolved over time.

Please see the [Creating Workspaces](/extending/workspace) guide to learn how to build your own.
7 changes: 7 additions & 0 deletions lua/codecompanion/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ Points to note:
contains_code = false,
},
},
["workspace"] = {
callback = "strategies.chat.slash_commands.workspace",
description = "Load a workspace file",
opts = {
contains_code = true,
},
},
},
keymaps = {
options = {
Expand Down
42 changes: 27 additions & 15 deletions lua/codecompanion/strategies/chat/slash_commands/file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ function SlashCommand:execute(SlashCommands)
end

---Open and read the contents of the selected file
---@param selected table The selected item from the provider { relative_path = string, path = string }
---@param selected { relative_path: string?, path: string, description: string? }
function SlashCommand:read(selected)
local ok, content = pcall(function()
return path.new(selected.path):read()
Expand All @@ -135,8 +135,8 @@ function SlashCommand:read(selected)
end

---Output from the slash command in the chat buffer
---@param selected table The selected item from the provider { relative_path = string, path = string }
---@param opts? table
---@param selected { relative_path: string?, path: string, description: string? }
---@param opts? { silent: boolean, pin: boolean }
---@return nil
function SlashCommand:output(selected, opts)
if not config.can_send_code() and (self.config.opts and self.config.opts.contains_code) then
Expand All @@ -150,32 +150,44 @@ function SlashCommand:output(selected, opts)
return log:warn("Could not read the file: %s", selected.path)
end

local message = "Here is the content from the file"
if opts.pin then
message = "Here is the updated content from the file"
end
-- Workspaces allow the user to set their own custom description which should take priority
local description
if selected.description then
description = fmt(
[[%s
self.Chat:add_message({
role = config.constants.USER_ROLE,
content = fmt(
[[%s `%s`:
```%s
%s
```]],
selected.description,
ft,
content
)
else
description = fmt(
[[%s %s:
```%s
%s
```]],
message,
relative_path,
opts.pin and "Here is the updated content from the file" or "Here is the content from the file",
"located at `" .. relative_path .. "`",
ft,
content
),
)
end

self.Chat:add_message({
role = config.constants.USER_ROLE,
content = description or "",
}, { reference = id, visible = false })

if opts.pin then
return
end

self.Chat.References:add({
id = id,
id = id or "",
path = selected.path,
source = "codecompanion.strategies.chat.slash_commands.file",
})
Expand Down
29 changes: 29 additions & 0 deletions lua/codecompanion/strategies/chat/slash_commands/init.lua
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
local config = require("codecompanion.config")
local log = require("codecompanion.utils.log")

---Resolve the callback to the correct module
Expand Down Expand Up @@ -69,4 +70,32 @@ function SlashCommands:execute(item, chat)
:execute(self)
end

---Function for external objects to add references via Slash Commands
---@param chat CodeCompanion.Chat
---@param slash_command string
---@param opts { path: string, url?: string, description: string }
---@return nil
function SlashCommands.references(chat, slash_command, opts)
local slash_commands = {
file = require("codecompanion.strategies.chat.slash_commands.file").new({
Chat = chat,
}),
symbols = require("codecompanion.strategies.chat.slash_commands.symbols").new({
Chat = chat,
}),
url = require("codecompanion.strategies.chat.slash_commands.fetch").new({
Chat = chat,
config = config.strategies.chat.slash_commands["fetch"],
}),
}

if slash_command == "file" or slash_command == "symbols" then
return slash_commands[slash_command]:output({ description = opts.description, path = opts.path }, { silent = true })
end

if slash_command == "url" then
return slash_commands[slash_command]:output(opts.url, { silent = true })
end
end

return SlashCommands
Loading

0 comments on commit 8173b5d

Please sign in to comment.