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 eaba359

Browse files
committedMar 17, 2025·
feat(context)!: implement schema-based context providers
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.
1 parent 62b1249 commit eaba359

File tree

6 files changed

+500
-220
lines changed

6 files changed

+500
-220
lines changed
 

‎lua/CopilotChat/client.lua

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
---@class CopilotChat.Client.ask
22
---@field load_history boolean
33
---@field headless boolean
4-
---@field contexts table<string, string>?
54
---@field selection CopilotChat.select.selection?
65
---@field embeddings table<CopilotChat.context.embed>?
76
---@field system_prompt string
@@ -201,49 +200,14 @@ end
201200
--- Generate ask request
202201
--- @param history table<CopilotChat.Provider.input>
203202
--- @param memory CopilotChat.Client.memory?
204-
--- @param contexts table<string, string>?
205203
--- @param prompt string
206204
--- @param system_prompt string
207205
--- @param generated_messages table<CopilotChat.Provider.input>
208-
local function generate_ask_request(history, memory, contexts, prompt, system_prompt, generated_messages)
206+
local function generate_ask_request(history, memory, prompt, system_prompt, generated_messages)
209207
local messages = {}
210208

211209
system_prompt = vim.trim(system_prompt)
212210

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

695659
local headers = self:authenticate(provider_name)
696660
local request = provider.prepare_input(
697-
generate_ask_request(history, self.memory, opts.contexts, prompt, opts.system_prompt, generated_messages),
661+
generate_ask_request(history, self.memory, prompt, opts.system_prompt, generated_messages),
698662
options
699663
)
700664
local is_stream = request.stream

‎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:
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 needed rather than guessing about files or code
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: 210 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,214 @@ local function get_outline(content, ft)
397399
return table.concat(outline_lines, '\n'), symbols
398400
end
399401

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

‎lua/CopilotChat/init.lua

Lines changed: 57 additions & 54 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.describe_context(name, ctx) .. '\n' .. context_string
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)
340353
end)
341354

342-
prompt = prompt:gsub('#' .. WORD, function(match)
343-
return parse_context(match) and '' or '#' .. match
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)
357+
end)
358+
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 = value.description or '',
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)
@@ -887,19 +887,20 @@ function M.ask(prompt, config)
887887
local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, {
888888
load_history = not config.headless,
889889
headless = config.headless,
890-
contexts = contexts,
891890
selection = selection,
892891
embeddings = filtered_embeddings,
893892
system_prompt = system_prompt,
894893
model = selected_model,
895894
agent = selected_agent,
896895
temperature = config.temperature,
897896
on_progress = vim.schedule_wrap(function(token)
898-
local out = config.stream and config.stream(token, state.source) or nil
899-
if out == nil then
900-
out = token
897+
local to_print = not config.headless and token
898+
if to_print and config.stream then
899+
local out = config.stream(token, state.source)
900+
if out ~= nil then
901+
to_print = out
902+
end
901903
end
902-
local to_print = not config.headless and out
903904
if to_print and to_print ~= '' then
904905
M.chat:append(token)
905906
end
@@ -922,11 +923,13 @@ function M.ask(prompt, config)
922923
end
923924

924925
-- Call the callback function and store to history
925-
local out = config.callback and config.callback(response, state.source) or nil
926-
if out == nil then
927-
out = response
926+
local to_store = not config.headless and response
927+
if to_store and config.callback then
928+
local out = config.callback(response, state.source)
929+
if out ~= nil then
930+
to_store = out
931+
end
928932
end
929-
local to_store = not config.headless and out
930933
if to_store and to_store ~= '' then
931934
table.insert(client.history, {
932935
content = 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.