diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..250dd97 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +# Code quality CI for ghost.nvim +# Runs stylua formatting check and luacheck linter + +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: stylua + luacheck + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install StyLua + uses: JohnnyMorganz/stylua-action@v4 + with: + version: latest + token: ${{ secrets.GITHUB_TOKEN }} + args: --check lua + + - name: Install Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.1" + + - name: Install LuaRocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Install luacheck + run: luarocks install luacheck + + - name: Run luacheck + run: luacheck lua diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..00779ee --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,41 @@ +-- Luacheck configuration for ghost.nvim +-- https://luacheck.readthedocs.io/ + +-- Use LuaJIT (Neovim runtime) +std = "luajit" + +-- Define Neovim globals +globals = { + "vim", +} + +-- Read-only globals (standard Lua + LuaJIT) +read_globals = { + "jit", + "unpack", +} + +-- Ignore generated/vendor directories +exclude_files = { + ".opencode/**", + "node_modules/**", +} + +-- Maximum line length (match stylua column_width) +max_line_length = 120 + +-- Maximum cyclomatic complexity +max_cyclomatic_complexity = 15 + +-- Warnings configuration +-- See: https://luacheck.readthedocs.io/en/stable/warnings.html + +-- Allow unused arguments starting with underscore +unused_args = true +unused_secondaries = true + +-- Allow self as unused (common in OOP patterns) +self = false + +-- Specific file overrides can be added here: +-- files["lua/ghost/test.lua"] = { ignore = { "212" } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..838e63c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +# Pre-commit hooks for ghost.nvim +# https://pre-commit.com/ +# +# Install: pre-commit install +# Run all: pre-commit run --all-files +# +# Prerequisites: stylua, luacheck must be installed locally + +repos: + - repo: local + hooks: + # StyLua formatter - auto-fixes formatting + # If files are modified, commit will be stopped for re-staging + - id: stylua + name: stylua (format) + entry: stylua + language: system + types: [lua] + args: [] + + # Luacheck linter - blocks on any diagnostics + - id: luacheck + name: luacheck (lint) + entry: luacheck + language: system + types: [lua] + args: ["--no-color"] diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..93845fd --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,12 @@ +# StyLua configuration for ghost.nvim +# https://github.com/JohnnyMorganz/StyLua + +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" + +[sort_requires] +enabled = false diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dbc7586 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# ghost.nvim Makefile +# Development tasks for code quality + +.PHONY: all format format-check lint check precommit precommit-install help + +# Default target +all: check + +# Format all Lua files with stylua +format: + @echo "Formatting Lua files..." + stylua lua + +# Check formatting without modifying files +format-check: + @echo "Checking Lua formatting..." + stylua --check lua + +# Run luacheck linter +lint: + @echo "Linting Lua files..." + luacheck lua + +# Run all checks (format + lint) +check: format-check lint + @echo "All checks passed!" + +# Install pre-commit hooks +precommit-install: + @echo "Installing pre-commit hooks..." + pre-commit install + +# Run pre-commit on all files +precommit: + @echo "Running pre-commit on all files..." + pre-commit run --all-files + +# Show help +help: + @echo "Available targets:" + @echo " make format - Format Lua files with stylua" + @echo " make format-check - Check Lua formatting (no changes)" + @echo " make lint - Run luacheck linter" + @echo " make check - Run format-check + lint" + @echo " make precommit-install - Install pre-commit hooks" + @echo " make precommit - Run pre-commit on all files" + @echo " make help - Show this help message" diff --git a/README.md b/README.md index 29007ae..f02ae7d 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,47 @@ Shows: - Connection state (CONNECTED/INITIALIZING/DISCONNECTED) - Last error message (if any) +## Development + +### Prerequisites + +Install code quality tools: + +```bash +# macOS +brew install stylua luacheck +pip install pre-commit + +# Or manually: +# stylua: https://github.com/JohnnyMorganz/StyLua +# luacheck: https://github.com/mpeterv/luacheck +# pre-commit: https://pre-commit.com/ +``` + +### Setup + +```bash +# Install pre-commit hooks (runs stylua + luacheck on commit) +make precommit-install +``` + +### Makefile Targets + +| Target | Description | +|--------|-------------| +| `make format` | Format Lua files with stylua | +| `make format-check` | Check formatting (no changes) | +| `make lint` | Run luacheck linter | +| `make check` | Run format-check + lint | +| `make precommit` | Run pre-commit on all files | + +### Code Style + +- **Formatter**: [StyLua](https://github.com/JohnnyMorganz/StyLua) - 2-space indent, double quotes +- **Linter**: [luacheck](https://github.com/mpeterv/luacheck) - LuaJIT std, strict warnings + +Configuration files: `.stylua.toml`, `.luacheckrc` + ## License MIT diff --git a/lua/ghost/acp.lua b/lua/ghost/acp.lua index 45193a6..221c3e3 100644 --- a/lua/ghost/acp.lua +++ b/lua/ghost/acp.lua @@ -271,7 +271,7 @@ end --- Handle stderr data from the subprocess --- @param data string The error data received -local function on_stderr(_, data) +local function on_stderr(_, _data) -- OpenCode writes logs to stderr, we ignore them -- Uncomment below for debugging: -- if data then @@ -1030,7 +1030,7 @@ end --- Legacy: Set data receive callback --- @param callback fun(data: string)|nil The callback function -function M.set_on_data(callback) +function M.set_on_data(_callback) -- This is handled by on_stdout now end diff --git a/lua/ghost/config.lua b/lua/ghost/config.lua index 6a12f90..ce53bdf 100644 --- a/lua/ghost/config.lua +++ b/lua/ghost/config.lua @@ -4,11 +4,12 @@ local M = {} --- Default configuration for Ghost - --- @class GhostConfig - --- @field keybind string Keybind to open the prompt buffer - --- @field backend "opencode"|"codex" Which ACP backend to use - --- @field acp_command string|table Command to run for ACP (default: "opencode"). Table format bypasses "acp" argument for custom scripts. - --- @field acp_cwd string|nil Working directory for ACP subprocess (default: current directory) +--- @class GhostConfig +--- @field keybind string Keybind to open the prompt buffer +--- @field backend "opencode"|"codex" Which ACP backend to use +--- @field acp_command string|table Command to run for ACP (default: "opencode"). +--- Table format bypasses "acp" argument for custom scripts. +--- @field acp_cwd string|nil Working directory for ACP subprocess (default: current directory) --- @field agent string|nil Agent name to use (e.g., "NULL", "plan", "explore", "general") --- @field model string|nil Model name/id to request (e.g., "gpt-4.1", "o1") --- @field autoread boolean Enable autoread so buffers reload when agent edits files diff --git a/lua/ghost/context.lua b/lua/ghost/context.lua index df25dd2..2171e9c 100644 --- a/lua/ghost/context.lua +++ b/lua/ghost/context.lua @@ -146,7 +146,7 @@ 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) +function M.capture(bufnr, include_selection) -- luacheck: ignore 561 -- Safely get buffer number with fallback local ok, bufnr_result = pcall(function() return bufnr or vim.api.nvim_get_current_buf() diff --git a/lua/ghost/health.lua b/lua/ghost/health.lua index 7523e51..f98746c 100644 --- a/lua/ghost/health.lua +++ b/lua/ghost/health.lua @@ -28,7 +28,7 @@ local function check_executable(name, desc, required) end end -function M.check() +function M.check() -- luacheck: ignore 561 start("ghost.nvim") -- Check Neovim version diff --git a/lua/ghost/list.lua b/lua/ghost/list.lua index ba0a387..0ccabae 100644 --- a/lua/ghost/list.lua +++ b/lua/ghost/list.lua @@ -78,10 +78,7 @@ local function open_snacks_picker() vim.schedule(function() local load_ok, load_err = response_display.load_transcript(sess.id) if not load_ok then - vim.notify( - "Ghost: Failed to load transcript - " .. (load_err or "unknown error"), - vim.log.levels.WARN - ) + vim.notify("Ghost: Failed to load transcript - " .. (load_err or "unknown error"), vim.log.levels.WARN) end response_display.open() end) diff --git a/lua/ghost/persist.lua b/lua/ghost/persist.lua index 4f88741..e4307e4 100644 --- a/lua/ghost/persist.lua +++ b/lua/ghost/persist.lua @@ -21,7 +21,7 @@ local function sanitize_path(path) -- Replace remaining slashes with underscores sanitized = sanitized:gsub("/", "_") -- Remove or replace other potentially problematic characters - sanitized = sanitized:gsub("[<>:\"|?*\\]", "_") + sanitized = sanitized:gsub('[<>:"|?*\\]', "_") -- Collapse multiple underscores sanitized = sanitized:gsub("_+", "_") -- Remove trailing underscore diff --git a/lua/ghost/receiver.lua b/lua/ghost/receiver.lua index 5ff764b..9e591f4 100644 --- a/lua/ghost/receiver.lua +++ b/lua/ghost/receiver.lua @@ -87,7 +87,7 @@ 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) +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) @@ -256,7 +256,7 @@ end --- @param result table The final result --- @param request_id string|nil The request ID --- @param ghost_session_id string|nil The ghost session ID -local function process_completion(result, request_id, ghost_session_id) +local function process_completion(_result, request_id, ghost_session_id) -- Flush any remaining transcript buffer for the correct session (US-009) local target_session_id = ghost_session_id if not target_session_id then diff --git a/lua/ghost/response.lua b/lua/ghost/response.lua index 2dd23de..aefd8c9 100644 --- a/lua/ghost/response.lua +++ b/lua/ghost/response.lua @@ -36,22 +36,22 @@ local CHARS_PER_TICK_SMALL = 24 -- Chars/tick at small backlog --- @field rendered_current_line string|nil The current_line value last rendered --- @field needs_full_redraw boolean Force full buffer redraw on next tick local state = { - buf = nil, - win = nil, - lines = {}, - current_line = "", - tool_calls = {}, - is_streaming = false, - is_complete = false, - on_reply = nil, -- Callback when user wants to reply - current_session_id = nil, -- Track which session is displayed - -- Smooth streaming state - pending_chunks = {}, - pending_bytes = 0, - render_timer = nil, - rendered_line_count = 0, - rendered_current_line = nil, - needs_full_redraw = false, + buf = nil, + win = nil, + lines = {}, + current_line = "", + tool_calls = {}, + is_streaming = false, + is_complete = false, + on_reply = nil, -- Callback when user wants to reply + current_session_id = nil, -- Track which session is displayed + -- Smooth streaming state + pending_chunks = {}, + pending_bytes = 0, + render_timer = nil, + rendered_line_count = 0, + rendered_current_line = nil, + needs_full_redraw = false, } -- Forward declarations @@ -63,218 +63,217 @@ local stop_render_timer --- Reset all streaming and content state --- Used by both clear() and close() to avoid duplication local function reset_state() - state.lines = {} - state.current_line = "" - state.tool_calls = {} - state.is_streaming = false - state.is_complete = false - state.current_session_id = nil - state.pending_chunks = {} - state.pending_bytes = 0 - state.rendered_line_count = 0 - state.rendered_current_line = nil - state.needs_full_redraw = false + state.lines = {} + state.current_line = "" + state.tool_calls = {} + state.is_streaming = false + state.is_complete = false + state.current_session_id = nil + state.pending_chunks = {} + state.pending_bytes = 0 + state.rendered_line_count = 0 + state.rendered_current_line = nil + state.needs_full_redraw = false end --- Start the render timer for smooth streaming start_render_timer = function() - if state.render_timer then - return -- Already running - end - - local timer = vim.loop.new_timer() - if not timer then - return - end - - state.render_timer = timer - timer:start( - 0, - RENDER_INTERVAL_MS, - vim.schedule_wrap(function() - flush_pending() - end) - ) + if state.render_timer then + return -- Already running + end + + local timer = vim.loop.new_timer() + if not timer then + return + end + + state.render_timer = timer + timer:start( + 0, + RENDER_INTERVAL_MS, + vim.schedule_wrap(function() + flush_pending() + end) + ) end --- Stop the render timer stop_render_timer = function() - if state.render_timer then - state.render_timer:stop() - state.render_timer:close() - state.render_timer = nil - end + if state.render_timer then + state.render_timer:stop() + state.render_timer:close() + state.render_timer = nil + end end --- Process pending chunks and update buffer incrementally -flush_pending = function() - -- 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 - 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 - elseif state.pending_bytes > BACKLOG_MEDIUM_THRESHOLD then - -- Medium backlog: moderate speed - chars_to_process = CHARS_PER_TICK_MEDIUM - elseif state.pending_bytes > BACKLOG_SMALL_THRESHOLD then - -- Small backlog: slightly faster - chars_to_process = CHARS_PER_TICK_SMALL - end - - -- Consume text from pending queue - local processed = 0 - local text_to_process = {} - - while #state.pending_chunks > 0 and processed < chars_to_process do - local chunk = state.pending_chunks[1] - 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 - processed = remaining - break - 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 - end - end - - -- Update buffer (incremental or full redraw) - 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 - end - - 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 - 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 - 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 }) - end - - -- Stop timer if no more pending data and not streaming - if #state.pending_chunks == 0 and not state.is_streaming then - stop_render_timer() - end +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 + 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 + elseif state.pending_bytes > BACKLOG_MEDIUM_THRESHOLD then + -- Medium backlog: moderate speed + chars_to_process = CHARS_PER_TICK_MEDIUM + elseif state.pending_bytes > BACKLOG_SMALL_THRESHOLD then + -- Small backlog: slightly faster + chars_to_process = CHARS_PER_TICK_SMALL + end + + -- Consume text from pending queue + local processed = 0 + local text_to_process = {} + + while #state.pending_chunks > 0 and processed < chars_to_process do + local chunk = state.pending_chunks[1] + 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 + break + 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 + end + end + + -- Update buffer (incremental or full redraw) + 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 + end + + 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 + 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 + 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 }) + end + + -- Stop timer if no more pending data and not streaming + if #state.pending_chunks == 0 and not state.is_streaming then + stop_render_timer() + end end --- Calculate window dimensions for response display --- @return table Window configuration for nvim_open_win local function get_window_config() - local opts = config.options - local width = math.floor(vim.o.columns * (opts.response_window and opts.response_window.width or 0.6)) - local height = math.floor(vim.o.lines * (opts.response_window and opts.response_window.height or 0.4)) - local row = math.floor((vim.o.lines - height) / 2) - local col = math.floor((vim.o.columns - width) / 2) - - return { - relative = "editor", - width = width, - height = height, - row = row, - col = col, - style = "minimal", - border = "rounded", - title = " Ghost Response ", - title_pos = "center", - } + local opts = config.options + local width = math.floor(vim.o.columns * (opts.response_window and opts.response_window.width or 0.6)) + local height = math.floor(vim.o.lines * (opts.response_window and opts.response_window.height or 0.4)) + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + return { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + title = " Ghost Response ", + title_pos = "center", + } end --- Create and open the response buffer @@ -282,265 +281,265 @@ end --- @return number|nil buf The buffer number --- @return number|nil win The window number function M.open(enter) - -- Default to entering the window - if enter == nil then - enter = true - end - - -- If window already open and valid, just focus it - if state.win and vim.api.nvim_win_is_valid(state.win) then - if enter then - vim.api.nvim_set_current_win(state.win) - end - return state.buf, state.win - end - - -- Create a new scratch buffer - local buf = vim.api.nvim_create_buf(false, true) - if buf == 0 then - vim.notify("Ghost: Failed to create response buffer", vim.log.levels.ERROR) - return nil, nil - end - - -- Set buffer options - pcall(vim.api.nvim_set_option_value, "buftype", "nofile", { buf = buf }) - pcall(vim.api.nvim_set_option_value, "bufhidden", "hide", { buf = buf }) -- hide, not wipe - preserve content - pcall(vim.api.nvim_set_option_value, "swapfile", false, { buf = buf }) - pcall(vim.api.nvim_set_option_value, "filetype", "markdown", { buf = buf }) - pcall(vim.api.nvim_set_option_value, "modifiable", true, { buf = buf }) - - -- Open the floating window - ENTER it for focus - local win_config = get_window_config() - local win = vim.api.nvim_open_win(buf, enter, win_config) - if win == 0 then - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - vim.notify("Ghost: Failed to create response window", vim.log.levels.ERROR) - return nil, nil - end - - -- Store state (preserve existing content state) - state.buf = buf - state.win = win - -- Only reset content if we don't have existing content - if #state.lines == 0 and state.current_line == "" then - state.tool_calls = {} - end - - -- Set window options - pcall(vim.api.nvim_set_option_value, "wrap", true, { win = win }) - pcall(vim.api.nvim_set_option_value, "linebreak", true, { win = win }) - pcall(vim.api.nvim_set_option_value, "cursorline", false, { win = win }) - - -- Set up buffer-local keymaps - pcall(vim.keymap.set, "n", "q", function() - M.hide() -- hide instead of close to preserve content - end, { buffer = buf, silent = true, desc = "Hide Ghost response" }) - - pcall(vim.keymap.set, "n", "", function() - M.hide() -- hide instead of close to preserve content - end, { buffer = buf, silent = true, desc = "Hide Ghost response" }) - - pcall(vim.keymap.set, "n", "", function() - M.hide() -- hide instead of close to preserve content - end, { buffer = buf, silent = true, desc = "Hide Ghost response" }) - - -- Reply keymap - continue the conversation (US-008) - pcall(vim.keymap.set, "n", "r", function() - if not state.on_reply then - vim.notify("Ghost: Reply not available", vim.log.levels.WARN) - return - end - - -- Ensure we reply to the session whose transcript is displayed (US-008) - if state.current_session_id then - local session = require("ghost.session") - local ok, err = session.switch_session(state.current_session_id) - if not ok then - vim.notify("Ghost: Failed to switch to session - " .. (err or "unknown error"), vim.log.levels.ERROR) - return - end - end - - state.on_reply() - end, { buffer = buf, silent = true, desc = "Reply to Ghost response" }) - - -- Also map Enter to reply for convenience (US-008) - pcall(vim.keymap.set, "n", "", function() - if not state.on_reply then - vim.notify("Ghost: Reply not available", vim.log.levels.WARN) - return - end - - -- Ensure we reply to the session whose transcript is displayed (US-008) - if state.current_session_id then - local session = require("ghost.session") - local ok, err = session.switch_session(state.current_session_id) - if not ok then - vim.notify("Ghost: Failed to switch to session - " .. (err or "unknown error"), vim.log.levels.ERROR) - return - end - end - - state.on_reply() - end, { buffer = buf, silent = true, desc = "Reply to Ghost response" }) - - -- Restore existing content if we have any - if #state.lines > 0 or state.current_line ~= "" or #state.pending_chunks > 0 then - -- New buffer needs full redraw, reset render tracking - state.rendered_line_count = 0 - state.rendered_current_line = nil - state.needs_full_redraw = true - update_buffer() - - -- Restart timer if there are pending chunks - if #state.pending_chunks > 0 then - start_render_timer() - end - end - - return buf, win + -- Default to entering the window + if enter == nil then + enter = true + end + + -- If window already open and valid, just focus it + if state.win and vim.api.nvim_win_is_valid(state.win) then + if enter then + vim.api.nvim_set_current_win(state.win) + end + return state.buf, state.win + end + + -- Create a new scratch buffer + local buf = vim.api.nvim_create_buf(false, true) + if buf == 0 then + vim.notify("Ghost: Failed to create response buffer", vim.log.levels.ERROR) + return nil, nil + end + + -- Set buffer options + pcall(vim.api.nvim_set_option_value, "buftype", "nofile", { buf = buf }) + pcall(vim.api.nvim_set_option_value, "bufhidden", "hide", { buf = buf }) -- hide, not wipe - preserve content + pcall(vim.api.nvim_set_option_value, "swapfile", false, { buf = buf }) + pcall(vim.api.nvim_set_option_value, "filetype", "markdown", { buf = buf }) + pcall(vim.api.nvim_set_option_value, "modifiable", true, { buf = buf }) + + -- Open the floating window - ENTER it for focus + local win_config = get_window_config() + local win = vim.api.nvim_open_win(buf, enter, win_config) + if win == 0 then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + vim.notify("Ghost: Failed to create response window", vim.log.levels.ERROR) + return nil, nil + end + + -- Store state (preserve existing content state) + state.buf = buf + state.win = win + -- Only reset content if we don't have existing content + if #state.lines == 0 and state.current_line == "" then + state.tool_calls = {} + end + + -- Set window options + pcall(vim.api.nvim_set_option_value, "wrap", true, { win = win }) + pcall(vim.api.nvim_set_option_value, "linebreak", true, { win = win }) + pcall(vim.api.nvim_set_option_value, "cursorline", false, { win = win }) + + -- Set up buffer-local keymaps + pcall(vim.keymap.set, "n", "q", function() + M.hide() -- hide instead of close to preserve content + end, { buffer = buf, silent = true, desc = "Hide Ghost response" }) + + pcall(vim.keymap.set, "n", "", function() + M.hide() -- hide instead of close to preserve content + end, { buffer = buf, silent = true, desc = "Hide Ghost response" }) + + pcall(vim.keymap.set, "n", "", function() + M.hide() -- hide instead of close to preserve content + end, { buffer = buf, silent = true, desc = "Hide Ghost response" }) + + -- Reply keymap - continue the conversation (US-008) + pcall(vim.keymap.set, "n", "r", function() + if not state.on_reply then + vim.notify("Ghost: Reply not available", vim.log.levels.WARN) + return + end + + -- Ensure we reply to the session whose transcript is displayed (US-008) + if state.current_session_id then + local session = require("ghost.session") + local ok, err = session.switch_session(state.current_session_id) + if not ok then + vim.notify("Ghost: Failed to switch to session - " .. (err or "unknown error"), vim.log.levels.ERROR) + return + end + end + + state.on_reply() + end, { buffer = buf, silent = true, desc = "Reply to Ghost response" }) + + -- Also map Enter to reply for convenience (US-008) + pcall(vim.keymap.set, "n", "", function() + if not state.on_reply then + vim.notify("Ghost: Reply not available", vim.log.levels.WARN) + return + end + + -- Ensure we reply to the session whose transcript is displayed (US-008) + if state.current_session_id then + local session = require("ghost.session") + local ok, err = session.switch_session(state.current_session_id) + if not ok then + vim.notify("Ghost: Failed to switch to session - " .. (err or "unknown error"), vim.log.levels.ERROR) + return + end + end + + state.on_reply() + end, { buffer = buf, silent = true, desc = "Reply to Ghost response" }) + + -- Restore existing content if we have any + if #state.lines > 0 or state.current_line ~= "" or #state.pending_chunks > 0 then + -- New buffer needs full redraw, reset render tracking + state.rendered_line_count = 0 + state.rendered_current_line = nil + state.needs_full_redraw = true + update_buffer() + + -- Restart timer if there are pending chunks + if #state.pending_chunks > 0 then + start_render_timer() + end + end + + return buf, win end --- Hide the response window (preserves content for later viewing) function M.hide() - if state.win and vim.api.nvim_win_is_valid(state.win) then - pcall(vim.api.nvim_win_close, state.win, true) - end - state.win = nil - -- Keep buf, lines, current_line, tool_calls - content is preserved + if state.win and vim.api.nvim_win_is_valid(state.win) then + pcall(vim.api.nvim_win_close, state.win, true) + end + state.win = nil + -- Keep buf, lines, current_line, tool_calls - content is preserved end --- Close the response window and clear all content function M.close() - -- Stop any in-progress rendering - stop_render_timer() - - M.hide() - -- Now clear everything - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then - pcall(vim.api.nvim_buf_delete, state.buf, { force = true }) - end - state.buf = nil - reset_state() + -- Stop any in-progress rendering + stop_render_timer() + + M.hide() + -- Now clear everything + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + pcall(vim.api.nvim_buf_delete, state.buf, { force = true }) + end + state.buf = nil + reset_state() end --- Check if response window is open --- @return boolean True if open function M.is_open() - return state.win ~= nil and vim.api.nvim_win_is_valid(state.win) + return state.win ~= nil and vim.api.nvim_win_is_valid(state.win) end --- Check if there is content (even if window is hidden) --- @return boolean True if has content function M.has_content() - return #state.lines > 0 or state.current_line ~= "" or #state.pending_chunks > 0 + return #state.lines > 0 or state.current_line ~= "" or #state.pending_chunks > 0 end --- Check if response is currently streaming --- @return boolean True if streaming function M.is_streaming() - return state.is_streaming + return state.is_streaming end --- Check if response is complete --- @return boolean True if complete function M.is_complete() - return state.is_complete + return state.is_complete end --- Toggle the response window visibility --- Opens if closed (and has content), closes if open function M.toggle() - if M.is_open() then - M.hide() - elseif M.has_content() then - M.open() - else - vim.notify("Ghost: No response to show", vim.log.levels.INFO) - end + if M.is_open() then + M.hide() + elseif M.has_content() then + M.open() + else + vim.notify("Ghost: No response to show", vim.log.levels.INFO) + end end --- Show the response window (re-open if hidden) function M.show() - if M.has_content() then - M.open() - else - vim.notify("Ghost: No response to show", vim.log.levels.INFO) - end + if M.has_content() then + M.open() + else + vim.notify("Ghost: No response to show", vim.log.levels.INFO) + end end --- Update the buffer content (full redraw for non-streaming updates) --- Used by tool calls, separators, headers, and transcript loading update_buffer = function() - if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then - return - end - - -- Mark for full redraw and trigger via timer or immediate - state.needs_full_redraw = true - - -- If timer is running, let it handle the redraw - if state.render_timer then - return - end - - -- Otherwise do immediate full redraw - vim.schedule(function() - if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then - return - end - - 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_set_option_value, "modifiable", true, { buf = state.buf }) - pcall(vim.api.nvim_buf_set_lines, state.buf, 0, -1, false, display_lines) - - -- Update render tracking - state.rendered_line_count = #state.lines - state.rendered_current_line = state.current_line ~= "" and state.current_line or nil - state.needs_full_redraw = false - - -- 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 }) - end - end) + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + return + end + + -- Mark for full redraw and trigger via timer or immediate + state.needs_full_redraw = true + + -- If timer is running, let it handle the redraw + if state.render_timer then + return + end + + -- Otherwise do immediate full redraw + vim.schedule(function() + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + return + end + + 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_set_option_value, "modifiable", true, { buf = state.buf }) + pcall(vim.api.nvim_buf_set_lines, state.buf, 0, -1, false, display_lines) + + -- Update render tracking + state.rendered_line_count = #state.lines + state.rendered_current_line = state.current_line ~= "" and state.current_line or nil + state.needs_full_redraw = false + + -- 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 }) + end + end) end --- Append text to the response (handles streaming) --- Text is queued and rendered smoothly via timer-based updates --- @param text string The text to append function M.append_text(text) - if not text or text == "" then - return - end - - -- Mark as streaming - state.is_streaming = true - state.is_complete = false - - -- Open window if not already open (don't steal focus if user closed it) - -- Only open window for first content; if user closed it, just buffer the content - if not M.is_open() and not M.has_content() and #state.pending_chunks == 0 then - -- First content - open and focus - M.open(true) - end - -- If window was closed but has content, we just queue the text - -- Content will be available when user reopens with ar - - -- Queue text for smooth rendering - table.insert(state.pending_chunks, text) - state.pending_bytes = state.pending_bytes + #text - - -- Start render timer if not already running - start_render_timer() + if not text or text == "" then + return + end + + -- Mark as streaming + state.is_streaming = true + state.is_complete = false + + -- Open window if not already open (don't steal focus if user closed it) + -- Only open window for first content; if user closed it, just buffer the content + if not M.is_open() and not M.has_content() and #state.pending_chunks == 0 then + -- First content - open and focus + M.open(true) + end + -- If window was closed but has content, we just queue the text + -- Content will be available when user reopens with ar + + -- Queue text for smooth rendering + table.insert(state.pending_chunks, text) + state.pending_bytes = state.pending_bytes + #text + + -- Start render timer if not already running + start_render_timer() end --- Add a tool call status line @@ -549,229 +548,229 @@ end --- @param status string Status (pending, in_progress, completed, failed) --- @param kind string|nil Tool kind (read, edit, execute, etc.) function M.update_tool_call(tool_id, tool_name, status, kind) - if not M.is_open() then - M.open() - end - - -- Format tool status line - local status_icon = ({ - pending = "ā³", - in_progress = "šŸ”„", - completed = "āœ…", - failed = "āŒ", - })[status] or "ā“" - - local kind_str = kind and (" [" .. kind .. "]") or "" - local line = string.format("%s %s%s", status_icon, tool_name, kind_str) - - -- Track tool calls to update existing lines - if state.tool_calls[tool_id] then - -- Update existing line - local line_num = state.tool_calls[tool_id].line_num - if line_num <= #state.lines then - state.lines[line_num] = line - end - else - -- Add new tool call line - -- Complete current line first if not empty - if state.current_line ~= "" then - table.insert(state.lines, state.current_line) - state.current_line = "" - end - table.insert(state.lines, line) - state.tool_calls[tool_id] = { - line_num = #state.lines, - tool_name = tool_name, - } - end - - update_buffer() + if not M.is_open() then + M.open() + end + + -- Format tool status line + local status_icon = ({ + pending = "ā³", + in_progress = "šŸ”„", + completed = "āœ…", + failed = "āŒ", + })[status] or "ā“" + + local kind_str = kind and (" [" .. kind .. "]") or "" + local line = string.format("%s %s%s", status_icon, tool_name, kind_str) + + -- Track tool calls to update existing lines + if state.tool_calls[tool_id] then + -- Update existing line + local line_num = state.tool_calls[tool_id].line_num + if line_num <= #state.lines then + state.lines[line_num] = line + end + else + -- Add new tool call line + -- Complete current line first if not empty + if state.current_line ~= "" then + table.insert(state.lines, state.current_line) + state.current_line = "" + end + table.insert(state.lines, line) + state.tool_calls[tool_id] = { + line_num = #state.lines, + tool_name = tool_name, + } + end + + update_buffer() end --- Flush all pending chunks synchronously (used before headers/separators) local function flush_pending_sync() - if #state.pending_chunks == 0 then - return - end - - -- Process all pending text immediately - local combined = table.concat(state.pending_chunks) - state.pending_chunks = {} - state.pending_bytes = 0 - - -- Process into lines - 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 + if #state.pending_chunks == 0 then + return + end + + -- Process all pending text immediately + local combined = table.concat(state.pending_chunks) + state.pending_chunks = {} + state.pending_bytes = 0 + + -- Process into lines + 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 --- Add a separator line function M.add_separator() - if not M.is_open() then - return - end + if not M.is_open() then + return + end - -- Flush any pending text first to maintain correct order - flush_pending_sync() + -- Flush any pending text first to maintain correct order + flush_pending_sync() - -- Complete current line first - if state.current_line ~= "" then - table.insert(state.lines, state.current_line) - state.current_line = "" - end + -- Complete current line first + if state.current_line ~= "" then + table.insert(state.lines, state.current_line) + state.current_line = "" + end - table.insert(state.lines, "---") - update_buffer() + table.insert(state.lines, "---") + update_buffer() end --- Add a header line --- @param header string The header text function M.add_header(header) - if not M.is_open() then - M.open() - end - - -- Flush any pending text first to maintain correct order - flush_pending_sync() - - -- Complete current line first - if state.current_line ~= "" then - table.insert(state.lines, state.current_line) - state.current_line = "" - end - - table.insert(state.lines, "## " .. header) - table.insert(state.lines, "") - update_buffer() + if not M.is_open() then + M.open() + end + + -- Flush any pending text first to maintain correct order + flush_pending_sync() + + -- Complete current line first + if state.current_line ~= "" then + table.insert(state.lines, state.current_line) + state.current_line = "" + end + + table.insert(state.lines, "## " .. header) + table.insert(state.lines, "") + update_buffer() end --- Clear the response buffer content (but keep window open if it is) function M.clear() - -- Stop any in-progress rendering - stop_render_timer() - - -- Reset all state - reset_state() - - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then - vim.schedule(function() - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then - pcall(vim.api.nvim_set_option_value, "modifiable", true, { buf = state.buf }) - pcall(vim.api.nvim_buf_set_lines, state.buf, 0, -1, false, { "" }) - end - end) - end + -- Stop any in-progress rendering + stop_render_timer() + + -- Reset all state + reset_state() + + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + vim.schedule(function() + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + pcall(vim.api.nvim_set_option_value, "modifiable", true, { buf = state.buf }) + pcall(vim.api.nvim_buf_set_lines, state.buf, 0, -1, false, { "" }) + end + end) + end end --- Get the current content --- @return string The content as a single string function M.get_content() - local all_lines = vim.deepcopy(state.lines) - if state.current_line ~= "" then - table.insert(all_lines, state.current_line) - end - return table.concat(all_lines, "\n") + local all_lines = vim.deepcopy(state.lines) + if state.current_line ~= "" then + table.insert(all_lines, state.current_line) + end + return table.concat(all_lines, "\n") end --- Handle a Ghost update event --- @param update table The update from receiver -function M.handle_update(update) - 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") - 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 - end - end +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") + 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 + end + end end --- Handle completion of a response --- @param response table The final response from receiver function M.handle_response(response) - -- Flush all pending chunks immediately before completion - flush_pending_sync() - - -- Stop the render timer - stop_render_timer() - - -- Complete current line first (before adding completion indicators) - if state.current_line ~= "" then - table.insert(state.lines, state.current_line) - state.current_line = "" - end - - -- Add completion indicators directly (bypass append_text to avoid resetting state) - if response.type == "explanation" then - table.insert(state.lines, "---") - table.insert(state.lines, "*Response complete*") - elseif response.type == "edit" then - table.insert(state.lines, "---") - table.insert(state.lines, string.format("šŸ“ *Edited: %s*", response.file_path or "unknown")) - end - - -- Mark as complete (after adding content so it's not overwritten) - state.is_streaming = false - state.is_complete = true - - -- Force full redraw to ensure everything is displayed - state.needs_full_redraw = true - update_buffer() - - -- Notify user if window is closed - if not M.is_open() and M.has_content() then - vim.schedule(function() - vim.notify("Ghost: Response complete. Press ar to view.", vim.log.levels.INFO) - end) - end + -- Flush all pending chunks immediately before completion + flush_pending_sync() + + -- Stop the render timer + stop_render_timer() + + -- Complete current line first (before adding completion indicators) + if state.current_line ~= "" then + table.insert(state.lines, state.current_line) + state.current_line = "" + end + + -- Add completion indicators directly (bypass append_text to avoid resetting state) + if response.type == "explanation" then + table.insert(state.lines, "---") + table.insert(state.lines, "*Response complete*") + elseif response.type == "edit" then + table.insert(state.lines, "---") + table.insert(state.lines, string.format("šŸ“ *Edited: %s*", response.file_path or "unknown")) + end + + -- Mark as complete (after adding content so it's not overwritten) + state.is_streaming = false + state.is_complete = true + + -- Force full redraw to ensure everything is displayed + state.needs_full_redraw = true + update_buffer() + + -- Notify user if window is closed + if not M.is_open() and M.has_content() then + vim.schedule(function() + vim.notify("Ghost: Response complete. Press ar to view.", vim.log.levels.INFO) + end) + end end --- Set callback for when user wants to reply --- @param callback fun()|nil Callback function function M.set_on_reply(callback) - state.on_reply = callback + state.on_reply = callback end --- Set the current session ID being displayed --- @param session_id string|nil Session ID function M.set_current_session(session_id) - state.current_session_id = session_id + state.current_session_id = session_id end --- Get the current session ID being displayed --- @return string|nil Session ID function M.get_current_session() - return state.current_session_id + return state.current_session_id end --- Load transcript content from disk for a session @@ -779,37 +778,37 @@ end --- @return boolean success True if loaded successfully --- @return string|nil error Error message if load failed function M.load_transcript(session_id) - local transcript = require("ghost.transcript") - - -- Read transcript from disk - local content, err = transcript.read_transcript(session_id) - if not content then - return false, err - end - - -- Clear current response content (also stops timer and resets state) - M.clear() - - -- Split content into lines and populate the response buffer - local lines = vim.split(content, "\n", { plain = true }) - - -- Set the lines directly into the state - state.lines = lines - state.current_line = "" - state.is_streaming = false - state.is_complete = true - state.current_session_id = session_id -- Track which session is displayed (US-008) - -- Mark for full redraw since we loaded content directly - state.needs_full_redraw = true - state.rendered_line_count = 0 - state.rendered_current_line = nil - - -- Update buffer if window is open - if M.is_open() then - update_buffer() - end - - return true, nil + local transcript = require("ghost.transcript") + + -- Read transcript from disk + local content, err = transcript.read_transcript(session_id) + if not content then + return false, err + end + + -- Clear current response content (also stops timer and resets state) + M.clear() + + -- Split content into lines and populate the response buffer + local lines = vim.split(content, "\n", { plain = true }) + + -- Set the lines directly into the state + state.lines = lines + state.current_line = "" + state.is_streaming = false + state.is_complete = true + state.current_session_id = session_id -- Track which session is displayed (US-008) + -- Mark for full redraw since we loaded content directly + state.needs_full_redraw = true + state.rendered_line_count = 0 + state.rendered_current_line = nil + + -- Update buffer if window is open + if M.is_open() then + update_buffer() + end + + return true, nil end return M diff --git a/lua/ghost/session.lua b/lua/ghost/session.lua index 9830db2..d9f1f48 100644 --- a/lua/ghost/session.lua +++ b/lua/ghost/session.lua @@ -101,7 +101,7 @@ function M.create_session(opts) s.acp_initialized = false end end, - on_error = function(error_msg) + on_error = function(_error_msg) local s = M.sessions[session_id] if s then s.status = "error" diff --git a/lua/ghost/status.lua b/lua/ghost/status.lua index cf61b43..22a979f 100644 --- a/lua/ghost/status.lua +++ b/lua/ghost/status.lua @@ -229,7 +229,7 @@ end --- @param mark_as_error boolean|nil If true, mark them as errors instead of just removing function M.clear_active(mark_as_error) if mark_as_error then - for request_id, req in pairs(state.requests) do + for _, req in pairs(state.requests) do req.completed_at = os.time() req.status = "error" req.error_message = "Request cleared (timed out or disconnected)" @@ -367,7 +367,7 @@ end --- Build the content lines for the status window --- @return string[] Lines to display in the status window -function M.build_status_content() +function M.build_status_content() -- luacheck: ignore 561 local acp = require("ghost.acp") local lines = {} diff --git a/lua/ghost/test.lua b/lua/ghost/test.lua index 8088a46..384dd64 100644 --- a/lua/ghost/test.lua +++ b/lua/ghost/test.lua @@ -60,7 +60,7 @@ function M.test_prompt(prompt_text) local output_lines = {} acp.send_prompt(prompt_text, nil, { - on_update = function(update) + 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 @@ -367,7 +367,7 @@ function M.run_all() print("[1/3] Testing connection...") local acp = require("ghost.acp") - acp.initialize(function(err, capabilities) + acp.initialize(function(err, _capabilities) if err then print("FAIL: Connection - " .. err) return diff --git a/lua/ghost/transcript.lua b/lua/ghost/transcript.lua index 74c4ea4..9a55db0 100644 --- a/lua/ghost/transcript.lua +++ b/lua/ghost/transcript.lua @@ -213,11 +213,7 @@ end --- @return string|nil error Error message if write failed function M.write_error(session_id, error_message) local timestamp = os.time() - local line = string.format( - "\nāŒ **Error** (%s): %s\n\n", - format_timestamp(timestamp), - error_message - ) + local line = string.format("\nāŒ **Error** (%s): %s\n\n", format_timestamp(timestamp), error_message) -- Flush any pending response text before writing error M.flush_response_buffer(session_id)