Skip to content

Commit 7199ae9

Browse files
committed
selection.lua add code selection to /ask
1 parent ae5d3b6 commit 7199ae9

3 files changed

Lines changed: 215 additions & 34 deletions

File tree

lua/copilot_agent/chat.lua

Lines changed: 64 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ local function help_lines()
114114
}
115115
end
116116

117+
local function is_session_not_attached_error(err)
118+
if type(err) ~= 'string' then
119+
return false
120+
end
121+
return err:lower():find('session is not attached to this service', 1, true) ~= nil
122+
end
123+
117124
local function show_help_popup()
118125
local lines = help_lines()
119126
local max_w = 0
@@ -1256,6 +1263,60 @@ function M.ask(prompt, opts)
12561263
local function dispatch_prompt()
12571264
local prompt_body = text
12581265
local included_restore_context = false
1266+
local body = { prompt = prompt_body }
1267+
1268+
local function cleanup_temp_files()
1269+
for _, p in ipairs(temp_files) do
1270+
pcall(os.remove, p)
1271+
end
1272+
end
1273+
1274+
local function finalize_failure(request_err)
1275+
state.pending_checkpoint_turn = nil
1276+
state.active_turn_assistant_index = nil
1277+
state.live_assistant_entry_index = nil
1278+
state.active_turn_assistant_message_id = nil
1279+
state.active_assistant_merge_group = nil
1280+
state.active_tool = nil
1281+
state.active_tool_run_id = nil
1282+
state.active_tool_detail = nil
1283+
state.pending_tool_detail = nil
1284+
state.overlay_tool_display = nil
1285+
state.overlay_tool_queue = {}
1286+
state.overlay_tool_schedule_token = (tonumber(state.overlay_tool_schedule_token) or 0) + 1
1287+
state.post_tool_use_hooks = {}
1288+
state.recent_activity_lines = {}
1289+
state.recent_activity_items = {}
1290+
state.recent_activity_tool_calls = {}
1291+
state.current_intent = nil
1292+
state.chat_busy = false
1293+
refresh_statuslines()
1294+
refresh_reasoning_overlay(true)
1295+
append_entry('error', 'Failed to send prompt: ' .. request_err)
1296+
end
1297+
1298+
local function send_once()
1299+
request('POST', string.format('/sessions/%s/messages', session_id), body, function(_, request_err)
1300+
if request_err and is_session_not_attached_error(request_err) then
1301+
-- Recover the session so the next user prompt will succeed, but do
1302+
-- NOT retry automatically — retrying risks sending the prompt twice.
1303+
require('copilot_agent.session').recover_after_service_restart(session_id, function() end)
1304+
cleanup_temp_files()
1305+
finalize_failure(request_err .. ' (session recovered — please resend)')
1306+
return
1307+
end
1308+
1309+
cleanup_temp_files()
1310+
if request_err then
1311+
finalize_failure(request_err)
1312+
return
1313+
end
1314+
if included_restore_context then
1315+
state.pending_session_context = nil
1316+
end
1317+
end)
1318+
end
1319+
12591320
local pending_context = state.pending_session_context
12601321
if type(pending_context) == 'table' and type(pending_context.text) == 'string' and pending_context.text ~= '' then
12611322
if pending_context.session_id == nil or pending_context.session_id == session_id then
@@ -1271,6 +1332,8 @@ function M.ask(prompt, opts)
12711332
end
12721333
end
12731334

1335+
body.prompt = prompt_body
1336+
12741337
require('copilot_agent').open_chat({
12751338
activate_input_on_session_ready = false,
12761339
replace_current = opts.replace_current,
@@ -1286,44 +1349,11 @@ function M.ask(prompt, opts)
12861349
refresh_statuslines()
12871350
schedule_render()
12881351

1289-
local body = { prompt = prompt_body }
12901352
body.clientId = service.client_id()
12911353
if #api_attachments > 0 then
12921354
body.attachments = api_attachments
12931355
end
1294-
request('POST', string.format('/sessions/%s/messages', session_id), body, function(_, request_err)
1295-
-- Clean up any clipboard temp PNGs — the HTTP request has been delivered.
1296-
for _, p in ipairs(temp_files) do
1297-
pcall(os.remove, p)
1298-
end
1299-
if request_err then
1300-
state.pending_checkpoint_turn = nil
1301-
state.active_turn_assistant_index = nil
1302-
state.live_assistant_entry_index = nil
1303-
state.active_turn_assistant_message_id = nil
1304-
state.active_assistant_merge_group = nil
1305-
state.active_tool = nil
1306-
state.active_tool_run_id = nil
1307-
state.active_tool_detail = nil
1308-
state.pending_tool_detail = nil
1309-
state.overlay_tool_display = nil
1310-
state.overlay_tool_queue = {}
1311-
state.overlay_tool_schedule_token = (tonumber(state.overlay_tool_schedule_token) or 0) + 1
1312-
state.post_tool_use_hooks = {}
1313-
state.recent_activity_lines = {}
1314-
state.recent_activity_items = {}
1315-
state.recent_activity_tool_calls = {}
1316-
state.current_intent = nil
1317-
state.chat_busy = false
1318-
refresh_statuslines()
1319-
refresh_reasoning_overlay(true)
1320-
append_entry('error', 'Failed to send prompt: ' .. request_err)
1321-
return
1322-
end
1323-
if included_restore_context then
1324-
state.pending_session_context = nil
1325-
end
1326-
end)
1356+
send_once()
13271357
end
13281358

13291359
dispatch_prompt()

lua/copilot_agent/selection.lua

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
-- Copyright 2026 ray-x. All rights reserved.
2+
-- Use of this source code is governed by an Apache 2.0
3+
-- license that can be found in the LICENSE file.
4+
5+
local M = {}
6+
7+
local function display_name(path)
8+
if type(path) ~= 'string' or path == '' then
9+
return '[No Name]'
10+
end
11+
local name = vim.fn.fnamemodify(path, ':t')
12+
if type(name) == 'string' and name ~= '' then
13+
return name
14+
end
15+
return '[No Name]'
16+
end
17+
18+
local function swap_range(start_row, start_col, end_row, end_col)
19+
if start_row > end_row or (start_row == end_row and start_col > end_col) then
20+
return end_row, end_col, start_row, start_col
21+
end
22+
return start_row, start_col, end_row, end_col
23+
end
24+
25+
local function clamp(value, min_value, max_value)
26+
if value < min_value then
27+
return min_value
28+
end
29+
if value > max_value then
30+
return max_value
31+
end
32+
return value
33+
end
34+
35+
local function extract_block_text(bufnr, start_row, start_col, end_row, end_col)
36+
local lines = {}
37+
for row = start_row, end_row do
38+
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
39+
local from_col = clamp(start_col, 0, #line)
40+
local to_col = clamp(end_col + 1, 0, #line)
41+
if to_col < from_col then
42+
to_col = from_col
43+
end
44+
lines[#lines + 1] = line:sub(from_col + 1, to_col)
45+
end
46+
return table.concat(lines, '\n')
47+
end
48+
49+
local function extract_text(bufnr, start_row, start_col, end_row, end_col, visual_mode)
50+
if visual_mode == '\022' then
51+
return extract_block_text(bufnr, start_row, start_col, end_row, end_col)
52+
end
53+
local ok, lines = pcall(vim.api.nvim_buf_get_text, bufnr, start_row, start_col, end_row, end_col + 1, {})
54+
if ok and type(lines) == 'table' then
55+
return table.concat(lines, '\n')
56+
end
57+
return extract_block_text(bufnr, start_row, start_col, end_row, end_col)
58+
end
59+
60+
function M.current_buffer_selection(opts)
61+
opts = opts or {}
62+
local bufnr = opts.bufnr or vim.api.nvim_get_current_buf()
63+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
64+
return nil
65+
end
66+
67+
local start_mark = vim.api.nvim_buf_get_mark(bufnr, '<')
68+
local end_mark = vim.api.nvim_buf_get_mark(bufnr, '>')
69+
if not start_mark or not end_mark or start_mark[1] == 0 or end_mark[1] == 0 then
70+
return nil
71+
end
72+
73+
local start_row = start_mark[1] - 1
74+
local start_col = math.max(0, start_mark[2])
75+
local end_row = end_mark[1] - 1
76+
local end_col = math.max(0, end_mark[2])
77+
start_row, start_col, end_row, end_col = swap_range(start_row, start_col, end_row, end_col)
78+
79+
local text = extract_text(bufnr, start_row, start_col, end_row, end_col, opts.visual_mode)
80+
text = type(text) == 'string' and text or ''
81+
if vim.trim(text) == '' then
82+
return nil
83+
end
84+
85+
local path = vim.api.nvim_buf_get_name(bufnr)
86+
local attachment = {
87+
type = 'selection',
88+
path = path,
89+
text = text,
90+
start_line = start_row,
91+
end_line = end_row,
92+
display = string.format('selection:%s:%d-%d', display_name(path), start_row + 1, end_row + 1),
93+
}
94+
return attachment
95+
end
96+
97+
return M

tests/integration/setup_spec.lua

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8309,6 +8309,60 @@ describe('chat session activation', function()
83098309
assert_eq(false, captured_opts.activate_input_on_session_ready)
83108310
end)
83118311

8312+
it('recovers session on stale attachment error without retrying', function()
8313+
local original_open_chat = agent.open_chat
8314+
local original_with_session = session.with_session
8315+
local http = require('copilot_agent.http')
8316+
local original_request = http.request
8317+
local original_recover = session.recover_after_service_restart
8318+
local request_count = 0
8319+
local recovered_session_id
8320+
8321+
agent.open_chat = function() end
8322+
session.with_session = function(callback)
8323+
callback('session-123', nil)
8324+
end
8325+
session.recover_after_service_restart = function(session_id, callback)
8326+
recovered_session_id = session_id
8327+
if callback then
8328+
callback(session_id, nil)
8329+
end
8330+
end
8331+
http.request = function(method, path, _body, callback)
8332+
if method == 'POST' and path == '/sessions/session-123/messages' then
8333+
request_count = request_count + 1
8334+
if request_count == 1 then
8335+
callback(nil, 'session is not attached to this service')
8336+
else
8337+
callback({}, nil)
8338+
end
8339+
return
8340+
end
8341+
error('unexpected request ' .. method .. ' ' .. path)
8342+
end
8343+
8344+
package.loaded['copilot_agent.chat'] = nil
8345+
local chat = require('copilot_agent.chat')
8346+
chat.ask('hello')
8347+
8348+
agent.open_chat = original_open_chat
8349+
session.with_session = original_with_session
8350+
http.request = original_request
8351+
session.recover_after_service_restart = original_recover
8352+
8353+
-- Should NOT retry — only 1 request sent
8354+
assert_eq(1, request_count)
8355+
assert_eq('session-123', recovered_session_id)
8356+
-- Error entry should be appended (prompt failed)
8357+
local has_error = false
8358+
for _, entry in ipairs(agent.state.entries) do
8359+
if entry.kind == 'error' then
8360+
has_error = true
8361+
end
8362+
end
8363+
assert_true(has_error)
8364+
end)
8365+
83128366
it('opens input immediately when a session is already active', function()
83138367
local input = require('copilot_agent.input')
83148368
local original_open_input_window = input.open_input_window

0 commit comments

Comments
 (0)