diff --git a/README.md b/README.md index 8dd1e6f..d55966c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ The main difference between jumpy and other tools, like 99, are: Some of the existing features are to be fleshed out, but the sole purpose is to know exactly what I am letting the LLM write into my code. I have no interest in letting it change everything, and only _then_ can I go back and review every change. That being said, here are the features of jumpy: - **inline prompt**: describe a change without leaving the buffer +- **async & parallel prompts**: requests run in the background — prompt one buffer, move to another and prompt again; each response lands in its own buffer independently (`:JumpyCancel` aborts everything in flight) +- **multi-file prompts**: `@mention` several files in one prompt and review the cross-file hunks via quickfix - **search/replace hunks**: LLM returns only changed sections, not whole files - **in-buffer diff review**: proposed edits shown inline with accept/reject before anything is written - **per-hunk control**: accept, reject, or reprompt individual hunks @@ -107,6 +109,8 @@ user to API billing. Set `claude_code_command` in `setup()` if `claude` is not o In the Jumpy quickfix list, use `a` to accept or `x` to reject the selected hunk. Jumpy advances to the next pending hunk, including hunks in other files. +`:JumpyCancel` cancels every request currently in flight. + ## License MIT diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index d37fc5e..8b81239 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -100,6 +100,9 @@ function M.auto_setup() vim.api.nvim_create_user_command("Jumpy", function() require("jumpy.prompt").open() end, { desc = "Open Jumpy prompt" }) + vim.api.nvim_create_user_command("JumpyCancel", function() + require("jumpy.loading").cancel() + end, { desc = "Cancel all in-flight Jumpy requests" }) end function M._setup_highlights() diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index 3859a5a..eb20f5b 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -161,10 +161,12 @@ function M._build_claude_code_cmd(messages, config) return cmd end -local function make_claude_code_request(messages, callback) +local function make_claude_code_request(messages, callback, opts) + opts = opts or {} local config = get_config() local command = config.claude_code_command or "claude" local loading = require("jumpy.loading") + local requests = require("jumpy.requests") if vim.fn.executable(command) ~= 1 then loading.error("Claude Code CLI not found; install it or set claude_code_command") @@ -172,9 +174,29 @@ local function make_claude_code_request(messages, callback) end local env = claude_code_env() - local cancelled = false + + local request_id = requests.begin({ label = opts.label, targets = opts.targets }) loading.start() + -- Every terminal path must release exactly one spinner refcount and drop + -- the request from the registry. + local finished = false + local function finish() + if finished then + return + end + finished = true + requests.finish(request_id) + loading.stop() + end + + local function fail(msg) + vim.schedule(function() + finish() + loading.error(msg) + end) + end + local auth_jid auth_jid = vim.fn.jobstart({ command, "auth", "status" }, { env = env, @@ -182,17 +204,12 @@ local function make_claude_code_request(messages, callback) stdout_buffered = true, stderr_buffered = true, on_exit = function(_, exit_code) - if not loading.is_active() then - cancelled = true - end - if cancelled then - loading.stop() + if requests.is_cancelled(request_id) then + finish() return end if exit_code ~= 0 then - vim.schedule(function() - loading.error("Claude Code is not authenticated; run 'claude login' and select your Claude plan") - end) + fail("Claude Code is not authenticated; run 'claude login' and select your Claude plan") return end @@ -221,23 +238,18 @@ local function make_claude_code_request(messages, callback) end end, on_exit = function(_, request_exit_code) - if not loading.is_active() then - cancelled = true - end - if cancelled then - loading.stop() + if requests.is_cancelled(request_id) then + finish() return end local stderr_text = table.concat(stderr_chunks, "\n") if request_exit_code ~= 0 then - vim.schedule(function() - local msg = "Claude Code request failed (exit " .. request_exit_code .. ")" - if stderr_text ~= "" then - msg = msg .. " — " .. stderr_text - end - loading.error(msg) - end) + local msg = "Claude Code request failed (exit " .. request_exit_code .. ")" + if stderr_text ~= "" then + msg = msg .. " — " .. stderr_text + end + fail(msg) return end @@ -245,38 +257,39 @@ local function make_claude_code_request(messages, callback) local ok, parsed = pcall(vim.fn.json_decode, raw) local content = ok and extract_content_claude_code(parsed) or nil if type(content) ~= "string" or content == "" then - vim.schedule(function() - loading.error("Claude Code returned an invalid response (update the CLI and try again)") - end) + fail("Claude Code returned an invalid response (update the CLI and try again)") return end - loading.stop() + finish() content = content:gsub("^```[%w]*\n", ""):gsub("\n```%s*$", "") callback(content) end, }) if request_jid <= 0 then + finish() loading.error("failed to start Claude Code CLI") else - loading.set_job(request_jid) + requests.set_job(request_id, request_jid) end end, }) if auth_jid <= 0 then + finish() loading.error("failed to start Claude Code CLI") else - loading.set_job(auth_jid) + requests.set_job(request_id, auth_jid) end end -local function make_request(messages, callback) +local function make_request(messages, callback, opts) + opts = opts or {} local config = get_config() if is_claude_code() then - make_claude_code_request(messages, callback) + make_claude_code_request(messages, callback, opts) return end @@ -322,9 +335,30 @@ local function make_request(messages, callback) local response_chunks = {} local stderr_chunks = {} local loading = require("jumpy.loading") - local cancelled = false + local requests = require("jumpy.requests") + + local request_id = requests.begin({ label = opts.label, targets = opts.targets }) loading.start() + -- Every terminal path must release exactly one spinner refcount and drop + -- the request from the registry. + local finished = false + local function finish() + if finished then + return + end + finished = true + requests.finish(request_id) + loading.stop() + end + + local function fail(msg) + vim.schedule(function() + finish() + loading.error(msg) + end) + end + local jid jid = vim.fn.jobstart(cmd, { stdout_buffered = true, @@ -346,25 +380,19 @@ local function make_request(messages, callback) end end, on_exit = function(_, exit_code) - if not loading.is_active() then - cancelled = true - end - - if cancelled then - loading.stop() + if requests.is_cancelled(request_id) then + finish() return end local stderr_text = table.concat(stderr_chunks, "\n") if exit_code ~= 0 then - vim.schedule(function() - local msg = "request failed (curl exit " .. exit_code .. ")" - if stderr_text ~= "" then - msg = msg .. " — " .. stderr_text - end - loading.error(msg) - end) + local msg = "request failed (curl exit " .. exit_code .. ")" + if stderr_text ~= "" then + msg = msg .. " — " .. stderr_text + end + fail(msg) return end @@ -374,17 +402,15 @@ local function make_request(messages, callback) if not ok then vim.schedule(function() local preview = vim.fn.strcharpart(vim.fn.substitute(raw, "\n", " ", "g"), 0, 120) - loading.error("response was not JSON: " .. preview) + fail("response was not JSON: " .. preview) end) return end if parsed.error then - vim.schedule(function() - local err = parsed.error - local msg = type(err) == "table" and (err.message or vim.inspect(err)) or tostring(err) - loading.error("API error: " .. msg) - end) + local err = parsed.error + local msg = type(err) == "table" and (err.message or vim.inspect(err)) or tostring(err) + fail("API error: " .. msg) return end @@ -396,13 +422,11 @@ local function make_request(messages, callback) end if not content then - vim.schedule(function() - loading.error("empty response from LLM (check model / response shape)") - end) + fail("empty response from LLM (check model / response shape)") return end - loading.stop() + finish() content = content:gsub("^```[%w]*\n", ""):gsub("\n```%s*$", "") callback(content) @@ -410,18 +434,19 @@ local function make_request(messages, callback) }) if jid <= 0 then + finish() loading.error("failed to start curl — is it installed?") else - loading.set_job(jid) + requests.set_job(request_id, jid) end end -function M.request(context, callback) +function M.request(context, callback, opts) local messages = build_messages(context) - make_request(messages, callback) + make_request(messages, callback, opts) end -function M.reprompt(context, callback) +function M.reprompt(context, callback, opts) local messages = build_reprompt_messages(context) make_request(messages, function(content) local patch = require("jumpy.patch") @@ -432,7 +457,7 @@ function M.reprompt(context, callback) end) end callback(new_lines) - end) + end, opts) end return M diff --git a/lua/jumpy/loading.lua b/lua/jumpy/loading.lua index 7f3f9f7..2c5181f 100644 --- a/lua/jumpy/loading.lua +++ b/lua/jumpy/loading.lua @@ -2,23 +2,33 @@ local M = {} local FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } +-- Refcounted spinner: each in-flight request calls start()/stop() once, so +-- parallel requests share one spinner that reports how many are running. +local count = 0 local timer local dismiss_timer -local active = false local frame_idx = 1 local start_time = 0 -local win -local buf -local current_job_id = nil +local spin_win, spin_buf +local err_win, err_buf -local function close_ui() +local function close_float(win, buf) if win and vim.api.nvim_win_is_valid(win) then pcall(vim.api.nvim_win_close, win, true) end if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end - win, buf = nil, nil +end + +local function close_spinner() + close_float(spin_win, spin_buf) + spin_win, spin_buf = nil, nil +end + +local function close_error() + close_float(err_win, err_buf) + err_win, err_buf = nil, nil end local function cancel_dismiss() @@ -29,22 +39,27 @@ local function cancel_dismiss() end end -local function open_float(text) - close_ui() +local function float_width(text) + local w = math.min(vim.api.nvim_strwidth(text) + 4, vim.o.columns - 4) + return math.max(w, 20) +end - buf = vim.api.nvim_create_buf(false, true) - vim.bo[buf].buftype = "nofile" +local function spinner_row() + return math.max(0, vim.o.lines - vim.o.cmdheight - 4) +end - local w = math.min(vim.api.nvim_strwidth(text) + 4, vim.o.columns - 4) - w = math.max(w, 20) +local function open_float(text, row) + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + local w = float_width(text) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text }) - win = vim.api.nvim_open_win(buf, false, { + local win = vim.api.nvim_open_win(buf, false, { relative = "editor", width = w, height = 1, - row = math.max(0, vim.o.lines - vim.o.cmdheight - 4), + row = row, col = math.max(0, math.floor((vim.o.columns - w) / 2)), style = "minimal", border = "rounded", @@ -54,84 +69,102 @@ local function open_float(text) title_pos = "center", }) vim.wo[win].wrap = false + + return win, buf end -local function update_text(text) - if not buf or not vim.api.nvim_buf_is_valid(buf) then - return - end - local w = math.min(vim.api.nvim_strwidth(text) + 4, vim.o.columns - 4) - w = math.max(w, 20) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text }) - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_set_width(win, w) +local function spinner_text() + local secs = math.floor((vim.loop.now() - start_time) / 1000) + local elapsed = secs >= 1 and string.format(" %ds", secs) or "" + local frame = FRAMES[frame_idx] + if count > 1 then + return string.format(" %s waiting for %d requests…%s ", frame, count, elapsed) end + return string.format(" %s waiting for model…%s ", frame, elapsed) end -local function format_elapsed() - local secs = math.floor((vim.loop.now() - start_time) / 1000) - if secs < 1 then - return "" +local function update_spinner() + if not spin_buf or not vim.api.nvim_buf_is_valid(spin_buf) then + return + end + local text = spinner_text() + vim.api.nvim_buf_set_lines(spin_buf, 0, -1, false, { text }) + if spin_win and vim.api.nvim_win_is_valid(spin_win) then + vim.api.nvim_win_set_width(spin_win, float_width(text)) end - return string.format(" %ds", secs) end function M.start() - M.stop() - active = true + count = count + 1 + + if count > 1 then + update_spinner() + return + end + frame_idx = 1 start_time = vim.loop.now() - - local text = string.format(" %s waiting for model… ", FRAMES[1]) - open_float(text) + close_spinner() + spin_win, spin_buf = open_float(spinner_text(), spinner_row()) timer = vim.loop.new_timer() timer:start( 80, 80, vim.schedule_wrap(function() - if not active then + if count == 0 then return end frame_idx = frame_idx % #FRAMES + 1 - local line = string.format(" %s waiting for model…%s ", FRAMES[frame_idx], format_elapsed()) - update_text(line) + update_spinner() end) ) end function M.stop() - active = false - cancel_dismiss() + if count == 0 then + return + end + count = count - 1 + + if count > 0 then + update_spinner() + return + end + if timer then timer:stop() timer:close() timer = nil end - close_ui() - current_job_id = nil + close_spinner() end function M.cancel() - if current_job_id and vim.fn.jobwait({ current_job_id }, 0)[1] == -1 then - vim.fn.jobstop(current_job_id) + local requests = require("jumpy.requests") + local n = requests.cancel_all() + if n > 0 then + vim.notify(string.format("jumpy: cancelled %d request(s)", n), vim.log.levels.INFO) + else + vim.notify("jumpy: no requests in flight", vim.log.levels.INFO) end - M.stop() -end - -function M.set_job(jid) - current_job_id = jid end function M.is_active() - return active + return count > 0 end +-- Transient error toast; independent of the spinner so one failed request +-- doesn't hide the progress of others still running. function M.error(msg) - M.stop() + cancel_dismiss() + close_error() - local text = string.format(" ✗ %s ", msg) - open_float(text) + local row = spinner_row() + if count > 0 then + row = math.max(0, row - 3) + end + err_win, err_buf = open_float(string.format(" ✗ %s ", msg), row) dismiss_timer = vim.loop.new_timer() dismiss_timer:start( @@ -139,7 +172,7 @@ function M.error(msg) 0, vim.schedule_wrap(function() cancel_dismiss() - close_ui() + close_error() end) ) end diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index 747ec7e..9cc18d4 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -222,6 +222,7 @@ function M.reprompt() state.source_buf = vim.api.nvim_get_current_buf() state.reprompt_hunk_idx = hunk_idx + state.visual_selection = nil state.paths = path.list_files() state.buf, state.win = create_float(" jumpy: reprompt this hunk ") @@ -261,8 +262,9 @@ function M._submit() end local source_buf = state.source_buf + local visual_selection = state.visual_selection - local source_lines = state.visual_selection and vim.split(state.visual_selection.text, "\n", { plain = true }) + local source_lines = visual_selection and vim.split(visual_selection.text, "\n", { plain = true }) or vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) local source_name = vim.api.nvim_buf_get_name(source_buf) @@ -291,6 +293,23 @@ function M._submit() local is_multi_file = #tagged_files > 1 local llm = require("jumpy.llm") + local requests = require("jumpy.requests") + + -- One in-flight request per file: a second request against the same file + -- would clobber the first one's pending hunks when it lands. + local targets = {} + for _, file in ipairs(tagged_files) do + local key = requests.target_key(file.bufnr, file.abs_path) + if key then + if requests.is_target_busy(key) then + vim.notify("jumpy: a request is already running for " .. file.path, vim.log.levels.WARN) + return + end + targets[key] = true + end + end + + local request_opts = { targets = targets, label = source_rel } local function send_request(symbols) symbols = symbols or "" @@ -328,7 +347,7 @@ function M._submit() vim.notify("jumpy: hunk updated", vim.log.levels.INFO) end) - end) + end, request_opts) elseif is_multi_file then local context = { tagged_files = tagged_files, @@ -344,9 +363,16 @@ function M._submit() local render = require("jumpy.render") local patch = require("jumpy.patch") + -- The buffer may have been edited while the request was in flight + -- (e.g. reviewing hunks from a parallel prompt), so apply against + -- the freshest lines we can get, not the snapshot from submit time. local files_by_path = {} for _, file in ipairs(tagged_files) do - files_by_path[file.path] = file.lines + local file_lines = file.lines + if file.bufnr and vim.api.nvim_buf_is_valid(file.bufnr) then + file_lines = vim.api.nvim_buf_get_lines(file.bufnr, 0, -1, false) + end + files_by_path[file.path] = file_lines end local results, total_unmatched = patch.apply_by_file(files_by_path, response_text, source_rel) @@ -357,17 +383,20 @@ function M._submit() local tagged_by_path = index_tagged_files(tagged_files) local total_hunks = 0 + local target_bufs = {} for file_path, result in pairs(results) do local file = tagged_by_path[file_path] if file then local bufnr = buffer_for_tagged_file(file) if bufnr then - local hunks = diff.compute(file.lines, result.lines) + local original = files_by_path[file_path] + local hunks = diff.compute(original, result.lines) if #hunks > 0 then require("jumpy.navigate")._clear_undo_history(bufnr) - render.show(bufnr, hunks, file.lines, result.lines) + render.show(bufnr, hunks, original, result.lines) total_hunks = total_hunks + #hunks + target_bufs[bufnr] = true end else vim.notify("jumpy: could not open " .. file_path .. ", skipping", vim.log.levels.WARN) @@ -380,15 +409,21 @@ function M._submit() return end + local nav = require("jumpy.navigate") + nav._refresh_quickfix() + vim.notify( string.format("jumpy: %d hunk(s) proposed across %d file(s)", total_hunks, vim.tbl_count(results)), vim.log.levels.INFO ) - local nav = require("jumpy.navigate") - nav.first_hunk_any_buf() + -- Only steal the cursor if the user is still in one of the target + -- buffers; otherwise they may be busy with another prompt/review. + if target_bufs[vim.api.nvim_get_current_buf()] then + nav.first_hunk_any_buf() + end end) - end) + end, request_opts) else local context = { file_contents = table.concat(source_lines, "\n"), @@ -408,15 +443,20 @@ function M._submit() local render = require("jumpy.render") local patch = require("jumpy.patch") - local proposed_lines, unmatched = patch.apply(source_lines, response_text) + -- Full-buffer prompts re-read the buffer so parallel work done in + -- the meantime is respected; visual-selection prompts keep the + -- snapshot since they target a fixed region. + local original = visual_selection and source_lines or vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) + + local proposed_lines, unmatched = patch.apply(original, response_text) if unmatched > 0 then vim.notify(string.format("jumpy: %d block(s) could not be matched", unmatched), vim.log.levels.WARN) end - local hunks = diff.compute(source_lines, proposed_lines) - if state.visual_selection then - local offset = state.visual_selection.start_line - 1 + local hunks = diff.compute(original, proposed_lines) + if visual_selection then + local offset = visual_selection.start_line - 1 for _, hunk in ipairs(hunks) do hunk.old_start = hunk.old_start + offset @@ -429,14 +469,18 @@ function M._submit() end require("jumpy.navigate")._clear_undo_history(source_buf) - render.show(source_buf, hunks, source_lines, proposed_lines) - - vim.notify(string.format("jumpy: %d hunk(s) proposed", #hunks), vim.log.levels.INFO) + render.show(source_buf, hunks, original, proposed_lines) local nav = require("jumpy.navigate") - nav.next_hunk() + nav._refresh_quickfix() + + vim.notify(string.format("jumpy: %d hunk(s) proposed in %s", #hunks, source_rel), vim.log.levels.INFO) + + if vim.api.nvim_get_current_buf() == source_buf then + nav.next_hunk() + end end) - end) + end, request_opts) end end diff --git a/lua/jumpy/requests.lua b/lua/jumpy/requests.lua new file mode 100644 index 0000000..6ffa46b --- /dev/null +++ b/lua/jumpy/requests.lua @@ -0,0 +1,98 @@ +-- Registry of in-flight LLM requests. Each request owns its own id, job and +-- target files, so multiple prompts can run in parallel without sharing the +-- single global "loading" state that made jumpy single-flight. +local M = {} + +local next_id = 0 +local inflight = {} + +--- Register a new request. +--- @param opts table|nil { label = string, targets = { [key] = true } } +--- @return number request id +function M.begin(opts) + opts = opts or {} + next_id = next_id + 1 + inflight[next_id] = { + label = opts.label, + targets = opts.targets or {}, + job_id = nil, + cancelled = false, + } + return next_id +end + +function M.set_job(id, job_id) + local req = inflight[id] + if req then + req.job_id = job_id + end +end + +function M.finish(id) + inflight[id] = nil +end + +--- Unknown/finished ids count as cancelled so late callbacks are discarded. +function M.is_cancelled(id) + local req = inflight[id] + return req == nil or req.cancelled +end + +--- A target key is a normalized absolute file path, or "buf:N" for +--- unnamed buffers. +function M.target_key(bufnr, abs_path) + if abs_path and abs_path ~= "" then + return abs_path + end + if bufnr then + local name = vim.api.nvim_buf_get_name(bufnr) + if name ~= "" then + return require("jumpy.path").normalize_abs(name) + end + return "buf:" .. bufnr + end + return nil +end + +function M.is_target_busy(target) + for _, req in pairs(inflight) do + if not req.cancelled and req.targets[target] then + return true + end + end + return false +end + +function M.count() + local n = 0 + for _, req in pairs(inflight) do + if not req.cancelled then + n = n + 1 + end + end + return n +end + +--- Cancel every in-flight request. Jobs are stopped; each request is cleaned +--- up by its own on_exit handler. Returns how many requests were cancelled. +function M.cancel_all() + local n = 0 + for _, req in pairs(inflight) do + if not req.cancelled then + req.cancelled = true + n = n + 1 + if req.job_id and vim and vim.fn and vim.fn.jobstop then + pcall(vim.fn.jobstop, req.job_id) + end + end + end + return n +end + +--- Test helper: drop all state. +function M._reset() + next_id = 0 + inflight = {} +end + +return M diff --git a/tests/requests_spec.lua b/tests/requests_spec.lua new file mode 100644 index 0000000..a66f278 --- /dev/null +++ b/tests/requests_spec.lua @@ -0,0 +1,68 @@ +-- Add lua/ to package.path so require works outside Neovim +package.path = package.path .. ";lua/?.lua;lua/?/init.lua" + +local requests = require("jumpy.requests") + +describe("requests registry", function() + before_each(function() + requests._reset() + end) + + it("assigns unique ids and counts in-flight requests", function() + local a = requests.begin() + local b = requests.begin() + + assert.are_not.equal(a, b) + assert.are.equal(2, requests.count()) + + requests.finish(a) + assert.are.equal(1, requests.count()) + + requests.finish(b) + assert.are.equal(0, requests.count()) + end) + + it("treats unknown or finished ids as cancelled", function() + assert.is_true(requests.is_cancelled(999)) + + local id = requests.begin() + assert.is_false(requests.is_cancelled(id)) + + requests.finish(id) + assert.is_true(requests.is_cancelled(id)) + end) + + it("tracks busy targets per request", function() + local id = requests.begin({ targets = { ["/tmp/a.lua"] = true, ["/tmp/b.lua"] = true } }) + + assert.is_true(requests.is_target_busy("/tmp/a.lua")) + assert.is_true(requests.is_target_busy("/tmp/b.lua")) + assert.is_false(requests.is_target_busy("/tmp/c.lua")) + + requests.finish(id) + assert.is_false(requests.is_target_busy("/tmp/a.lua")) + end) + + it("allows parallel requests on different targets", function() + requests.begin({ targets = { ["/tmp/a.lua"] = true } }) + requests.begin({ targets = { ["/tmp/b.lua"] = true } }) + + assert.are.equal(2, requests.count()) + assert.is_true(requests.is_target_busy("/tmp/a.lua")) + assert.is_true(requests.is_target_busy("/tmp/b.lua")) + end) + + it("cancel_all marks every request cancelled and frees targets", function() + local a = requests.begin({ targets = { ["/tmp/a.lua"] = true } }) + local b = requests.begin({ targets = { ["/tmp/b.lua"] = true } }) + + assert.are.equal(2, requests.cancel_all()) + + assert.is_true(requests.is_cancelled(a)) + assert.is_true(requests.is_cancelled(b)) + assert.are.equal(0, requests.count()) + assert.is_false(requests.is_target_busy("/tmp/a.lua")) + + assert.are.equal(0, requests.cancel_all()) + end) +end)