diff --git a/local/ai-commit/lua/ai-commit/generator.lua b/local/ai-commit/lua/ai-commit/generator.lua --- a/local/ai-commit/lua/ai-commit/generator.lua +++ b/local/ai-commit/lua/ai-commit/generator.lua @@ -135,6 +135,11 @@ if job then table.insert(http_jobs, job) end end, + on_request_start = function() + if not done and not aborted then + spinner_ui.start_stream_section(spinner) + end + end, on_chunk = function(chunk) if not done and not aborted then spinner_ui.append_stream(spinner, chunk) diff --git a/local/ai-commit/lua/ai-commit/providers.lua b/local/ai-commit/lua/ai-commit/providers.lua --- a/local/ai-commit/lua/ai-commit/providers.lua +++ b/local/ai-commit/lua/ai-commit/providers.lua @@ -125,6 +125,8 @@ if status.phase == 'loading' then status_callback(action and (action .. ' with ' .. status_model) or ('Loading model ' .. status_model)) elseif status.phase == 'loaded' then status_callback('Loaded model ' .. status_model) + elseif status.phase == 'context' then + status_callback('Loading prompt context with ' .. status_model) elseif status.phase == 'thinking' then local suffix = status.tokens_per_second and string.format(' (%.1f t/s)', status.tokens_per_second) or '' status_callback((action or 'Thinking') .. ' with ' .. status_model .. suffix) @@ -134,6 +136,10 @@ status_callback((action or 'Generating response') .. ' with ' .. status_model .. suffix) elseif status.phase == 'error' then status_callback('Provider error from ' .. status_model) end + end + + if request_context and request_context.on_request_start then + request_context.on_request_start(source_id, model) end ai_provider.chat(provider, { diff --git a/local/ai-commit/lua/ai-commit/spinner.lua b/local/ai-commit/lua/ai-commit/spinner.lua --- a/local/ai-commit/lua/ai-commit/spinner.lua +++ b/local/ai-commit/lua/ai-commit/spinner.lua @@ -6,6 +6,72 @@ local M = {} local frames = { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } +local function preview_width(bufnr) + local win = vim.fn.bufwinid(bufnr) + if win ~= -1 then + return math.max(20, vim.api.nvim_win_get_width(win) - 4) + end + return 76 +end + +local function split_long_token(token, width) + local parts = {} + local current = '' + local index = 0 + local length = vim.fn.strchars(token) + + while index < length do + local char = vim.fn.strcharpart(token, index, 1) + if current ~= '' and vim.fn.strdisplaywidth(current .. char) > width then + table.insert(parts, current) + current = char + else + current = current .. char + end + index = index + 1 + end + + if current ~= '' then + table.insert(parts, current) + end + return parts +end + +local function wrap_line(line, width) + if line == '' or vim.fn.strdisplaywidth(line) <= width then + return { line } + end + + local wrapped = {} + local current = '' + + for token in line:gmatch '%S+%s*' do + local candidate = current .. token + if current ~= '' and vim.fn.strdisplaywidth(candidate) > width then + table.insert(wrapped, (current:gsub('%s+$', ''))) + current = token + else + current = candidate + end + + if vim.fn.strdisplaywidth(current) > width then + local parts = split_long_token(current:gsub('%s+$', ''), width) + for index, part in ipairs(parts) do + if index < #parts then + table.insert(wrapped, part) + else + current = part + end + end + end + end + + if current ~= '' then + table.insert(wrapped, (current:gsub('%s+$', ''))) + end + return #wrapped > 0 and wrapped or { line } +end + local function stop_timer_safe(spinner) local timer = spinner and spinner.timer if not timer then @@ -25,13 +91,26 @@ end) end ---@param spinner table +---@param bufnr integer ---@return table -local function preview_virt_lines(spinner) +local function preview_virt_lines(spinner, bufnr) local lines = {} + local width = preview_width(bufnr) for _, line in ipairs(spinner.stream_preview) do - table.insert(lines, { { line, 'Comment' } }) + for _, wrapped in ipairs(wrap_line(line, width)) do + table.insert(lines, wrapped) + end end - return lines + + while #lines > (config.values.preview_lines or 5) do + table.remove(lines, 1) + end + + local virt_lines = {} + for _, line in ipairs(lines) do + table.insert(virt_lines, { { line, 'Comment' } }) + end + return virt_lines end ---@param bufnr integer @@ -77,7 +156,7 @@ right_gravity = false, virt_text = virt_text, virt_text_pos = 'eol', } - local virt_lines = preview_virt_lines(spinner) + local virt_lines = preview_virt_lines(spinner, bufnr) if #virt_lines > 0 then opts.virt_lines = virt_lines opts.virt_lines_above = false @@ -113,8 +192,19 @@ end spinner.stream_preview[#spinner.stream_preview] = (spinner.stream_preview[#spinner.stream_preview] or '') .. chunk end - while #spinner.stream_preview > (config.values.preview_lines or 5) do + while #spinner.stream_preview > 200 do table.remove(spinner.stream_preview, 1) + end +end + +---@param spinner table|nil +function M.start_stream_section(spinner) + if not spinner or #spinner.stream_preview == 0 then + return + end + + if spinner.stream_preview[#spinner.stream_preview] ~= '' then + table.insert(spinner.stream_preview, '') end end diff --git a/local/ai-provider/lua/ai-provider/init.lua b/local/ai-provider/lua/ai-provider/init.lua --- a/local/ai-provider/lua/ai-provider/init.lua +++ b/local/ai-provider/lua/ai-provider/init.lua @@ -44,7 +44,7 @@ ---@field eval_duration? integer Generation duration in nanoseconds, when reported. ---@class AiProviderStatus ---@field provider string Provider name. ----@field phase string Standard phase, for example `loading`, `loaded`, `thinking`, `generating`, `done`, or `error`. +---@field phase string Standard phase, for example `loading`, `loaded`, `context`, `thinking`, `generating`, `done`, or `error`. ---@field message string Human-readable status message. ---@field model? string Model/profile name. ---@field used_model? string Raw provider model name. diff --git a/local/ai-provider/lua/ai-provider/providers/ollama.lua b/local/ai-provider/lua/ai-provider/providers/ollama.lua --- a/local/ai-provider/lua/ai-provider/providers/ollama.lua +++ b/local/ai-provider/lua/ai-provider/providers/ollama.lua @@ -295,14 +295,7 @@ return end if code ~= 0 then - log.error( - 'ollama request process failed code=' - .. tostring(code) - .. ' model=' - .. tostring(raw_model) - .. ' error=' - .. tostring(error_message) - ) + log.error('ollama request process failed code=' .. tostring(code) .. ' model=' .. tostring(raw_model) .. ' error=' .. tostring(error_message)) if request.callback then request.callback(nil, { requested_model = selected_model, @@ -354,12 +347,12 @@ log.error('ollama provider error requested_model=' .. tostring(selected_model) .. ' error=' .. provider_error) if request.callback then request.callback(nil, meta) end - emit_status { - phase = 'error', - message = provider_error, - tokens = metrics.eval_count, - tokens_per_second = tokens_per_second(metrics.eval_count, metrics.eval_duration), - } + emit_status { + phase = 'error', + message = provider_error, + tokens = metrics.eval_count, + tokens_per_second = tokens_per_second(metrics.eval_count, metrics.eval_duration), + } return end if done_reason == 'length' then @@ -467,14 +460,7 @@ end if response.status ~= 200 then local error_message = response.error or response.body or 'ollama preload failed' - log.error( - 'ollama preload failed status=' - .. tostring(response.status) - .. ' model=' - .. tostring(raw_model) - .. ' error=' - .. tostring(error_message) - ) + log.error('ollama preload failed status=' .. tostring(response.status) .. ' model=' .. tostring(raw_model) .. ' error=' .. tostring(error_message)) if request.callback then request.callback(nil, { requested_model = selected_model, @@ -506,8 +492,8 @@ load_duration and string.format('%.0f', load_duration / 1e6) or 'nil' ) ) emit_status { - phase = 'generating', - message = 'Generating response', + phase = 'context', + message = 'Loading prompt context', } job = run_chat() if request.register_http_job then