From 8588395763eb783249a9421d25a35b7ab8cfd0e3 Mon Sep 17 00:00:00 2001 From: assagman Date: Tue, 20 Jan 2026 12:13:04 +0300 Subject: [PATCH 1/2] refactor(ghost): extract helper functions and use dispatch tables Reduce cyclomatic complexity across ghost modules by: - Extracting small, focused helper functions from large monolithic functions - Using dispatch tables for update type handling in receiver/response/test - Breaking down context capture, health checks, and status building - Removing luacheck ignore annotations no longer needed Signed-off-by: assagman --- lua/ghost/context.lua | 131 ++++++++++--------- lua/ghost/health.lua | 71 ++++++----- lua/ghost/receiver.lua | 272 ++++++++++++++++++++------------------- lua/ghost/response.lua | 281 +++++++++++++++++++++++------------------ lua/ghost/status.lua | 129 +++++++++++-------- lua/ghost/test.lua | 87 ++++++++----- 6 files changed, 542 insertions(+), 429 deletions(-) diff --git a/lua/ghost/context.lua b/lua/ghost/context.lua index 2171e9c..1cdf6c9 100644 --- a/lua/ghost/context.lua +++ b/lua/ghost/context.lua @@ -146,103 +146,114 @@ end --- @param bufnr number|nil Buffer number (defaults to current buffer) --- @param include_selection boolean|nil Whether to capture visual selection (default false) --- @return GhostContext Captured context -function M.capture(bufnr, include_selection) -- luacheck: ignore 561 - -- Safely get buffer number with fallback - local ok, bufnr_result = pcall(function() +local function create_error_context() + return { + file_path = nil, + file_name = nil, + file_extension = nil, + filetype = "", + language = nil, + content = "", + cursor = { line = 1, col = 1 }, + bufnr = 0, + is_special = true, + is_unnamed = true, + selection = nil, + selection_range = nil, + has_selection = false, + } +end + +local function safe_get_bufnr(bufnr) + local ok, result = pcall(function() return bufnr or vim.api.nvim_get_current_buf() end) - if not ok or not bufnr_result then + if not ok or not result then vim.notify("Ghost: Failed to get current buffer", vim.log.levels.ERROR) - -- Return minimal context to avoid crashes - return { - file_path = nil, - file_name = nil, - file_extension = nil, - filetype = "", - language = nil, - content = "", - cursor = { line = 1, col = 1 }, - bufnr = 0, - is_special = true, - is_unnamed = true, - selection = nil, - selection_range = nil, - has_selection = false, - } + return nil end - bufnr = bufnr_result + return result +end - -- Get buffer name (file path) - safely +local function get_buffer_info(bufnr) local buf_name_ok, buf_name_result = pcall(vim.api.nvim_buf_get_name, bufnr) local buf_name = buf_name_ok and buf_name_result or "" local has_name = buf_name ~= "" - -- Determine file path and name local file_path = has_name and buf_name or nil - local file_name = nil - if file_path then - file_name = vim.fn.fnamemodify(file_path, ":t") - end - - -- Get file extension + local file_name = file_path and vim.fn.fnamemodify(file_path, ":t") or nil local extension = get_extension(file_path) - -- Get filetype - safely local ft_ok, ft_result = pcall(vim.api.nvim_get_option_value, "filetype", { buf = bufnr }) local filetype = ft_ok and ft_result or "" - -- Detect language local language = detect_language(filetype, extension) - -- Get buffer content - safely handle empty or invalid buffers local lines_ok, lines_result = pcall(vim.api.nvim_buf_get_lines, bufnr, 0, -1, false) local lines = lines_ok and lines_result or {} local content = table.concat(lines, "\n") - -- Get cursor position (1-based for both line and col) - safely + return { + file_path = file_path, + file_name = file_name, + extension = extension, + filetype = filetype, + language = language, + content = content, + has_name = has_name, + } +end + +local function get_cursor_info() local cursor_ok, cursor_result = pcall(vim.api.nvim_win_get_cursor, 0) local cursor = cursor_ok and cursor_result or { 1, 0 } - local cursor_pos = { + return { line = cursor[1] or 1, - col = (cursor[2] or 0) + 1, -- nvim_win_get_cursor returns 0-based col + col = (cursor[2] or 0) + 1, } +end - -- Check if special buffer - local is_special = is_special_buffer(bufnr) - - -- Capture visual selection if requested - safely - local selection_text = nil - local selection_range = nil - if include_selection then - local sel_ok, sel_text, sel_range = pcall(function() - return get_visual_selection(bufnr) - end) - if sel_ok then - selection_text = sel_text - selection_range = sel_range - end +local function get_selection_info(bufnr, include_selection) + if not include_selection then + return nil, nil + end + local sel_ok, sel_text, sel_range = pcall(function() + return get_visual_selection(bufnr) + end) + if sel_ok then + return sel_text, sel_range + end + return nil, nil +end + +function M.capture(bufnr, include_selection) + local buf = safe_get_bufnr(bufnr) + if not buf then + return create_error_context() end + local info = get_buffer_info(buf) + local cursor_pos = get_cursor_info() + local selection_text, selection_range = get_selection_info(buf, include_selection) + --- @type GhostContext local context = { - file_path = file_path, - file_name = file_name, - file_extension = extension, - filetype = filetype or "", - language = language, - content = content, + file_path = info.file_path, + file_name = info.file_name, + file_extension = info.extension, + filetype = info.filetype or "", + language = info.language, + content = info.content, cursor = cursor_pos, - bufnr = bufnr, - is_special = is_special, - is_unnamed = not has_name, + bufnr = buf, + is_special = is_special_buffer(buf), + is_unnamed = not info.has_name, selection = selection_text, selection_range = selection_range, has_selection = selection_text ~= nil, } - -- Store in module state M.current = context - return context end diff --git a/lua/ghost/health.lua b/lua/ghost/health.lua index f98746c..cdba91e 100644 --- a/lua/ghost/health.lua +++ b/lua/ghost/health.lua @@ -28,30 +28,28 @@ local function check_executable(name, desc, required) end end -function M.check() -- luacheck: ignore 561 - start("ghost.nvim") - - -- Check Neovim version +local function check_nvim_version() local nvim_version = vim.version() if nvim_version.major < 0 or (nvim_version.major == 0 and nvim_version.minor < 10) then error("Neovim 0.10+ required, you're using " .. tostring(nvim_version)) else ok("Neovim " .. tostring(nvim_version)) end +end - -- Check Ghost configuration +local function check_config() local config_ok, config = pcall(require, "ghost.config") if not config_ok then error("Failed to load ghost.config module") - return + return nil end local backend = config.options and config.options.backend or "opencode" ok("Backend: " .. backend) + return backend, config +end - -- Check backend executables - start("ghost.nvim: Backend Dependencies") - +local function check_backend_deps(backend, config) if backend == "opencode" then local acp_cmd = config.options and config.options.acp_command or "opencode" if type(acp_cmd) == "table" then @@ -62,13 +60,10 @@ function M.check() -- luacheck: ignore 561 check_executable("bunx", "bunx (required for codex backend)", true) check_executable("bun", "bun runtime", false) end +end - -- Check optional dependencies - start("ghost.nvim: Optional Dependencies") - - -- Git (for project-based session persistence) +local function check_optional_deps() if check_executable("git", "git (for project session persistence)", false) then - -- Check if we're in a git repo local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null") if handle then local result = handle:read("*a") @@ -81,33 +76,51 @@ function M.check() -- luacheck: ignore 561 end end - -- Snacks.nvim (for enhanced picker UI) local snacks_ok, snacks = pcall(require, "snacks") if snacks_ok and snacks.picker then ok("Snacks.nvim available (enhanced session picker)") else warn("Snacks.nvim not available (using vim.ui.select fallback for :GhostList)") end +end - -- Check ACP connection status - start("ghost.nvim: Connection Status") - +local function check_acp_connection() local acp_ok, acp = pcall(require, "ghost.acp") - if acp_ok then - if acp.is_connected and acp.is_connected() then - ok("ACP connected") - if acp.get_session_id then - local session_id = acp.get_session_id() - if session_id then - ok("Session ID: " .. session_id) - end + if not acp_ok then + warn("Could not check ACP status") + return + end + + if acp.is_connected and acp.is_connected() then + ok("ACP connected") + if acp.get_session_id then + local session_id = acp.get_session_id() + if session_id then + ok("Session ID: " .. session_id) end - else - warn("ACP not connected (will connect on first prompt)") end else - warn("Could not check ACP status") + warn("ACP not connected (will connect on first prompt)") end end +function M.check() + start("ghost.nvim") + check_nvim_version() + + local backend, config = check_config() + if not backend then + return + end + + start("ghost.nvim: Backend Dependencies") + check_backend_deps(backend, config) + + start("ghost.nvim: Optional Dependencies") + check_optional_deps() + + start("ghost.nvim: Connection Status") + check_acp_connection() +end + return M diff --git a/lua/ghost/receiver.lua b/lua/ghost/receiver.lua index 9e591f4..67c8a70 100644 --- a/lua/ghost/receiver.lua +++ b/lua/ghost/receiver.lua @@ -87,168 +87,174 @@ end --- Process an ACP session/update notification --- @param update table The update notification params --- @param request_id string|nil The request ID -local function process_session_update(update, request_id) -- luacheck: ignore 561 - state.current_request_id = request_id - - -- Extract ghost_session_id from update (US-009) - local ghost_session_id = update.__ghost_session_id - - -- Get the inner update object (ACP format: params.update.sessionUpdate) - local inner = update.update or update - local update_type = inner.sessionUpdate - - -- Handle message_delta (streaming text) - if update_type == "message_delta" or update_type == "message" then - local content = inner.content - if content and type(content) == "table" then - for _, item in ipairs(content) do - if item.type == "text" then - process_text_content(item, request_id, ghost_session_id) - end +local function handle_message_delta(inner, request_id, ghost_session_id) + local content = inner.content + if content and type(content) == "table" then + for _, item in ipairs(content) do + if item.type == "text" then + process_text_content(item, request_id, ghost_session_id) end end - return end +end - -- Handle agent_message_chunk (OpenCode's streaming message text) - if update_type == "agent_message_chunk" then - local chunk_text = nil - - if inner.content and type(inner.content) == "table" then - if inner.content[1] then - for _, item in ipairs(inner.content) do - if type(item) == "table" then - if item.type == "text" and item.text then - chunk_text = (chunk_text or "") .. item.text - elseif item.text then - chunk_text = (chunk_text or "") .. item.text - elseif item.content and type(item.content) == "string" then - chunk_text = (chunk_text or "") .. item.content - end - elseif type(item) == "string" then - chunk_text = (chunk_text or "") .. item - end - end - else - chunk_text = inner.content.text or inner.content.content or inner.content.chunk - end +local function extract_text_from_item(item) + if type(item) == "table" then + if item.type == "text" and item.text then + return item.text + elseif item.text then + return item.text + elseif item.content and type(item.content) == "string" then + return item.content end + elseif type(item) == "string" then + return item + end + return nil +end - if not chunk_text then - chunk_text = inner.chunk or inner.text or inner.delta +local function extract_text_from_content_array(content) + local chunk_text = nil + for _, item in ipairs(content) do + local text = extract_text_from_item(item) + if text then + chunk_text = (chunk_text or "") .. text end + end + return chunk_text +end - if chunk_text and type(chunk_text) == "string" and #chunk_text > 0 then - process_text_content({ text = chunk_text }, request_id, ghost_session_id) +local function extract_chunk_text(inner) + if inner.content and type(inner.content) == "table" then + if inner.content[1] then + return extract_text_from_content_array(inner.content) + else + return inner.content.text or inner.content.content or inner.content.chunk end - return end + return inner.chunk or inner.text or inner.delta +end + +local function handle_agent_message_chunk(inner, request_id, ghost_session_id) + local chunk_text = extract_chunk_text(inner) + if chunk_text and type(chunk_text) == "string" and #chunk_text > 0 then + process_text_content({ text = chunk_text }, request_id, ghost_session_id) + end +end - -- Handle agent_thought_chunk (OpenCode's streaming thought/reasoning) - if update_type == "agent_thought_chunk" then - local chunk_text = inner.chunk or inner.text or inner.delta - - -- Handle content as single object: { type: "text", text: "..." } - if not chunk_text and inner.content and type(inner.content) == "table" then - if inner.content.type == "text" and inner.content.text then - -- Single content object (ACP format) - chunk_text = inner.content.text - elseif inner.content[1] then - -- Array of content items (fallback) - for _, item in ipairs(inner.content) do - if item.type == "text" and item.text then - chunk_text = item.text - break - end +local function handle_agent_thought_chunk(inner, request_id, ghost_session_id) + local chunk_text = inner.chunk or inner.text or inner.delta + + if not chunk_text and inner.content and type(inner.content) == "table" then + if inner.content.type == "text" and inner.content.text then + chunk_text = inner.content.text + elseif inner.content[1] then + for _, item in ipairs(inner.content) do + if item.type == "text" and item.text then + chunk_text = item.text + break end end end + end - if chunk_text and type(chunk_text) == "string" and #chunk_text > 0 then - process_text_content({ text = chunk_text }, request_id, ghost_session_id) - end - return + if chunk_text and type(chunk_text) == "string" and #chunk_text > 0 then + process_text_content({ text = chunk_text }, request_id, ghost_session_id) end +end - -- Handle agent_thought / agent_message (complete versions) - if update_type == "agent_thought" or update_type == "agent_message" then - local text = inner.thought or inner.message or inner.text or inner.content - if text and type(text) == "string" then - process_text_content({ text = text }, request_id, ghost_session_id) - end - return +local function handle_agent_thought(inner, request_id, ghost_session_id) + local text = inner.thought or inner.message or inner.text or inner.content + if text and type(text) == "string" then + process_text_content({ text = text }, request_id, ghost_session_id) end +end - -- Handle tool_call (notify about tool usage for status display) - if update_type == "tool_call" then - local tool_id = inner.toolCallId or "" - local tool_name = inner.title or "unknown" - local tool_status = inner.status or "pending" +local function get_target_session_id(ghost_session_id) + if ghost_session_id then + return ghost_session_id + end + local active_session = session.get_active_session() + if active_session then + return active_session.id + end + return nil +end - -- Write tool call to transcript for the correct session (US-009) - local target_session_id = ghost_session_id - if not target_session_id then - local active_session = session.get_active_session() - if active_session then - target_session_id = active_session.id - end - end +local function handle_tool_call(inner, request_id, ghost_session_id) + local tool_id = inner.toolCallId or "" + local tool_name = inner.title or "unknown" + local tool_status = inner.status or "pending" - if target_session_id then - transcript.write_tool_call(target_session_id, tool_name, tool_id, tool_status) - end + local target_session_id = get_target_session_id(ghost_session_id) + if target_session_id then + transcript.write_tool_call(target_session_id, tool_name, tool_id, tool_status) + end - if state.on_update then - state.on_update({ - type = "tool_call", - request_id = request_id, - tool_name = tool_name, - tool_id = tool_id, - status = tool_status, - ghost_session_id = ghost_session_id, - }) - end - return + if state.on_update then + state.on_update({ + type = "tool_call", + request_id = request_id, + tool_name = tool_name, + tool_id = tool_id, + status = tool_status, + ghost_session_id = ghost_session_id, + }) end +end - -- Handle tool_call_update (tool progress/completion) - if update_type == "tool_call_update" then - local tool_id = inner.toolCallId or "" - local tool_status = inner.status - local tool_name = inner.title +local function handle_tool_call_update(inner, request_id, ghost_session_id) + local tool_id = inner.toolCallId or "" + local tool_status = inner.status + local tool_name = inner.title - -- Write tool call update to transcript for the correct session (US-009) - local target_session_id = ghost_session_id - if not target_session_id then - local active_session = session.get_active_session() - if active_session then - target_session_id = active_session.id - end - end + local target_session_id = get_target_session_id(ghost_session_id) + if target_session_id and tool_name and tool_status then + transcript.write_tool_call(target_session_id, tool_name, tool_id, tool_status) + end - if target_session_id and tool_name and tool_status then - transcript.write_tool_call(target_session_id, tool_name, tool_id, tool_status) - end + if state.on_update then + state.on_update({ + type = "tool_call_update", + request_id = request_id, + tool_id = tool_id, + status = tool_status, + tool_name = tool_name, + ghost_session_id = ghost_session_id, + }) + end +end - if state.on_update then - state.on_update({ - type = "tool_call_update", - request_id = request_id, - tool_id = tool_id, - status = tool_status, - tool_name = tool_name, - ghost_session_id = ghost_session_id, - }) - end - return +local function handle_unknown_update(inner, request_id, ghost_session_id) + local text = inner.text or inner.chunk or inner.content + if text and type(text) == "string" and #text > 0 then + process_text_content({ text = text }, request_id, ghost_session_id) end +end - -- Fallback: Try to extract text from unknown update types - if update_type then - local text = inner.text or inner.chunk or inner.content - if text and type(text) == "string" and #text > 0 then - process_text_content({ text = text }, request_id, ghost_session_id) - end +local update_handlers = { + message_delta = handle_message_delta, + message = handle_message_delta, + agent_message_chunk = handle_agent_message_chunk, + agent_thought_chunk = handle_agent_thought_chunk, + agent_thought = handle_agent_thought, + agent_message = handle_agent_thought, + tool_call = handle_tool_call, + tool_call_update = handle_tool_call_update, +} + +local function process_session_update(update, request_id) + state.current_request_id = request_id + + local ghost_session_id = update.__ghost_session_id + local inner = update.update or update + local update_type = inner.sessionUpdate + + local handler = update_handlers[update_type] + if handler then + handler(inner, request_id, ghost_session_id) + elseif update_type then + handle_unknown_update(inner, request_id, ghost_session_id) end end diff --git a/lua/ghost/response.lua b/lua/ghost/response.lua index aefd8c9..d88ec6b 100644 --- a/lua/ghost/response.lua +++ b/lua/ghost/response.lua @@ -106,31 +106,23 @@ stop_render_timer = function() end end ---- Process pending chunks and update buffer incrementally -flush_pending = function() -- luacheck: ignore 561 - -- Nothing to do if no pending data and no forced redraw - if #state.pending_chunks == 0 and not state.needs_full_redraw then - -- Stop timer if not streaming anymore - if not state.is_streaming then - stop_render_timer() - end - return - end - - -- Determine how many chars to process this tick (adaptive based on backlog) - local chars_to_process = MIN_CHARS_PER_TICK +--- Determine chars to process based on backlog size +--- @return number chars_to_process +local function determine_chars_to_process() if state.pending_bytes > BACKLOG_LARGE_THRESHOLD then - -- Large backlog: process more to catch up (but still smooth) - chars_to_process = MAX_CHARS_PER_TICK + return MAX_CHARS_PER_TICK elseif state.pending_bytes > BACKLOG_MEDIUM_THRESHOLD then - -- Medium backlog: moderate speed - chars_to_process = CHARS_PER_TICK_MEDIUM + return CHARS_PER_TICK_MEDIUM elseif state.pending_bytes > BACKLOG_SMALL_THRESHOLD then - -- Small backlog: slightly faster - chars_to_process = CHARS_PER_TICK_SMALL + return CHARS_PER_TICK_SMALL end + return MIN_CHARS_PER_TICK +end - -- Consume text from pending queue +--- Consume pending chunks up to chars_to_process limit +--- @param chars_to_process number Maximum chars to consume +--- @return string combined The consumed text +local function consume_pending_chunks(chars_to_process) local processed = 0 local text_to_process = {} @@ -139,13 +131,11 @@ flush_pending = function() -- luacheck: ignore 561 local remaining = chars_to_process - processed if #chunk <= remaining then - -- Take whole chunk table.insert(text_to_process, chunk) processed = processed + #chunk state.pending_bytes = state.pending_bytes - #chunk table.remove(state.pending_chunks, 1) else - -- Take partial chunk table.insert(text_to_process, chunk:sub(1, remaining)) state.pending_chunks[1] = chunk:sub(remaining + 1) state.pending_bytes = state.pending_bytes - remaining @@ -153,30 +143,94 @@ flush_pending = function() -- luacheck: ignore 561 end end - -- Process text into lines (newline-aware, avoiding per-char concatenation) - local combined = table.concat(text_to_process) - if #combined > 0 then - -- Split by newlines - local segments = vim.split(combined, "\n", { plain = true }) - - for i, segment in ipairs(segments) do - if i == 1 then - -- First segment: append to current_line - state.current_line = state.current_line .. segment - else - -- Subsequent segments: commit current_line and start new - table.insert(state.lines, state.current_line) - state.current_line = segment - end + return table.concat(text_to_process) +end + +--- Process text into lines, updating state.lines and state.current_line +--- @param combined string The text to process +local function process_text_into_lines(combined) + if #combined == 0 then + return + end + + local segments = vim.split(combined, "\n", { plain = true }) + for i, segment in ipairs(segments) do + if i == 1 then + state.current_line = state.current_line .. segment + else + table.insert(state.lines, state.current_line) + state.current_line = segment + end + end +end + +--- Perform a full buffer redraw +local function do_full_redraw() + local display_lines = vim.deepcopy(state.lines) + if state.current_line ~= "" then + table.insert(display_lines, state.current_line) + end + if #display_lines == 0 then + display_lines = { "" } + end + pcall(vim.api.nvim_buf_set_lines, state.buf, 0, -1, false, display_lines) + state.rendered_line_count = #state.lines + state.rendered_current_line = state.current_line ~= "" and state.current_line or nil + state.needs_full_redraw = false +end + +--- Perform an incremental buffer update +local function do_incremental_update() + local new_committed_count = #state.lines + + if new_committed_count > state.rendered_line_count then + local new_lines = {} + for i = state.rendered_line_count + 1, new_committed_count do + table.insert(new_lines, state.lines[i]) + end + local insert_at = state.rendered_line_count + pcall( + vim.api.nvim_buf_set_lines, + state.buf, + insert_at, + insert_at + (state.rendered_current_line ~= nil and 1 or 0), + false, + new_lines + ) + state.rendered_line_count = new_committed_count + state.rendered_current_line = nil + end + + if state.current_line ~= "" then + local line_idx = state.rendered_line_count + if state.rendered_current_line ~= nil then + pcall(vim.api.nvim_buf_set_lines, state.buf, line_idx, line_idx + 1, false, { state.current_line }) + else + pcall(vim.api.nvim_buf_set_lines, state.buf, line_idx, line_idx, false, { state.current_line }) + end + state.rendered_current_line = state.current_line + elseif state.rendered_current_line ~= nil then + state.rendered_current_line = nil + end +end + +--- Process pending chunks and update buffer incrementally +flush_pending = function() + if #state.pending_chunks == 0 and not state.needs_full_redraw then + if not state.is_streaming then + stop_render_timer() end + return end - -- Update buffer (incremental or full redraw) + local chars_to_process = determine_chars_to_process() + local combined = consume_pending_chunks(chars_to_process) + process_text_into_lines(combined) + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then return end - -- Skip buffer updates if window is hidden (just accumulate state) if not state.win or not vim.api.nvim_win_is_valid(state.win) then state.needs_full_redraw = true return @@ -185,64 +239,11 @@ flush_pending = function() -- luacheck: ignore 561 pcall(vim.api.nvim_set_option_value, "modifiable", true, { buf = state.buf }) if state.needs_full_redraw then - -- Full redraw required - local display_lines = vim.deepcopy(state.lines) - if state.current_line ~= "" then - table.insert(display_lines, state.current_line) - end - if #display_lines == 0 then - display_lines = { "" } - end - pcall(vim.api.nvim_buf_set_lines, state.buf, 0, -1, false, display_lines) - state.rendered_line_count = #state.lines - state.rendered_current_line = state.current_line ~= "" and state.current_line or nil - state.needs_full_redraw = false + do_full_redraw() else - -- Incremental update - local new_committed_count = #state.lines - - -- Append any new committed lines - if new_committed_count > state.rendered_line_count then - local new_lines = {} - for i = state.rendered_line_count + 1, new_committed_count do - table.insert(new_lines, state.lines[i]) - end - -- Append new committed lines after existing content - local insert_at = state.rendered_line_count - if state.rendered_current_line ~= nil then - -- There was a current_line rendered; replace it + append - insert_at = state.rendered_line_count - end - pcall( - vim.api.nvim_buf_set_lines, - state.buf, - insert_at, - insert_at + (state.rendered_current_line ~= nil and 1 or 0), - false, - new_lines - ) - state.rendered_line_count = new_committed_count - state.rendered_current_line = nil -- Will be set below if needed - end - - -- Update or add current_line - if state.current_line ~= "" then - local line_idx = state.rendered_line_count - if state.rendered_current_line ~= nil then - -- Update existing last line - pcall(vim.api.nvim_buf_set_lines, state.buf, line_idx, line_idx + 1, false, { state.current_line }) - else - -- Append new current_line - pcall(vim.api.nvim_buf_set_lines, state.buf, line_idx, line_idx, false, { state.current_line }) - end - state.rendered_current_line = state.current_line - elseif state.rendered_current_line ~= nil then - -- current_line was cleared (became committed); already handled above - state.rendered_current_line = nil - end + do_incremental_update() end - -- Scroll to bottom if state.win and vim.api.nvim_win_is_valid(state.win) then local line_count = vim.api.nvim_buf_line_count(state.buf) pcall(vim.api.nvim_win_set_cursor, state.win, { math.max(1, line_count), 0 }) @@ -678,43 +679,71 @@ function M.get_content() return table.concat(all_lines, "\n") end ---- Handle a Ghost update event ---- @param update table The update from receiver -function M.handle_update(update) -- luacheck: ignore 561 - if update.type == "text_chunk" then - M.append_text(update.text) - elseif update.type == "tool_call" then - M.update_tool_call(update.tool_id, update.tool_name, update.status or "pending", update.kind) - elseif update.type == "tool_call_update" then - -- Look up existing tool call or create new entry - local tool_info = state.tool_calls[update.tool_id] - local tool_name = update.tool_name or (tool_info and tool_info.tool_name) or "tool" - M.update_tool_call(update.tool_id, tool_name, update.status or "in_progress", nil) - elseif update.type == "tool_output" then - -- Summarize tool output instead of showing full content - if update.output_type == "file_content" then - -- Just show a summary, not the full file - local line_count = update.line_count or 0 - M.append_text(string.format(" 📄 (read %d lines)\n", line_count)) - else - -- Show truncated output for other types - local preview = (update.content or ""):sub(1, 100) - if #(update.content or "") > 100 then - preview = preview .. "..." - end - M.append_text(" → " .. preview .. "\n") +--- Handler for text_chunk updates +--- @param update table +local function handle_text_chunk(update) + M.append_text(update.text) +end + +--- Handler for tool_call updates +--- @param update table +local function handle_tool_call(update) + M.update_tool_call(update.tool_id, update.tool_name, update.status or "pending", update.kind) +end + +--- Handler for tool_call_update updates +--- @param update table +local function handle_tool_call_update(update) + local tool_info = state.tool_calls[update.tool_id] + local tool_name = update.tool_name or (tool_info and tool_info.tool_name) or "tool" + M.update_tool_call(update.tool_id, tool_name, update.status or "in_progress", nil) +end + +--- Handler for tool_output updates +--- @param update table +local function handle_tool_output(update) + if update.output_type == "file_content" then + local line_count = update.line_count or 0 + M.append_text(string.format(" 📄 (read %d lines)\n", line_count)) + else + local preview = (update.content or ""):sub(1, 100) + if #(update.content or "") > 100 then + preview = preview .. "..." end - elseif update.type == "plan" then - M.add_header("Plan") - if update.plan and update.plan.entries then - for _, entry in ipairs(update.plan.entries) do - local status_icon = entry.completed and "✅" or "⬜" - M.append_text(status_icon .. " " .. (entry.description or entry.title or "Step") .. "\n") - end + M.append_text(" → " .. preview .. "\n") + end +end + +--- Handler for plan updates +--- @param update table +local function handle_plan(update) + M.add_header("Plan") + if update.plan and update.plan.entries then + for _, entry in ipairs(update.plan.entries) do + local status_icon = entry.completed and "✅" or "⬜" + M.append_text(status_icon .. " " .. (entry.description or entry.title or "Step") .. "\n") end end end +--- Dispatch table for update types +local update_handlers = { + text_chunk = handle_text_chunk, + tool_call = handle_tool_call, + tool_call_update = handle_tool_call_update, + tool_output = handle_tool_output, + plan = handle_plan, +} + +--- Handle a Ghost update event +--- @param update table The update from receiver +function M.handle_update(update) + local handler = update_handlers[update.type] + if handler then + handler(update) + end +end + --- Handle completion of a response --- @param response table The final response from receiver function M.handle_response(response) diff --git a/lua/ghost/status.lua b/lua/ghost/status.lua index 22a979f..58c3c6c 100644 --- a/lua/ghost/status.lua +++ b/lua/ghost/status.lua @@ -367,37 +367,38 @@ end --- Build the content lines for the status window --- @return string[] Lines to display in the status window -function M.build_status_content() -- luacheck: ignore 561 - local acp = require("ghost.acp") - local lines = {} - - -- Header +local function build_header_section(lines) table.insert(lines, "Ghost Status") table.insert(lines, string.rep("-", 40)) table.insert(lines, "") +end - -- Backend - local acp_status = acp.status() +local function build_backend_section(lines, acp_status) table.insert(lines, "Backend:") table.insert(lines, string.format(" %s", acp_status.backend or "opencode")) table.insert(lines, "") +end - -- Connection state with clear labels +local function build_connection_connected(lines, acp_status) + table.insert(lines, " Status: CONNECTED") + local agent_name = acp_status.agent_info and acp_status.agent_info.name or "unknown" + local version = acp_status.agent_info and acp_status.agent_info.version or "" + table.insert(lines, string.format(" Agent: %s %s", agent_name, version)) + if acp_status.session_id then + table.insert(lines, string.format(" Session: %s", acp_status.session_id:sub(1, 12) .. "...")) + else + table.insert(lines, " Session: none") + end + table.insert(lines, " Transport: stdio (acp subprocess)") + if acp_status.pending_requests > 0 then + table.insert(lines, string.format(" Pending requests: %d", acp_status.pending_requests)) + end +end + +local function build_connection_section(lines, acp_status) table.insert(lines, "Connection State:") if acp_status.initialized then - table.insert(lines, " Status: CONNECTED") - local agent_name = acp_status.agent_info and acp_status.agent_info.name or "unknown" - local version = acp_status.agent_info and acp_status.agent_info.version or "" - table.insert(lines, string.format(" Agent: %s %s", agent_name, version)) - if acp_status.session_id then - table.insert(lines, string.format(" Session: %s", acp_status.session_id:sub(1, 12) .. "...")) - else - table.insert(lines, " Session: none") - end - table.insert(lines, " Transport: stdio (acp subprocess)") - if acp_status.pending_requests > 0 then - table.insert(lines, string.format(" Pending requests: %d", acp_status.pending_requests)) - end + build_connection_connected(lines, acp_status) elseif acp_status.initializing then table.insert(lines, " Status: INITIALIZING") table.insert(lines, " Starting ACP subprocess...") @@ -410,19 +411,22 @@ function M.build_status_content() -- luacheck: ignore 561 table.insert(lines, " Will auto-start when you send a prompt") end table.insert(lines, "") +end - -- Last Error (if present) - if acp_status.last_error then - table.insert(lines, "Last Error:") - local error_ago = acp_status.last_error_time and (os.time() - acp_status.last_error_time) or 0 - table.insert(lines, string.format(" %s", acp_status.last_error)) - if error_ago > 0 then - table.insert(lines, string.format(" (%s)", format_ago(error_ago))) - end - table.insert(lines, "") +local function build_error_section(lines, acp_status) + if not acp_status.last_error then + return + end + table.insert(lines, "Last Error:") + local error_ago = acp_status.last_error_time and (os.time() - acp_status.last_error_time) or 0 + table.insert(lines, string.format(" %s", acp_status.last_error)) + if error_ago > 0 then + table.insert(lines, string.format(" (%s)", format_ago(error_ago))) end + table.insert(lines, "") +end - -- Active requests +local function build_active_requests_section(lines) table.insert(lines, "Active Requests:") local active = M.get_all_active() if #active == 0 then @@ -441,35 +445,58 @@ function M.build_status_content() -- luacheck: ignore 561 end end table.insert(lines, "") +end - -- Last completed request +local function build_last_completed_status(last) + if last.status == "completed" then + local status_str = string.format("%s", last.response_type or "complete") + if last.response_summary then + status_str = status_str .. " - " .. last.response_summary + end + return status_str + else + local status_str = "error" + if last.error_message then + status_str = status_str .. " - " .. last.error_message + end + return status_str + end +end + +local function build_last_completed_section(lines) table.insert(lines, "Last Completed:") local last = state.last_completed if not last then table.insert(lines, " None") - else - local ago = os.time() - (last.completed_at or os.time()) - local status_str - if last.status == "completed" then - status_str = string.format("%s", last.response_type or "complete") - if last.response_summary then - status_str = status_str .. " - " .. last.response_summary - end - else - status_str = "error" - if last.error_message then - status_str = status_str .. " - " .. last.error_message - end - end - table.insert(lines, string.format(" %s (%s)", status_str, format_ago(ago))) - table.insert(lines, string.format(" Prompt: %s", last.prompt_preview)) - if last.file_path then - table.insert(lines, string.format(" File: %s", vim.fn.fnamemodify(last.file_path, ":t"))) - end + return + end + local ago = os.time() - (last.completed_at or os.time()) + local status_str = build_last_completed_status(last) + table.insert(lines, string.format(" %s (%s)", status_str, format_ago(ago))) + table.insert(lines, string.format(" Prompt: %s", last.prompt_preview)) + if last.file_path then + table.insert(lines, string.format(" File: %s", vim.fn.fnamemodify(last.file_path, ":t"))) end +end + +local function build_footer_section(lines) table.insert(lines, "") table.insert(lines, string.rep("-", 40)) table.insert(lines, "Press q or to close") +end + +function M.build_status_content() + local acp = require("ghost.acp") + local lines = {} + local acp_status = acp.status() + + build_header_section(lines) + build_backend_section(lines, acp_status) + build_connection_section(lines, acp_status) + build_error_section(lines, acp_status) + build_active_requests_section(lines) + build_last_completed_section(lines) + build_footer_section(lines) return lines end diff --git a/lua/ghost/test.lua b/lua/ghost/test.lua index 384dd64..46d2221 100644 --- a/lua/ghost/test.lua +++ b/lua/ghost/test.lua @@ -45,6 +45,61 @@ function M.test_session() end) end +--- Handle test update events with a dispatch table +--- @param update table The update event from ACP +--- @param output_lines table Table to collect output text +local function handle_test_update(update, output_lines) + local inner = update.update or update + local update_type = inner.sessionUpdate + + local handlers = { + message_delta = function() + local content = inner.content + if content and type(content) == "table" then + for _, item in ipairs(content) do + if item.type == "text" and item.text then + io.write(item.text) + io.flush() + table.insert(output_lines, item.text) + end + end + end + end, + message = function() + local content = inner.content + if content and type(content) == "table" then + for _, item in ipairs(content) do + if item.type == "text" and item.text then + io.write(item.text) + io.flush() + table.insert(output_lines, item.text) + end + end + end + end, + tool_call = function() + print("\n[Tool: " .. (inner.title or "unknown") .. " (" .. (inner.kind or "?") .. ")]") + end, + tool_call_update = function() + local status = inner.status or "unknown" + print("[Tool update: " .. (inner.toolCallId or "?") .. " - " .. status .. "]") + end, + plan_update = function() + print("[Plan updated]") + end, + plan = function() + print("[Plan updated]") + end, + } + + local handler = handlers[update_type] + if handler then + handler() + else + print("[Update: " .. vim.inspect(update):sub(1, 200) .. "]") + end +end + --- Test sending a simple prompt with streaming output --- Usage: :lua require('ghost.test').test_prompt() function M.test_prompt(prompt_text) @@ -60,36 +115,8 @@ function M.test_prompt(prompt_text) local output_lines = {} acp.send_prompt(prompt_text, nil, { - on_update = function(update) -- luacheck: ignore 561 - -- ACP update format: update.update.sessionUpdate indicates type - local inner = update.update or update - local update_type = inner.sessionUpdate - - if update_type == "message_delta" or update_type == "message" then - -- Handle streaming text content - local content = inner.content - if content and type(content) == "table" then - for _, item in ipairs(content) do - if item.type == "text" and item.text then - io.write(item.text) - io.flush() - table.insert(output_lines, item.text) - end - end - end - elseif update_type == "tool_call" then - -- New tool invocation - print("\n[Tool: " .. (inner.title or "unknown") .. " (" .. (inner.kind or "?") .. ")]") - elseif update_type == "tool_call_update" then - -- Tool progress/completion - local status = inner.status or "unknown" - print("[Tool update: " .. (inner.toolCallId or "?") .. " - " .. status .. "]") - elseif update_type == "plan_update" or update_type == "plan" then - print("[Plan updated]") - else - -- Debug: print raw update for unknown types - print("[Update: " .. vim.inspect(update):sub(1, 200) .. "]") - end + on_update = function(update) + handle_test_update(update, output_lines) end, on_complete = function(result) From 5e5d96f5ffb80f4d490c25e3ae14dbd484f8bd6e Mon Sep 17 00:00:00 2001 From: assagman Date: Tue, 20 Jan 2026 18:07:39 +0300 Subject: [PATCH 2/2] refactor(test): deduplicate handler logic in dispatch table Extract shared helper functions for message and plan handlers to eliminate duplication. This improves code maintainability and readability while keeping the dispatch table declarative. Closes review threads #1 and #2 in PR #8 Signed-off-by: assagman --- lua/ghost/test.lua | 49 +++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/lua/ghost/test.lua b/lua/ghost/test.lua index 46d2221..15c24fb 100644 --- a/lua/ghost/test.lua +++ b/lua/ghost/test.lua @@ -52,31 +52,26 @@ local function handle_test_update(update, output_lines) local inner = update.update or update local update_type = inner.sessionUpdate - local handlers = { - message_delta = function() - local content = inner.content - if content and type(content) == "table" then - for _, item in ipairs(content) do - if item.type == "text" and item.text then - io.write(item.text) - io.flush() - table.insert(output_lines, item.text) - end - end - end - end, - message = function() - local content = inner.content - if content and type(content) == "table" then - for _, item in ipairs(content) do - if item.type == "text" and item.text then - io.write(item.text) - io.flush() - table.insert(output_lines, item.text) - end + local function handle_message_content() + local content = inner.content + if content and type(content) == "table" then + for _, item in ipairs(content) do + if item.type == "text" and item.text then + io.write(item.text) + io.flush() + table.insert(output_lines, item.text) end end - end, + end + end + + local function handle_plan_update() + print("[Plan updated]") + end + + local handlers = { + message_delta = handle_message_content, + message = handle_message_content, tool_call = function() print("\n[Tool: " .. (inner.title or "unknown") .. " (" .. (inner.kind or "?") .. ")]") end, @@ -84,12 +79,8 @@ local function handle_test_update(update, output_lines) local status = inner.status or "unknown" print("[Tool update: " .. (inner.toolCallId or "?") .. " - " .. status .. "]") end, - plan_update = function() - print("[Plan updated]") - end, - plan = function() - print("[Plan updated]") - end, + plan_update = handle_plan_update, + plan = handle_plan_update, } local handler = handlers[update_type]