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..15c24fb 100644 --- a/lua/ghost/test.lua +++ b/lua/ghost/test.lua @@ -45,6 +45,52 @@ 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 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 + + 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, + tool_call_update = function() + local status = inner.status or "unknown" + print("[Tool update: " .. (inner.toolCallId or "?") .. " - " .. status .. "]") + end, + plan_update = handle_plan_update, + plan = handle_plan_update, + } + + 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 +106,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)