Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7604698

Browse files
committedMar 18, 2025·
feat(context)!: implement schema-based contexts
Redesign the context system to use JSON schema for validating and parsing inputs instead of callback-based approach. This improves the user experience by: - Adding structured input validation with schemas - Enhancing context documentation with auto-generated descriptions - Moving context help text from client to prompt template - Supporting multiple input parameters with separator-based parsing - Simplifying context resolution with declarative definitions BREAKING CHANGE: The context API has changed from callback-based input handling to schema-based definitions. Signed-off-by: Tomas Slusny <slusnucky@gmail.com>
1 parent 679441e commit 7604698

File tree

7 files changed

+537
-223
lines changed

7 files changed

+537
-223
lines changed
 

‎README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ Examples:
339339
> #git:staged
340340
> #url:https://example.com
341341
> #system:`ls -la | grep lua`
342+
> #command:`with;;multiple;;parameters`
342343
```
343344

344345
Define your own contexts in the configuration with input handling and resolution:
@@ -347,17 +348,22 @@ Define your own contexts in the configuration with input handling and resolution
347348
{
348349
contexts = {
349350
birthday = {
350-
input = function(callback)
351-
vim.ui.select({ 'user', 'napoleon' }, {
352-
prompt = 'Select birthday> ',
353-
}, callback)
354-
end,
351+
schema = {
352+
type = 'object',
353+
required = { 'name' },
354+
properties = {
355+
name = {
356+
type = 'string'
357+
enum = { 'Alice', 'Bob', 'Charlie' },
358+
},
359+
},
360+
},
361+
355362
resolve = function(input)
356363
return {
357364
{
358-
content = input .. ' birthday info',
359-
filename = input .. '_birthday',
360-
filetype = 'text',
365+
type = 'text',
366+
text = input.name .. ' birthday info',
361367
}
362368
}
363369
end

‎lua/CopilotChat/client.lua

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---@class CopilotChat.Client.ask
22
---@field headless boolean
3-
---@field contexts table<string, string>?
43
---@field selection CopilotChat.select.selection?
54
---@field embeddings table<CopilotChat.context.embed>?
65
---@field system_prompt string
@@ -195,49 +194,14 @@ end
195194

196195
--- Generate ask request
197196
--- @param history table<CopilotChat.Provider.input>
198-
--- @param contexts table<string, string>?
199197
--- @param prompt string
200198
--- @param system_prompt string
201199
--- @param generated_messages table<CopilotChat.Provider.input>
202-
local function generate_ask_request(history, contexts, prompt, system_prompt, generated_messages)
200+
local function generate_ask_request(history, prompt, system_prompt, generated_messages)
203201
local messages = {}
204202

205203
system_prompt = vim.trim(system_prompt)
206204

207-
-- Include context help
208-
if contexts and not vim.tbl_isempty(contexts) then
209-
local help_text = [[When you need additional context, request it using this format:
210-
211-
> #<command>:`<input>`
212-
213-
Examples:
214-
> #file:`path/to/file.js` (loads specific file)
215-
> #buffers:`visible` (loads all visible buffers)
216-
> #git:`staged` (loads git staged changes)
217-
> #system:`uname -a` (loads system information)
218-
219-
Guidelines:
220-
- Always request context when needed rather than guessing about files or code
221-
- Use the > format on a new line when requesting context
222-
- Output context commands directly - never ask if the user wants to provide information
223-
- Assume the user will provide requested context in their next response
224-
225-
Available context providers and their usage:]]
226-
227-
local context_names = vim.tbl_keys(contexts)
228-
table.sort(context_names)
229-
for _, name in ipairs(context_names) do
230-
local description = contexts[name]
231-
description = description:gsub('\n', '\n ')
232-
help_text = help_text .. '\n\n - #' .. name .. ': ' .. description
233-
end
234-
235-
if system_prompt ~= '' then
236-
system_prompt = system_prompt .. '\n\n'
237-
end
238-
system_prompt = system_prompt .. help_text
239-
end
240-
241205
-- Include system prompt
242206
if not utils.empty(system_prompt) then
243207
table.insert(messages, {
@@ -656,10 +620,8 @@ function Client:ask(prompt, opts)
656620
end
657621

658622
local headers = self:authenticate(provider_name)
659-
local request = provider.prepare_input(
660-
generate_ask_request(history, opts.contexts, prompt, opts.system_prompt, generated_messages),
661-
options
662-
)
623+
local request =
624+
provider.prepare_input(generate_ask_request(history, prompt, opts.system_prompt, generated_messages), options)
663625
local is_stream = request.stream
664626

665627
local args = {

‎lua/CopilotChat/config/contexts.lua

Lines changed: 161 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -3,98 +3,114 @@ local utils = require('CopilotChat.utils')
33

44
---@class CopilotChat.config.context
55
---@field description string?
6-
---@field input fun(callback: fun(input: string?), source: CopilotChat.source)?
7-
---@field resolve fun(input: string?, source: CopilotChat.source, prompt: string):table<CopilotChat.context.embed>
6+
---@field schema table?
7+
---@field resolve fun(input: table, source: CopilotChat.source, prompt: string):table<CopilotChat.context.embed>
88

99
---@type table<string, CopilotChat.config.context>
1010
return {
1111
buffer = {
12-
description = 'Includes specified buffer in chat context. Supports input (default current).',
13-
input = function(callback)
14-
vim.ui.select(
15-
vim.tbl_map(
16-
function(buf)
17-
return { id = buf, name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ':p:.') }
18-
end,
19-
vim.tbl_filter(function(buf)
20-
return utils.buf_valid(buf) and vim.fn.buflisted(buf) == 1
21-
end, vim.api.nvim_list_bufs())
22-
),
23-
{
24-
prompt = 'Select a buffer> ',
25-
format_item = function(item)
26-
return item.name
12+
description = 'Includes specified buffer in chat context.',
13+
14+
schema = {
15+
type = 'object',
16+
properties = {
17+
bufnr = {
18+
type = 'integer',
19+
description = 'Buffer number to include in chat context.',
20+
enum = function()
21+
return vim.tbl_map(
22+
function(buf)
23+
return buf
24+
end,
25+
vim.tbl_filter(function(buf)
26+
return utils.buf_valid(buf) and vim.fn.buflisted(buf) == 1
27+
end, vim.api.nvim_list_bufs())
28+
)
2729
end,
2830
},
29-
function(choice)
30-
callback(choice and choice.id)
31-
end
32-
)
33-
end,
34-
resolve = function(input, source)
35-
input = input and tonumber(input) or source.bufnr
31+
},
32+
},
3633

34+
resolve = function(input, source)
3735
utils.schedule_main()
3836
return {
39-
context.get_buffer(input),
37+
context.get_buffer(input.bufnr or source.bufnr),
4038
}
4139
end,
4240
},
4341

4442
buffers = {
45-
description = 'Includes all buffers in chat context. Supports input (default listed).',
46-
input = function(callback)
47-
vim.ui.select({ 'listed', 'visible' }, {
48-
prompt = 'Select buffer scope> ',
49-
}, callback)
50-
end,
51-
resolve = function(input)
52-
input = input or 'listed'
43+
description = 'Includes all buffers in chat context.',
44+
45+
schema = {
46+
type = 'object',
47+
properties = {
48+
scope = {
49+
type = 'string',
50+
description = 'Scope of buffers to include in chat context.',
51+
enum = { 'listed', 'visible' },
52+
default = 'listed',
53+
},
54+
},
55+
},
5356

57+
resolve = function(input)
5458
utils.schedule_main()
5559
return vim.tbl_map(
5660
context.get_buffer,
5761
vim.tbl_filter(function(b)
58-
return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 and (input == 'listed' or #vim.fn.win_findbuf(b) > 0)
62+
return utils.buf_valid(b)
63+
and vim.fn.buflisted(b) == 1
64+
and (input.scope == 'listed' or #vim.fn.win_findbuf(b) > 0)
5965
end, vim.api.nvim_list_bufs())
6066
)
6167
end,
6268
},
6369

6470
file = {
65-
description = 'Includes content of provided file in chat context. Supports input.',
66-
input = function(callback, source)
67-
local files = utils.scan_dir(source.cwd(), {
68-
max_count = 0,
69-
})
71+
description = 'Includes content of provided file in chat context.',
72+
73+
schema = {
74+
type = 'object',
75+
required = { 'file' },
76+
properties = {
77+
file = {
78+
type = 'string',
79+
description = 'File to include in chat context.',
80+
enum = function(source)
81+
return utils.scan_dir(source.cwd(), {
82+
max_count = 0,
83+
})
84+
end,
85+
},
86+
},
87+
},
7088

71-
utils.schedule_main()
72-
vim.ui.select(files, {
73-
prompt = 'Select a file> ',
74-
}, callback)
75-
end,
7689
resolve = function(input)
77-
if not input or input == '' then
78-
return {}
79-
end
80-
8190
utils.schedule_main()
8291
return {
83-
context.get_file(utils.filepath(input), utils.filetype(input)),
92+
context.get_file(utils.filepath(input.file), utils.filetype(input.file)),
8493
}
8594
end,
8695
},
8796

8897
files = {
89-
description = 'Includes all non-hidden files in the current workspace in chat context. Supports input (glob pattern).',
90-
input = function(callback)
91-
vim.ui.input({
92-
prompt = 'Enter glob> ',
93-
}, callback)
94-
end,
98+
description = 'Includes all non-hidden files in the current workspace in chat context.',
99+
100+
schema = {
101+
type = 'object',
102+
properties = {
103+
glob = {
104+
type = 'string',
105+
description = 'Glob pattern to match files.',
106+
default = '**/*',
107+
},
108+
},
109+
},
110+
95111
resolve = function(input, source)
96112
local files = utils.scan_dir(source.cwd(), {
97-
glob = input,
113+
glob = input.glob,
98114
})
99115

100116
utils.schedule_main()
@@ -123,16 +139,23 @@ return {
123139
},
124140

125141
filenames = {
126-
description = 'Includes names of all non-hidden files in the current workspace in chat context. Supports input (glob pattern).',
127-
input = function(callback)
128-
vim.ui.input({
129-
prompt = 'Enter glob> ',
130-
}, callback)
131-
end,
142+
description = 'Includes names of all non-hidden files in the current workspace in chat context.',
143+
144+
schema = {
145+
type = 'object',
146+
properties = {
147+
glob = {
148+
type = 'string',
149+
description = 'Glob pattern to match files.',
150+
default = '**/*',
151+
},
152+
},
153+
},
154+
132155
resolve = function(input, source)
133156
local out = {}
134157
local files = utils.scan_dir(source.cwd(), {
135-
glob = input,
158+
glob = input.glob,
136159
})
137160

138161
local chunk_size = 100
@@ -158,14 +181,21 @@ return {
158181
},
159182

160183
git = {
161-
description = 'Requires `git`. Includes current git diff in chat context. Supports input (default unstaged, also accepts commit number).',
162-
input = function(callback)
163-
vim.ui.select({ 'unstaged', 'staged' }, {
164-
prompt = 'Select diff type> ',
165-
}, callback)
166-
end,
184+
description = 'Requires `git`. Includes current git diff in chat context.',
185+
186+
schema = {
187+
type = 'object',
188+
properties = {
189+
diff = {
190+
type = 'string',
191+
description = 'Git diff to include in chat context.',
192+
enum = { 'unstaged', 'staged', 'git sha' },
193+
default = 'unstaged',
194+
},
195+
},
196+
},
197+
167198
resolve = function(input, source)
168-
input = input or 'unstaged'
169199
local cmd = {
170200
'git',
171201
'-C',
@@ -175,80 +205,85 @@ return {
175205
'--no-ext-diff',
176206
}
177207

178-
if input == 'staged' then
208+
if input.diff == 'staged' then
179209
table.insert(cmd, '--staged')
180-
elseif input == 'unstaged' then
210+
elseif input.diff == 'unstaged' then
181211
table.insert(cmd, '--')
182212
else
183-
table.insert(cmd, input)
213+
table.insert(cmd, input.diff)
184214
end
185215

186216
local out = utils.system(cmd)
187217

188218
return {
189219
{
190220
content = out.stdout,
191-
filename = 'git_diff_' .. input,
221+
filename = 'git_diff_' .. input.diff,
192222
filetype = 'diff',
193223
},
194224
}
195225
end,
196226
},
197227

198228
url = {
199-
description = 'Includes content of provided URL in chat context. Supports input.',
200-
input = function(callback)
201-
vim.ui.input({
202-
prompt = 'Enter URL> ',
203-
default = 'https://',
204-
}, callback)
205-
end,
229+
description = 'Includes content of provided URL in chat context.',
230+
231+
schema = {
232+
type = 'object',
233+
required = { 'url' },
234+
properties = {
235+
url = {
236+
type = 'string',
237+
description = 'URL to include in chat context.',
238+
},
239+
},
240+
},
241+
206242
resolve = function(input)
207243
return {
208-
context.get_url(input),
244+
context.get_url(input.url),
209245
}
210246
end,
211247
},
212248

213249
register = {
214-
description = 'Includes contents of register in chat context. Supports input (default +, e.g clipboard).',
215-
input = function(callback)
216-
local choices = utils.kv_list({
217-
['+'] = 'synchronized with the system clipboard',
218-
['*'] = 'synchronized with the selection clipboard',
219-
['"'] = 'last deleted, changed, or yanked content',
220-
['0'] = 'last yank',
221-
['-'] = 'deleted or changed content smaller than one line',
222-
['.'] = 'last inserted text',
223-
['%'] = 'name of the current file',
224-
[':'] = 'most recent executed command',
225-
['#'] = 'alternate buffer',
226-
['='] = 'result of an expression',
227-
['/'] = 'last search pattern',
228-
})
250+
description = 'Includes contents of register in chat context.',
251+
252+
schema = {
253+
type = 'object',
254+
properties = {
255+
register = {
256+
type = 'string',
257+
description = 'Register to include in chat context.',
258+
enum = {
259+
'+',
260+
'*',
261+
'"',
262+
'0',
263+
'-',
264+
'.',
265+
'%',
266+
':',
267+
'#',
268+
'=',
269+
'/',
270+
},
271+
default = '+',
272+
},
273+
},
274+
},
229275

230-
vim.ui.select(choices, {
231-
prompt = 'Select a register> ',
232-
format_item = function(choice)
233-
return choice.key .. ' - ' .. choice.value
234-
end,
235-
}, function(choice)
236-
callback(choice and choice.key)
237-
end)
238-
end,
239276
resolve = function(input)
240-
input = input or '+'
241-
242277
utils.schedule_main()
243-
local lines = vim.fn.getreg(input)
278+
local lines = vim.fn.getreg(input.register)
244279
if not lines or lines == '' then
245280
return {}
246281
end
247282

248283
return {
249284
{
250285
content = lines,
251-
filename = 'vim_register_' .. input,
286+
filename = 'vim_register_' .. input.register,
252287
filetype = '',
253288
},
254289
}
@@ -257,6 +292,7 @@ return {
257292

258293
quickfix = {
259294
description = 'Includes quickfix list file contents in chat context.',
295+
260296
resolve = function()
261297
utils.schedule_main()
262298

@@ -298,23 +334,26 @@ return {
298334
},
299335

300336
system = {
301-
description = [[Includes output of provided system shell command in chat context. Supports input.
337+
description = [[Includes output of provided system shell command in chat context.
302338
303339
Important:
304340
- Only use system commands as last resort, they are run every time the context is requested.
305341
- For example instead of curl use the url context, instead of finding and grepping try to check if there is any context that can query the data you need instead.
306342
- If you absolutely need to run a system command, try to use read-only commands and avoid commands that modify the system state.
307343
]],
308-
input = function(callback)
309-
vim.ui.input({
310-
prompt = 'Enter command> ',
311-
}, callback)
312-
end,
313-
resolve = function(input)
314-
if not input or input == '' then
315-
return {}
316-
end
317344

345+
schema = {
346+
type = 'object',
347+
required = { 'command' },
348+
properties = {
349+
command = {
350+
type = 'string',
351+
description = 'System command to include in chat context.',
352+
},
353+
},
354+
},
355+
356+
resolve = function(input)
318357
utils.schedule_main()
319358

320359
local shell, shell_flag
@@ -324,7 +363,7 @@ Important:
324363
shell, shell_flag = 'sh', '-c'
325364
end
326365

327-
local out = utils.system({ shell, shell_flag, input })
366+
local out = utils.system({ shell, shell_flag, input.command })
328367
if not out then
329368
return {}
330369
end
@@ -343,7 +382,7 @@ Important:
343382
return {
344383
{
345384
content = out_text,
346-
filename = out_type .. '_' .. input:gsub('[^%w]', '_'):sub(1, 20),
385+
filename = out_type .. '_' .. input.command:gsub('[^%w]', '_'):sub(1, 20),
347386
filetype = 'text',
348387
},
349388
}

‎lua/CopilotChat/config/prompts.lua

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
local COPILOT_BASE = string.format(
2-
[[
1+
local COPILOT_BASE = [[
32
When asked for your name, you must respond with "GitHub Copilot".
43
Follow the user's requirements carefully & to the letter.
54
Follow Microsoft content policies.
65
Avoid content that violates copyrights.
76
If you are asked to generate content that is harmful, hateful, racist, sexist, lewd, violent, or completely irrelevant to software engineering, only respond with "Sorry, I can't assist with that."
87
Keep your answers short and impersonal.
98
The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal.
10-
The user is working on a %s machine. Please respond with system specific commands if applicable.
9+
The user is working on a {OS_NAME} machine. Please respond with system specific commands if applicable.
1110
You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output.
1211
1312
When presenting code changes:
@@ -27,9 +26,34 @@ When presenting code changes:
2726
5. Address any diagnostics issues when fixing code.
2827
2928
6. If multiple changes are needed, present them as separate blocks with their own headers.
30-
]],
31-
vim.uv.os_uname().sysname
32-
)
29+
30+
When you need additional context, request it using this format instead of guessing or making assumptions:
31+
32+
> #<command>:`<input>` (single input parameter)
33+
> #<command>:`<param1>;;<param2>;;<param3>` (multiple input parameters)
34+
35+
Examples:
36+
37+
> #file:`path/to/file.js` (loads specific file)
38+
> #buffers:`visible` (loads all visible buffers)
39+
> #git:`staged` (loads git staged changes)
40+
> #system:`uname -a` (loads system information)
41+
42+
Guidelines:
43+
- Always request context when possible instead of guessing and making assumptions
44+
- Use the > format on a new line when requesting context
45+
- Output context commands directly - never ask if the user wants to provide information
46+
- Assume the user will provide requested context in their next response
47+
- When showing only examples of context usage (not for execution), wrap them in triple backticks to prevent execution:
48+
49+
```
50+
> #file:`your-file.js`
51+
```
52+
53+
Available context providers and their usage:
54+
55+
{CONTEXTS}
56+
]]
3357

3458
local COPILOT_INSTRUCTIONS = [[
3559
You are a code-focused AI programming assistant that specializes in practical software engineering solutions.

‎lua/CopilotChat/context.lua

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ local OFF_SIDE_RULE_LANGUAGES = {
6969
'fsharp',
7070
}
7171

72+
local INPUT_SEPARATOR = ';;'
73+
7274
local MULTI_FILE_THRESHOLD = 5
7375

7476
--- Compute the cosine similarity between two vectors
@@ -397,6 +399,248 @@ local function get_outline(content, ft)
397399
return table.concat(outline_lines, '\n'), symbols
398400
end
399401

402+
local function sorted_propnames(schema)
403+
local prop_names = vim.tbl_keys(schema.properties)
404+
local required_set = {}
405+
if schema.required then
406+
for _, name in ipairs(schema.required) do
407+
required_set[name] = true
408+
end
409+
end
410+
411+
-- Sort properties with priority: required without default > required with default > optional
412+
table.sort(prop_names, function(a, b)
413+
local a_required = required_set[a] or false
414+
local b_required = required_set[b] or false
415+
local a_has_default = schema.properties[a].default ~= nil
416+
local b_has_default = schema.properties[b].default ~= nil
417+
418+
-- First priority: required properties without default
419+
if a_required and not a_has_default and (not b_required or b_has_default) then
420+
return true
421+
end
422+
if b_required and not b_has_default and (not a_required or a_has_default) then
423+
return false
424+
end
425+
426+
-- Second priority: required properties with default
427+
if a_required and not b_required then
428+
return true
429+
end
430+
if b_required and not a_required then
431+
return false
432+
end
433+
434+
-- Finally sort alphabetically
435+
return a < b
436+
end)
437+
438+
return prop_names
439+
end
440+
441+
--- Serialize a JSON schema to human-readable text format
442+
---@param schema table
443+
---@param indent string?
444+
---@return string
445+
local function describe_schema(schema, indent)
446+
indent = indent or ''
447+
local result = {}
448+
449+
-- Handle basic schema types
450+
if schema.type then
451+
table.insert(result, indent .. 'Type: ' .. schema.type)
452+
end
453+
454+
-- Add description if available
455+
if schema.description then
456+
table.insert(result, indent .. 'Description: ' .. schema.description)
457+
end
458+
459+
-- Add required field information
460+
if schema.required and #schema.required > 0 then
461+
table.insert(result, indent .. 'Required: ' .. table.concat(schema.required, ', '))
462+
end
463+
464+
-- Handle properties for object types
465+
if schema.properties then
466+
table.insert(result, indent .. 'Properties:')
467+
local prop_names = sorted_propnames(schema)
468+
for _, name in ipairs(prop_names) do
469+
local prop = schema.properties[name]
470+
table.insert(result, indent .. ' ' .. name .. ':')
471+
local nested_text = describe_schema(prop, indent .. ' ')
472+
table.insert(result, nested_text)
473+
end
474+
end
475+
476+
-- Handle arrays
477+
if schema.items then
478+
table.insert(result, indent .. 'Items:')
479+
local items_text = describe_schema(schema.items, indent .. ' ')
480+
table.insert(result, items_text)
481+
end
482+
483+
-- Handle enums
484+
if schema.enum and type(schema.enum) == 'table' then
485+
table.insert(result, indent .. 'Allowed values: ' .. table.concat(schema.enum, ', '))
486+
end
487+
488+
-- Add default value information
489+
if schema.default ~= nil then
490+
table.insert(result, indent .. 'Default: ' .. schema.default)
491+
end
492+
493+
return table.concat(result, '\n')
494+
end
495+
496+
--- Get input format information for a context
497+
---@param context_name string
498+
---@param context_config CopilotChat.config.context
499+
---@return string
500+
function M.describe_context(context_name, context_config)
501+
local function resolve_prop_value(prop_name)
502+
local prop_schema = context_config.schema.properties[prop_name]
503+
local example_value = ''
504+
if prop_schema.default ~= nil then
505+
example_value = prop_schema.default
506+
elseif prop_schema.enum and type(prop_schema.enum) == 'table' then
507+
example_value = prop_schema.enum[1]
508+
elseif prop_schema.type == 'string' then
509+
example_value = 'example-' .. prop_name
510+
elseif prop_schema.type == 'integer' then
511+
example_value = '42'
512+
elseif prop_schema.type == 'boolean' then
513+
example_value = 'true'
514+
else
515+
example_value = '<' .. prop_name .. '>'
516+
end
517+
return example_value
518+
end
519+
520+
local result = {}
521+
522+
table.insert(result, '## ' .. context_name)
523+
524+
if context_config.description then
525+
table.insert(result, vim.trim(context_config.description))
526+
end
527+
528+
table.insert(result, '')
529+
530+
if context_config.schema then
531+
table.insert(result, '### Input format')
532+
table.insert(result, describe_schema(context_config.schema))
533+
table.insert(result, '')
534+
end
535+
536+
table.insert(result, '### Example usage:')
537+
table.insert(result, '```')
538+
if context_config.schema then
539+
local prop_names = sorted_propnames(context_config.schema)
540+
541+
local values = {}
542+
for _, prop_name in ipairs(prop_names) do
543+
table.insert(values, '<' .. prop_name .. '>')
544+
end
545+
table.insert(result, string.format('#%s:`%s`', context_name, table.concat(values, INPUT_SEPARATOR)))
546+
547+
if utils.empty(context_config.schema.required) then
548+
table.insert(result, '#' .. context_name)
549+
else
550+
values = {}
551+
for _, prop_name in ipairs(prop_names) do
552+
if vim.tbl_contains(context_config.schema.required, prop_name) then
553+
table.insert(values, resolve_prop_value(prop_name))
554+
end
555+
end
556+
table.insert(result, string.format('#%s:`%s`', context_name, table.concat(values, INPUT_SEPARATOR)))
557+
end
558+
559+
values = {}
560+
for _, prop_name in ipairs(prop_names) do
561+
table.insert(values, resolve_prop_value(prop_name))
562+
end
563+
table.insert(result, string.format('#%s:`%s`', context_name, table.concat(values, INPUT_SEPARATOR)))
564+
else
565+
table.insert(result, '#' .. context_name)
566+
end
567+
568+
table.insert(result, '```')
569+
table.insert(result, '')
570+
return table.concat(result, '\n')
571+
end
572+
573+
--- Parse context input string into a table based on the schema
574+
---@param input string?
575+
---@param schema table?
576+
---@return table
577+
function M.parse_input(input, schema)
578+
if not input or input == '' or not schema or not schema.properties then
579+
return {}
580+
end
581+
582+
local parts = vim.split(input, INPUT_SEPARATOR)
583+
local result = {}
584+
local prop_names = sorted_propnames(schema)
585+
586+
-- Map input parts to schema properties in sorted order
587+
local i = 1
588+
for _, prop_name in ipairs(prop_names) do
589+
local prop_schema = schema.properties[prop_name]
590+
local value = parts[i] ~= '' and parts[i] or nil
591+
if value == nil and prop_schema.default ~= nil then
592+
value = prop_schema.default
593+
end
594+
595+
result[prop_name] = value
596+
i = i + 1
597+
if i > #parts then
598+
break
599+
end
600+
end
601+
602+
return result
603+
end
604+
605+
--- Get input from the user based on the schema
606+
---@param schema table?
607+
---@param source CopilotChat.source
608+
---@return string?
609+
function M.enter_input(schema, source)
610+
if not schema or not schema.properties then
611+
return nil
612+
end
613+
614+
local prop_names = sorted_propnames(schema)
615+
local out = {}
616+
617+
for _, prop_name in ipairs(prop_names) do
618+
local cfg = schema.properties[prop_name]
619+
if cfg.enum then
620+
local choices = type(cfg.enum) == 'table' and cfg.enum or cfg.enum(source)
621+
local choice = utils.select(choices, {
622+
prompt = string.format('Select %s> ', prop_name),
623+
})
624+
625+
table.insert(out, choice or '')
626+
elseif cfg.type == 'boolean' then
627+
table.insert(out, utils.select({ 'true', 'false' }, {
628+
prompt = string.format('Select %s> ', prop_name),
629+
}) or '')
630+
else
631+
table.insert(out, utils.input({
632+
prompt = string.format('Enter %s> ', prop_name),
633+
}) or '')
634+
end
635+
end
636+
637+
local out = vim.trim(table.concat(out, INPUT_SEPARATOR))
638+
if out:match('%s+') then
639+
out = string.format('`%s`', out)
640+
end
641+
return out
642+
end
643+
400644
--- Get data for a file
401645
---@param filename string
402646
---@param filetype string?

‎lua/CopilotChat/init.lua

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ local notify = require('CopilotChat.notify')
66
local utils = require('CopilotChat.utils')
77

88
local PLUGIN_NAME = 'CopilotChat'
9-
local WORD = '([^%s]+)'
10-
local WORD_INPUT = '([^%s:]+:`[^`]+`)'
9+
local WORD = '([^%s:]+)'
10+
local WORD_WITH_INPUT_UNQUOTED = WORD .. ':([^%s]+)'
11+
local WORD_WITH_INPUT_QUOTED = WORD .. ':`([^`]+)`'
1112

1213
---@class CopilotChat
1314
---@field config CopilotChat.config
@@ -303,6 +304,24 @@ function M.resolve_prompt(prompt, config)
303304
if prompts_to_use[config.system_prompt] then
304305
config.system_prompt = prompts_to_use[config.system_prompt].system_prompt
305306
end
307+
308+
if config.system_prompt then
309+
config.system_prompt = config.system_prompt:gsub('{OS_NAME}', vim.uv.os_uname().sysname)
310+
311+
local context_string = ''
312+
local context_names = vim.tbl_keys(M.config.contexts)
313+
table.sort(context_names)
314+
for _, name in ipairs(context_names) do
315+
local ctx = M.config.contexts[name]
316+
context_string = context_string .. context.describe_context(name, ctx) .. '\n'
317+
end
318+
context_string = vim.trim(context_string)
319+
320+
if context_string ~= '' then
321+
config.system_prompt = config.system_prompt:gsub('{CONTEXTS}', context_string)
322+
end
323+
end
324+
306325
return config, prompt
307326
end
308327

@@ -315,18 +334,12 @@ function M.resolve_context(prompt, config)
315334
config, prompt = M.resolve_prompt(prompt, config)
316335

317336
local contexts = {}
318-
local function parse_context(prompt_context)
319-
local split = vim.split(prompt_context, ':')
320-
local context_name = table.remove(split, 1)
321-
local context_input = vim.trim(table.concat(split, ':'))
322-
if vim.startswith(context_input, '`') and vim.endswith(context_input, '`') then
323-
context_input = context_input:sub(2, -2)
324-
end
325-
326-
if M.config.contexts[context_name] then
337+
local function parse_context(word, input)
338+
local ctx = M.config.contexts[word]
339+
if ctx then
327340
table.insert(contexts, {
328-
name = context_name,
329-
input = (context_input ~= '' and context_input or nil),
341+
name = word,
342+
input = context.parse_input(input, ctx.schema),
330343
})
331344

332345
return true
@@ -335,12 +348,16 @@ function M.resolve_context(prompt, config)
335348
return false
336349
end
337350

338-
prompt = prompt:gsub('#' .. WORD_INPUT, function(match)
339-
return parse_context(match) and '' or '#' .. match
351+
prompt = prompt:gsub('#' .. WORD_WITH_INPUT_QUOTED, function(word, input)
352+
return parse_context(word, input) and '' or string.format('#%s:`%s`', word, input)
353+
end)
354+
355+
prompt = prompt:gsub('#' .. WORD_WITH_INPUT_UNQUOTED, function(word, input)
356+
return parse_context(word, input) and '' or string.format('#%s:%s', word, input)
340357
end)
341358

342-
prompt = prompt:gsub('#' .. WORD, function(match)
343-
return parse_context(match) and '' or '#' .. match
359+
prompt = prompt:gsub('#' .. WORD, function(word)
360+
return parse_context(word) and '' or string.format('#%s', word)
344361
end)
345362

346363
if config.context then
@@ -357,11 +374,7 @@ function M.resolve_context(prompt, config)
357374
local embeddings = utils.ordered_map()
358375
for _, context_data in ipairs(contexts) do
359376
local context_value = M.config.contexts[context_data.name]
360-
notify.publish(
361-
notify.STATUS,
362-
'Resolving context: ' .. context_data.name .. (context_data.input and ' with input: ' .. context_data.input or '')
363-
)
364-
377+
notify.publish(notify.STATUS, 'Resolving context: ' .. context_data.name)
365378
local ok, resolved_embeddings = pcall(context_value.resolve, context_data.input, state.source or {}, prompt)
366379
if ok then
367380
for _, embedding in ipairs(resolved_embeddings) do
@@ -525,21 +538,16 @@ function M.trigger_complete(without_context)
525538

526539
if not without_context and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then
527540
local found_context = M.config.contexts[prefix:sub(2, -2)]
528-
if found_context and found_context.input then
541+
if found_context and found_context.schema then
529542
async.run(function()
530-
found_context.input(function(value)
531-
if not value then
532-
return
533-
end
534-
535-
local value_str = vim.trim(tostring(value))
536-
if value_str:find('%s') then
537-
value_str = '`' .. value_str .. '`'
538-
end
543+
local value = context.enter_input(found_context.schema, state.source)
544+
if not value then
545+
return
546+
end
539547

540-
vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { value_str })
541-
vim.api.nvim_win_set_cursor(0, { row, col + #value_str })
542-
end, state.source or {})
548+
utils.schedule_main()
549+
vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { value })
550+
vim.api.nvim_win_set_cursor(0, { row, col + #value })
543551
end)
544552
end
545553

@@ -634,8 +642,8 @@ function M.complete_items()
634642
word = '#' .. name,
635643
abbr = name,
636644
kind = 'context',
637-
info = value.description or '',
638-
menu = value.input and string.format('#%s:<input>', name) or string.format('#%s', name),
645+
info = context.describe_context(name, value),
646+
menu = vim.split(vim.trim(value.description or ''), '\n')[1],
639647
icase = 1,
640648
dup = 0,
641649
empty = 0,
@@ -848,14 +856,6 @@ function M.ask(prompt, config)
848856
config, prompt = M.resolve_prompt(prompt, config)
849857
local system_prompt = config.system_prompt or ''
850858

851-
-- Resolve context name and description
852-
local contexts = {}
853-
for name, context in pairs(M.config.contexts) do
854-
if context.description then
855-
contexts[name] = context.description
856-
end
857-
end
858-
859859
-- Remove sticky prefix
860860
prompt = table.concat(
861861
vim.tbl_map(function(l)
@@ -886,7 +886,6 @@ function M.ask(prompt, config)
886886

887887
local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, {
888888
headless = config.headless,
889-
contexts = contexts,
890889
selection = selection,
891890
embeddings = filtered_embeddings,
892891
system_prompt = system_prompt,

‎lua/CopilotChat/utils.lua

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,46 @@ M.ts_parse = async.wrap(function(parser, callback)
699699
end
700700
end, 2)
701701

702+
--- Wait for a user input
703+
M.input = async.wrap(function(opts, callback)
704+
local fn = function()
705+
vim.ui.input(opts, function(input)
706+
if input == nil or input == '' then
707+
callback(nil)
708+
return
709+
end
710+
711+
callback(input)
712+
end)
713+
end
714+
715+
if vim.in_fast_event() then
716+
vim.schedule(fn)
717+
else
718+
fn()
719+
end
720+
end, 2)
721+
722+
--- Select an item from a list
723+
M.select = async.wrap(function(choices, opts, callback)
724+
local fn = function()
725+
vim.ui.select(choices, opts, function(item)
726+
if item == nil or item == '' then
727+
callback(nil)
728+
return
729+
end
730+
731+
callback(item)
732+
end)
733+
end
734+
735+
if vim.in_fast_event() then
736+
vim.schedule(fn)
737+
else
738+
fn()
739+
end
740+
end, 3)
741+
702742
--- Get the info for a key.
703743
---@param name string
704744
---@param key table

0 commit comments

Comments
 (0)
Please sign in to comment.