My nvim configuration
refactor: claude has it's own cli wrapper
9 files changed, +953 -229
+1-0claude-nvim/lua/claude/edit.lua
| @@ -121,6 +121,7 @@ function M.prompt_visual() | ||
| 121 | 121 | local function close_popup() |
| 122 | 122 | if closed then return end |
| 123 | 123 | closed = true |
| 124 | + pcall(vim.cmd, "stopinsert") | |
| 124 | 125 | if vim.api.nvim_win_is_valid(pop_win) then |
| 125 | 126 | vim.api.nvim_win_close(pop_win, true) |
| 126 | 127 | end |
+77-0claude-nvim/lua/claude/highlights.lua
| @@ -0,0 +1,77 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local store = require("claude.store") | |
| 4 | +local ns = vim.api.nvim_create_namespace("claude_changes") | |
| 5 | +local cfg = { highlight = true, blame = true } | |
| 6 | + | |
| 7 | +function M.setup(opts) | |
| 8 | + cfg.highlight = opts.highlight ~= false | |
| 9 | + cfg.blame = opts.blame ~= false | |
| 10 | + | |
| 11 | + vim.api.nvim_set_hl(0, "ClaudeChange", { default = true, bg = "#3a2410" }) | |
| 12 | + vim.api.nvim_set_hl(0, "ClaudeChangeSign", { default = true, fg = "#e07b00" }) | |
| 13 | + vim.api.nvim_set_hl(0, "ClaudeBlame", { default = true, fg = "#7f5a2a", italic = true }) | |
| 14 | + | |
| 15 | + vim.api.nvim_create_autocmd({ "BufReadPost", "BufWritePost" }, { | |
| 16 | + callback = function(args) | |
| 17 | + M.apply_to_buf(args.buf) | |
| 18 | + end, | |
| 19 | + }) | |
| 20 | +end | |
| 21 | + | |
| 22 | +local function collect_for_path(abs_path) | |
| 23 | + local out = {} | |
| 24 | + for _, p in ipairs(store.all_prompts()) do | |
| 25 | + for _, f in ipairs(p.files or {}) do | |
| 26 | + if f.path == abs_path then | |
| 27 | + for _, h in ipairs(f.hunks or {}) do | |
| 28 | + table.insert(out, { | |
| 29 | + start = h.start, | |
| 30 | + finish = h.finish, | |
| 31 | + prompt = p.text or "", | |
| 32 | + ts = p.ts or 0, | |
| 33 | + }) | |
| 34 | + end | |
| 35 | + end | |
| 36 | + end | |
| 37 | + end | |
| 38 | + return out | |
| 39 | +end | |
| 40 | + | |
| 41 | +function M.apply_to_buf(buf) | |
| 42 | + if not vim.api.nvim_buf_is_valid(buf) then return end | |
| 43 | + if not (cfg.highlight or cfg.blame) then return end | |
| 44 | + local name = vim.api.nvim_buf_get_name(buf) | |
| 45 | + if name == "" then return end | |
| 46 | + if vim.bo[buf].buftype ~= "" then return end | |
| 47 | + local abs = vim.fn.fnamemodify(name, ":p") | |
| 48 | + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) | |
| 49 | + local hunks = collect_for_path(abs) | |
| 50 | + if #hunks == 0 then return end | |
| 51 | + local lc = vim.api.nvim_buf_line_count(buf) | |
| 52 | + for _, h in ipairs(hunks) do | |
| 53 | + local s = math.max(0, (h.start or 1) - 1) | |
| 54 | + local e = math.min(lc - 1, (h.finish or h.start or 1) - 1) | |
| 55 | + if e < s then e = s end | |
| 56 | + for ln = s, e do | |
| 57 | + local opts = {} | |
| 58 | + if cfg.highlight then opts.line_hl_group = "ClaudeChange" end | |
| 59 | + if cfg.blame and ln == s then | |
| 60 | + local snippet = (h.prompt or ""):gsub("%s+", " "):sub(1, 80) | |
| 61 | + opts.virt_text = { { " ▏ " .. snippet, "ClaudeBlame" } } | |
| 62 | + opts.virt_text_pos = "eol" | |
| 63 | + end | |
| 64 | + pcall(vim.api.nvim_buf_set_extmark, buf, ns, ln, 0, opts) | |
| 65 | + end | |
| 66 | + end | |
| 67 | +end | |
| 68 | + | |
| 69 | +function M.refresh_all() | |
| 70 | + for _, b in ipairs(vim.api.nvim_list_bufs()) do | |
| 71 | + if vim.api.nvim_buf_is_loaded(b) then | |
| 72 | + M.apply_to_buf(b) | |
| 73 | + end | |
| 74 | + end | |
| 75 | +end | |
| 76 | + | |
| 77 | +return M |
+19-39claude-nvim/lua/claude/init.lua
| @@ -1,65 +1,45 @@ | ||
| 1 | 1 | local M = {} |
| 2 | 2 | |
| 3 | -local terminal = require("claude.terminal") | |
| 4 | -local session = require("claude.session") | |
| 3 | +local ui = require("claude.ui") | |
| 4 | +local runner = require("claude.runner") | |
| 5 | +local highlights = require("claude.highlights") | |
| 6 | +local pickers = require("claude.pickers") | |
| 5 | 7 | local edit = require("claude.edit") |
| 6 | 8 | |
| 7 | 9 | ---@class ClaudeConfig |
| 8 | 10 | local defaults = { |
| 9 | - --- Width of the Claude terminal split (default: 80) | |
| 11 | + --- Width of the Claude panel (default: 80) | |
| 10 | 12 | width = 80, |
| 11 | 13 | --- Add --dangerously-skip-permissions flag to all claude commands (default: false) |
| 12 | 14 | skip_permissions = false, |
| 13 | - --- Restore Claude terminal on nvim startup if it was open last session (default: false) | |
| 14 | - open_on_start = false, | |
| 15 | + --- Highlight claude-changed lines like gitsigns (default: true) | |
| 16 | + highlight = true, | |
| 17 | + --- Inline blame: show prompt text on changed lines (default: true) | |
| 18 | + blame = true, | |
| 19 | + --- Write raw stream events + cmd + exit codes to <data>/claude-nvim/debug.log | |
| 20 | + debug = false, | |
| 15 | 21 | } |
| 16 | 22 | |
| 17 | 23 | --- Setup the Claude CLI wrapper plugin. |
| 18 | 24 | ---@param opts? ClaudeConfig |
| 19 | 25 | function M.setup(opts) |
| 20 | 26 | opts = vim.tbl_deep_extend("force", defaults, opts or {}) |
| 21 | - terminal.setup(opts) | |
| 22 | - session.setup(opts) | |
| 27 | + ui.setup(opts) | |
| 28 | + runner.setup(opts) | |
| 29 | + highlights.setup(opts) | |
| 23 | 30 | edit.setup(opts) |
| 24 | 31 | |
| 25 | 32 | vim.keymap.set("n", "<leader>c", "", { desc = "Claude" }) |
| 26 | 33 | |
| 27 | - -- Toggle claude panel (resumes last session) | |
| 28 | - vim.keymap.set("n", "<leader>ct", session.continue, { desc = "Claude: Toggle panel" }) | |
| 34 | + vim.keymap.set("n", "<leader>ct", ui.toggle, { desc = "Claude: Toggle panel" }) | |
| 35 | + vim.keymap.set("n", "<leader>cn", ui.start_new, { desc = "Claude: New session" }) | |
| 36 | + vim.keymap.set("n", "<leader>cr", ui.resume_last, { desc = "Claude: Resume last session" }) | |
| 37 | + vim.keymap.set("n", "<leader>cq", pickers.prompts_picker, { desc = "Claude: Prompts → quickfix" }) | |
| 38 | + vim.keymap.set("n", "<leader>cl", pickers.qf_last, { desc = "Claude: Last prompt → quickfix" }) | |
| 29 | 39 | |
| 30 | - -- Visual <leader>c → prompt popup that edits selection in place via claude -p | |
| 31 | 40 | vim.keymap.set("v", "<leader>c", function() |
| 32 | 41 | edit.prompt_visual() |
| 33 | 42 | end, { desc = "Claude: Edit selection with prompt" }) |
| 34 | - | |
| 35 | - -- Send current buffer to claude | |
| 36 | - vim.keymap.set("n", "<leader>cb", function() | |
| 37 | - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) | |
| 38 | - local content = table.concat(lines, "\n") | |
| 39 | - local filename = vim.fn.expand("%:t") | |
| 40 | - | |
| 41 | - if not terminal.is_open() then | |
| 42 | - session.continue() | |
| 43 | - vim.defer_fn(function() | |
| 44 | - terminal.send("Here is " .. filename .. ":\n\n" .. content .. "\n") | |
| 45 | - end, 500) | |
| 46 | - else | |
| 47 | - terminal.send("Here is " .. filename .. ":\n\n" .. content .. "\n") | |
| 48 | - end | |
| 49 | - end, { desc = "Claude: Send buffer" }) | |
| 50 | - | |
| 51 | - -- New session | |
| 52 | - vim.keymap.set("n", "<leader>cn", session.new, { desc = "Claude: New session" }) | |
| 53 | - | |
| 54 | - -- Resume picker | |
| 55 | - vim.keymap.set("n", "<leader>cr", session.resume, { desc = "Claude: Resume session picker" }) | |
| 56 | - | |
| 57 | - -- Restore claude terminal on start | |
| 58 | - if opts.open_on_start and terminal.load_state() then | |
| 59 | - vim.defer_fn(function() | |
| 60 | - session.continue() | |
| 61 | - end, 100) | |
| 62 | - end | |
| 63 | 43 | end |
| 64 | 44 | |
| 65 | 45 | return M |
+115-0claude-nvim/lua/claude/pickers.lua
| @@ -0,0 +1,115 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local store = require("claude.store") | |
| 4 | + | |
| 5 | +local function snippet(text, n) | |
| 6 | + return (text or ""):gsub("%s+", " "):sub(1, n or 60) | |
| 7 | +end | |
| 8 | + | |
| 9 | +function M.set_qf_from_prompt(p) | |
| 10 | + local items = {} | |
| 11 | + for _, f in ipairs(p.files or {}) do | |
| 12 | + for _, h in ipairs(f.hunks or {}) do | |
| 13 | + table.insert(items, { | |
| 14 | + filename = f.path, | |
| 15 | + lnum = h.start or 1, | |
| 16 | + end_lnum = h.finish or h.start or 1, | |
| 17 | + text = snippet(p.text, 80), | |
| 18 | + }) | |
| 19 | + end | |
| 20 | + end | |
| 21 | + if #items == 0 then | |
| 22 | + vim.notify("claude: prompt has no recorded changes", vim.log.levels.WARN) | |
| 23 | + return | |
| 24 | + end | |
| 25 | + vim.fn.setqflist({}, " ", { | |
| 26 | + title = "Claude: " .. snippet(p.text, 60), | |
| 27 | + items = items, | |
| 28 | + }) | |
| 29 | + vim.cmd("copen") | |
| 30 | +end | |
| 31 | + | |
| 32 | +function M.qf_last() | |
| 33 | + local prompts = store.all_prompts() | |
| 34 | + if #prompts == 0 then | |
| 35 | + vim.notify("claude: no prompts have caused changes yet", vim.log.levels.WARN) | |
| 36 | + return | |
| 37 | + end | |
| 38 | + M.set_qf_from_prompt(prompts[1]) | |
| 39 | +end | |
| 40 | + | |
| 41 | +function M.prompts_picker() | |
| 42 | + local ok_t, _ = pcall(require, "telescope") | |
| 43 | + if not ok_t then | |
| 44 | + vim.notify("claude: telescope.nvim not available", vim.log.levels.ERROR) | |
| 45 | + return | |
| 46 | + end | |
| 47 | + local pickers = require("telescope.pickers") | |
| 48 | + local finders = require("telescope.finders") | |
| 49 | + local actions = require("telescope.actions") | |
| 50 | + local action_state = require("telescope.actions.state") | |
| 51 | + local previewers = require("telescope.previewers") | |
| 52 | + local conf = require("telescope.config").values | |
| 53 | + | |
| 54 | + local prompts = store.all_prompts() | |
| 55 | + if #prompts == 0 then | |
| 56 | + vim.notify("claude: no prompts have caused changes yet", vim.log.levels.WARN) | |
| 57 | + return | |
| 58 | + end | |
| 59 | + | |
| 60 | + pickers.new({}, { | |
| 61 | + prompt_title = "Claude prompts (" .. #prompts .. ")", | |
| 62 | + finder = finders.new_table({ | |
| 63 | + results = prompts, | |
| 64 | + entry_maker = function(p) | |
| 65 | + local nfiles = #(p.files or {}) | |
| 66 | + local display = string.format( | |
| 67 | + "%s (%d file%s) %s", | |
| 68 | + os.date("%Y-%m-%d %H:%M", p.ts or 0), | |
| 69 | + nfiles, | |
| 70 | + nfiles == 1 and "" or "s", | |
| 71 | + snippet(p.text, 70) | |
| 72 | + ) | |
| 73 | + return { | |
| 74 | + value = p, | |
| 75 | + display = display, | |
| 76 | + ordinal = (p.text or "") .. " " .. tostring(p.ts or ""), | |
| 77 | + } | |
| 78 | + end, | |
| 79 | + }), | |
| 80 | + sorter = conf.generic_sorter({}), | |
| 81 | + previewer = previewers.new_buffer_previewer({ | |
| 82 | + define_preview = function(self, entry) | |
| 83 | + local lines = { | |
| 84 | + "# Prompt", | |
| 85 | + "", | |
| 86 | + } | |
| 87 | + for _, l in ipairs(vim.split(entry.value.text or "", "\n", { plain = true })) do | |
| 88 | + table.insert(lines, l) | |
| 89 | + end | |
| 90 | + table.insert(lines, "") | |
| 91 | + for _, f in ipairs(entry.value.files or {}) do | |
| 92 | + table.insert(lines, "diff --git a/" .. f.path .. " b/" .. f.path) | |
| 93 | + for _, line in ipairs(vim.split(f.diff or "", "\n", { plain = true })) do | |
| 94 | + table.insert(lines, line) | |
| 95 | + end | |
| 96 | + table.insert(lines, "") | |
| 97 | + end | |
| 98 | + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) | |
| 99 | + vim.bo[self.state.bufnr].filetype = "diff" | |
| 100 | + end, | |
| 101 | + }), | |
| 102 | + attach_mappings = function(prompt_bufnr) | |
| 103 | + actions.select_default:replace(function() | |
| 104 | + local entry = action_state.get_selected_entry() | |
| 105 | + actions.close(prompt_bufnr) | |
| 106 | + if entry and entry.value then | |
| 107 | + M.set_qf_from_prompt(entry.value) | |
| 108 | + end | |
| 109 | + end) | |
| 110 | + return true | |
| 111 | + end, | |
| 112 | + }):find() | |
| 113 | +end | |
| 114 | + | |
| 115 | +return M |
+310-0claude-nvim/lua/claude/runner.lua
| @@ -0,0 +1,310 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local store = require("claude.store") | |
| 4 | + | |
| 5 | +local skip_permissions = false | |
| 6 | +local debug_log = false | |
| 7 | +local active = {} | |
| 8 | + | |
| 9 | +local function log(tag, line) | |
| 10 | + if not debug_log then return end | |
| 11 | + local path = vim.fn.stdpath("data") .. "/claude-nvim/debug.log" | |
| 12 | + local f = io.open(path, "a") | |
| 13 | + if not f then return end | |
| 14 | + f:write(os.date("%H:%M:%S ") .. tag .. " " .. tostring(line) .. "\n") | |
| 15 | + f:close() | |
| 16 | +end | |
| 17 | + | |
| 18 | +function M.setup(opts) | |
| 19 | + skip_permissions = opts.skip_permissions == true | |
| 20 | + debug_log = opts.debug == true | |
| 21 | +end | |
| 22 | + | |
| 23 | +local function read_file(path) | |
| 24 | + local f = io.open(path, "rb") | |
| 25 | + if not f then return "" end | |
| 26 | + local c = f:read("*a") or "" | |
| 27 | + f:close() | |
| 28 | + return c | |
| 29 | +end | |
| 30 | + | |
| 31 | +local function abs_path(path) | |
| 32 | + if not path or path == "" then return nil end | |
| 33 | + if path:sub(1, 1) ~= "/" then | |
| 34 | + path = vim.fn.getcwd() .. "/" .. path | |
| 35 | + end | |
| 36 | + return vim.fn.fnamemodify(path, ":p") | |
| 37 | +end | |
| 38 | + | |
| 39 | +local function compute_change(before, after) | |
| 40 | + if before == after then return nil end | |
| 41 | + local indices = vim.diff(before, after, { result_type = "indices", algorithm = "histogram" }) | |
| 42 | + local unified = vim.diff(before, after, { algorithm = "histogram" }) | |
| 43 | + local hunks = {} | |
| 44 | + if type(indices) == "table" then | |
| 45 | + for _, h in ipairs(indices) do | |
| 46 | + local start_b = h[3] | |
| 47 | + local count_b = h[4] | |
| 48 | + local s, e | |
| 49 | + if count_b == 0 then | |
| 50 | + s = math.max(1, start_b) | |
| 51 | + e = s | |
| 52 | + else | |
| 53 | + s = start_b | |
| 54 | + e = start_b + count_b - 1 | |
| 55 | + end | |
| 56 | + table.insert(hunks, { start = s, finish = e }) | |
| 57 | + end | |
| 58 | + end | |
| 59 | + if #hunks == 0 then return nil end | |
| 60 | + return hunks, unified or "" | |
| 61 | +end | |
| 62 | + | |
| 63 | +local function persist_pointer(st) | |
| 64 | + store.write_pointer({ | |
| 65 | + session_id = st.session.id, | |
| 66 | + claude_id = st.session.claude_id, | |
| 67 | + ts = os.time(), | |
| 68 | + cwd = st.session.cwd, | |
| 69 | + }) | |
| 70 | +end | |
| 71 | + | |
| 72 | +local function flush_prompt(sid) | |
| 73 | + local st = active[sid] | |
| 74 | + if not st or not st.current_prompt then return end | |
| 75 | + local p = st.current_prompt | |
| 76 | + local files = {} | |
| 77 | + for path, before in pairs(st.before_files) do | |
| 78 | + local after = read_file(path) | |
| 79 | + local hunks, unified = compute_change(before, after) | |
| 80 | + if hunks then | |
| 81 | + table.insert(files, { path = path, hunks = hunks, diff = unified }) | |
| 82 | + end | |
| 83 | + end | |
| 84 | + st.current_prompt = nil | |
| 85 | + st.before_files = {} | |
| 86 | + | |
| 87 | + if #files > 0 then | |
| 88 | + p.files = files | |
| 89 | + table.insert(st.session.prompts, p) | |
| 90 | + store.save(st.session) | |
| 91 | + vim.schedule(function() | |
| 92 | + local ok_h, hl = pcall(require, "claude.highlights") | |
| 93 | + if ok_h then hl.refresh_all() end | |
| 94 | + end) | |
| 95 | + end | |
| 96 | + | |
| 97 | + vim.schedule(function() | |
| 98 | + local ok_u, ui = pcall(require, "claude.ui") | |
| 99 | + if ok_u then ui.on_prompt_complete(sid, p, #files > 0) end | |
| 100 | + end) | |
| 101 | +end | |
| 102 | + | |
| 103 | +local function ensure_before(sid, path) | |
| 104 | + local st = active[sid] | |
| 105 | + if not st or not path then return end | |
| 106 | + if st.before_files[path] == nil then | |
| 107 | + st.before_files[path] = read_file(path) | |
| 108 | + end | |
| 109 | +end | |
| 110 | + | |
| 111 | +local FILE_TOOLS = { Edit = true, Write = true, MultiEdit = true, NotebookEdit = true } | |
| 112 | + | |
| 113 | +local function handle_event(sid, evt) | |
| 114 | + local st = active[sid] | |
| 115 | + if not st then return end | |
| 116 | + local ok_u, ui = pcall(require, "claude.ui") | |
| 117 | + if not ok_u then return end | |
| 118 | + | |
| 119 | + local t = evt.type | |
| 120 | + if t == "system" and evt.subtype == "init" then | |
| 121 | + if evt.session_id then | |
| 122 | + st.session.claude_id = evt.session_id | |
| 123 | + persist_pointer(st) | |
| 124 | + end | |
| 125 | + elseif t == "assistant" and evt.message then | |
| 126 | + local msg = evt.message | |
| 127 | + if msg.content then | |
| 128 | + for _, block in ipairs(msg.content) do | |
| 129 | + if block.type == "text" and block.text then | |
| 130 | + ui.append_assistant_text(sid, block.text) | |
| 131 | + elseif block.type == "tool_use" then | |
| 132 | + local input = block.input or {} | |
| 133 | + local path = abs_path(input.file_path) | |
| 134 | + if path and FILE_TOOLS[block.name] then | |
| 135 | + ensure_before(sid, path) | |
| 136 | + end | |
| 137 | + ui.append_tool_use(sid, block.name or "tool", path or "") | |
| 138 | + end | |
| 139 | + end | |
| 140 | + end | |
| 141 | + elseif t == "user" and evt.message then | |
| 142 | + -- tool results, ignore content for now | |
| 143 | + elseif t == "result" then | |
| 144 | + flush_prompt(sid) | |
| 145 | + ui.append_separator(sid) | |
| 146 | + elseif t == "stream_event" then | |
| 147 | + local e = evt.event | |
| 148 | + if e and e.delta and e.delta.text then | |
| 149 | + ui.append_assistant_text(sid, e.delta.text) | |
| 150 | + end | |
| 151 | + end | |
| 152 | +end | |
| 153 | + | |
| 154 | +local function dispatch_line(sid, line) | |
| 155 | + if line == "" then return end | |
| 156 | + log("OUT", line) | |
| 157 | + local ok, evt = pcall(vim.json.decode, line) | |
| 158 | + if ok and type(evt) == "table" then | |
| 159 | + handle_event(sid, evt) | |
| 160 | + else | |
| 161 | + log("PARSE_FAIL", line) | |
| 162 | + end | |
| 163 | +end | |
| 164 | + | |
| 165 | +local function on_stdout(sid, data) | |
| 166 | + if not data then return end | |
| 167 | + local st = active[sid] | |
| 168 | + if not st then return end | |
| 169 | + st.line_buf = st.line_buf or "" | |
| 170 | + -- jobstart splits on \n: each callback gets list of lines; last element is partial continuation. | |
| 171 | + for i, chunk in ipairs(data) do | |
| 172 | + if i == 1 then | |
| 173 | + st.line_buf = st.line_buf .. chunk | |
| 174 | + else | |
| 175 | + dispatch_line(sid, st.line_buf) | |
| 176 | + st.line_buf = chunk | |
| 177 | + end | |
| 178 | + end | |
| 179 | +end | |
| 180 | + | |
| 181 | +local function flush_line_buf(sid) | |
| 182 | + local st = active[sid] | |
| 183 | + if not st then return end | |
| 184 | + if st.line_buf and st.line_buf ~= "" then | |
| 185 | + dispatch_line(sid, st.line_buf) | |
| 186 | + st.line_buf = "" | |
| 187 | + end | |
| 188 | +end | |
| 189 | + | |
| 190 | +function M.start_session() | |
| 191 | + local id = store.uuid() | |
| 192 | + local session = { | |
| 193 | + id = id, | |
| 194 | + started_at = os.time(), | |
| 195 | + cwd = vim.fn.getcwd(), | |
| 196 | + claude_id = nil, | |
| 197 | + prompts = {}, | |
| 198 | + } | |
| 199 | + active[id] = { | |
| 200 | + session = session, | |
| 201 | + line_buf = "", | |
| 202 | + before_files = {}, | |
| 203 | + current_prompt = nil, | |
| 204 | + job = nil, | |
| 205 | + } | |
| 206 | + return id | |
| 207 | +end | |
| 208 | + | |
| 209 | +function M.attach_existing(session) | |
| 210 | + active[session.id] = { | |
| 211 | + session = session, | |
| 212 | + line_buf = "", | |
| 213 | + before_files = {}, | |
| 214 | + current_prompt = nil, | |
| 215 | + job = nil, | |
| 216 | + } | |
| 217 | + return session.id | |
| 218 | +end | |
| 219 | + | |
| 220 | +function M.resume_from_pointer() | |
| 221 | + local ptr = store.read_pointer() | |
| 222 | + if not ptr then return nil end | |
| 223 | + local existing = ptr.session_id and store.load(ptr.session_id) or nil | |
| 224 | + if existing then | |
| 225 | + return M.attach_existing(existing) | |
| 226 | + end | |
| 227 | + -- session never wrote files; rebuild a thin session object so we can keep using claude_id | |
| 228 | + local session = { | |
| 229 | + id = ptr.session_id or store.uuid(), | |
| 230 | + started_at = ptr.ts or os.time(), | |
| 231 | + cwd = ptr.cwd or vim.fn.getcwd(), | |
| 232 | + claude_id = ptr.claude_id, | |
| 233 | + prompts = {}, | |
| 234 | + } | |
| 235 | + return M.attach_existing(session) | |
| 236 | +end | |
| 237 | + | |
| 238 | +function M.is_busy(sid) | |
| 239 | + local st = active[sid] | |
| 240 | + return st and st.job ~= nil | |
| 241 | +end | |
| 242 | + | |
| 243 | +function M.send(sid, prompt_text) | |
| 244 | + local st = active[sid] | |
| 245 | + if not st then return false, "no session" end | |
| 246 | + if st.job then return false, "previous prompt still running" end | |
| 247 | + | |
| 248 | + local prompt_id = store.uuid() | |
| 249 | + st.current_prompt = { id = prompt_id, text = prompt_text, ts = os.time(), files = {} } | |
| 250 | + st.before_files = {} | |
| 251 | + | |
| 252 | + local cmd = { "claude", "-p", "--output-format", "stream-json", "--verbose" } | |
| 253 | + if st.session.claude_id then | |
| 254 | + table.insert(cmd, "--resume") | |
| 255 | + table.insert(cmd, st.session.claude_id) | |
| 256 | + end | |
| 257 | + if skip_permissions then | |
| 258 | + table.insert(cmd, "--dangerously-skip-permissions") | |
| 259 | + end | |
| 260 | + table.insert(cmd, prompt_text) | |
| 261 | + | |
| 262 | + local ok_u, ui = pcall(require, "claude.ui") | |
| 263 | + if ok_u then ui.append_user_prompt(sid, prompt_text) end | |
| 264 | + | |
| 265 | + log("CMD", table.concat(cmd, " | ")) | |
| 266 | + | |
| 267 | + st.job = vim.fn.jobstart(cmd, { | |
| 268 | + stdout_buffered = false, | |
| 269 | + on_stdout = function(_, data) on_stdout(sid, data) end, | |
| 270 | + on_stderr = function(_, data) | |
| 271 | + if not data then return end | |
| 272 | + for _, l in ipairs(data) do | |
| 273 | + if l ~= "" then | |
| 274 | + log("ERR", l) | |
| 275 | + vim.schedule(function() | |
| 276 | + if ok_u then ui.append_stderr(sid, l) end | |
| 277 | + end) | |
| 278 | + end | |
| 279 | + end | |
| 280 | + end, | |
| 281 | + on_exit = function(_, code) | |
| 282 | + log("EXIT", code) | |
| 283 | + flush_line_buf(sid) | |
| 284 | + st.job = nil | |
| 285 | + if st.current_prompt then | |
| 286 | + flush_prompt(sid) | |
| 287 | + end | |
| 288 | + persist_pointer(st) | |
| 289 | + if code ~= 0 then | |
| 290 | + vim.schedule(function() | |
| 291 | + vim.notify("claude exited " .. tostring(code), vim.log.levels.ERROR) | |
| 292 | + end) | |
| 293 | + end | |
| 294 | + end, | |
| 295 | + }) | |
| 296 | + if st.job <= 0 then | |
| 297 | + st.job = nil | |
| 298 | + st.current_prompt = nil | |
| 299 | + return false, "failed to spawn claude" | |
| 300 | + end | |
| 301 | + pcall(vim.fn.chanclose, st.job, "stdin") | |
| 302 | + return true | |
| 303 | +end | |
| 304 | + | |
| 305 | +function M.get_session(sid) | |
| 306 | + local st = active[sid] | |
| 307 | + return st and st.session or nil | |
| 308 | +end | |
| 309 | + | |
| 310 | +return M |
+0-27claude-nvim/lua/claude/session.lua
| @@ -1,27 +0,0 @@ | ||
| 1 | -local M = {} | |
| 2 | - | |
| 3 | -local terminal = require("claude.terminal") | |
| 4 | - | |
| 5 | -local skip_flag = "" | |
| 6 | - | |
| 7 | -function M.setup(opts) | |
| 8 | - if opts.skip_permissions then | |
| 9 | - skip_flag = " --dangerously-skip-permissions" | |
| 10 | - end | |
| 11 | -end | |
| 12 | - | |
| 13 | -function M.continue() | |
| 14 | - terminal.toggle("claude --continue" .. skip_flag) | |
| 15 | -end | |
| 16 | - | |
| 17 | -function M.new() | |
| 18 | - terminal.kill() | |
| 19 | - terminal.open("claude" .. skip_flag) | |
| 20 | -end | |
| 21 | - | |
| 22 | -function M.resume() | |
| 23 | - terminal.kill() | |
| 24 | - terminal.open("claude --resume" .. skip_flag) | |
| 25 | -end | |
| 26 | - | |
| 27 | -return M |
+105-0claude-nvim/lua/claude/store.lua
| @@ -0,0 +1,105 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local function data_dir() | |
| 4 | + local d = vim.fn.stdpath("data") .. "/claude-nvim" | |
| 5 | + vim.fn.mkdir(d, "p") | |
| 6 | + return d | |
| 7 | +end | |
| 8 | + | |
| 9 | +local function cwd_dir() | |
| 10 | + local cwd = vim.fn.getcwd() | |
| 11 | + local enc = cwd:gsub("/", "%%") | |
| 12 | + local d = data_dir() .. "/" .. enc | |
| 13 | + vim.fn.mkdir(d, "p") | |
| 14 | + return d | |
| 15 | +end | |
| 16 | + | |
| 17 | +function M.session_path(id) | |
| 18 | + return cwd_dir() .. "/session-" .. id .. ".json" | |
| 19 | +end | |
| 20 | + | |
| 21 | +local function pointer_path() | |
| 22 | + return cwd_dir() .. "/last.json" | |
| 23 | +end | |
| 24 | + | |
| 25 | +function M.write_pointer(info) | |
| 26 | + local f = io.open(pointer_path(), "w") | |
| 27 | + if not f then return false end | |
| 28 | + f:write(vim.json.encode(info)) | |
| 29 | + f:close() | |
| 30 | + return true | |
| 31 | +end | |
| 32 | + | |
| 33 | +function M.read_pointer() | |
| 34 | + local f = io.open(pointer_path(), "r") | |
| 35 | + if not f then return nil end | |
| 36 | + local content = f:read("*a") | |
| 37 | + f:close() | |
| 38 | + local ok, data = pcall(vim.json.decode, content) | |
| 39 | + if not ok then return nil end | |
| 40 | + return data | |
| 41 | +end | |
| 42 | + | |
| 43 | +function M.list_sessions() | |
| 44 | + local d = cwd_dir() | |
| 45 | + local files = vim.fn.glob(d .. "/session-*.json", false, true) | |
| 46 | + local out = {} | |
| 47 | + for _, f in ipairs(files) do | |
| 48 | + local stat = vim.uv.fs_stat(f) | |
| 49 | + if stat then | |
| 50 | + table.insert(out, { path = f, mtime = stat.mtime.sec }) | |
| 51 | + end | |
| 52 | + end | |
| 53 | + table.sort(out, function(a, b) return a.mtime > b.mtime end) | |
| 54 | + return out | |
| 55 | +end | |
| 56 | + | |
| 57 | +function M.load_path(path) | |
| 58 | + local f = io.open(path, "r") | |
| 59 | + if not f then return nil end | |
| 60 | + local content = f:read("*a") | |
| 61 | + f:close() | |
| 62 | + local ok, data = pcall(vim.json.decode, content) | |
| 63 | + if not ok then return nil end | |
| 64 | + return data | |
| 65 | +end | |
| 66 | + | |
| 67 | +function M.load(id) | |
| 68 | + return M.load_path(M.session_path(id)) | |
| 69 | +end | |
| 70 | + | |
| 71 | +function M.save(session) | |
| 72 | + local f = io.open(M.session_path(session.id), "w") | |
| 73 | + if not f then return false end | |
| 74 | + f:write(vim.json.encode(session)) | |
| 75 | + f:close() | |
| 76 | + return true | |
| 77 | +end | |
| 78 | + | |
| 79 | +function M.all_prompts() | |
| 80 | + local out = {} | |
| 81 | + for _, entry in ipairs(M.list_sessions()) do | |
| 82 | + local s = M.load_path(entry.path) | |
| 83 | + if s and s.prompts then | |
| 84 | + for _, p in ipairs(s.prompts) do | |
| 85 | + local copy = vim.deepcopy(p) | |
| 86 | + copy.session_id = s.id | |
| 87 | + copy.claude_id = s.claude_id | |
| 88 | + table.insert(out, copy) | |
| 89 | + end | |
| 90 | + end | |
| 91 | + end | |
| 92 | + table.sort(out, function(a, b) return (a.ts or 0) > (b.ts or 0) end) | |
| 93 | + return out | |
| 94 | +end | |
| 95 | + | |
| 96 | +function M.uuid() | |
| 97 | + math.randomseed((os.time() * 1000 + (vim.uv.hrtime() % 1e6)) % 2147483647) | |
| 98 | + local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" | |
| 99 | + return (template:gsub("[xy]", function(c) | |
| 100 | + local v = (c == "x") and math.random(0, 15) or math.random(8, 11) | |
| 101 | + return string.format("%x", v) | |
| 102 | + end)) | |
| 103 | +end | |
| 104 | + | |
| 105 | +return M |
+0-163claude-nvim/lua/claude/terminal.lua
| @@ -1,163 +0,0 @@ | ||
| 1 | -local M = {} | |
| 2 | - | |
| 3 | -local width = 80 | |
| 4 | - | |
| 5 | ----@type table<number, { buf: number?, win: number?, was_insert: boolean }> | |
| 6 | -local tabs = {} | |
| 7 | - | |
| 8 | -local function get_state() | |
| 9 | - local tab = vim.api.nvim_get_current_tabpage() | |
| 10 | - if not tabs[tab] then | |
| 11 | - tabs[tab] = { buf = nil, win = nil, was_insert = true } | |
| 12 | - end | |
| 13 | - return tabs[tab] | |
| 14 | -end | |
| 15 | - | |
| 16 | -local function state_dir() | |
| 17 | - local dir = vim.fn.stdpath("data") .. "/claude-sessions" | |
| 18 | - vim.fn.mkdir(dir, "p") | |
| 19 | - return dir | |
| 20 | -end | |
| 21 | - | |
| 22 | -local function state_file() | |
| 23 | - local cwd = vim.fn.getcwd():gsub("/", "%%") | |
| 24 | - return state_dir() .. "/" .. cwd | |
| 25 | -end | |
| 26 | - | |
| 27 | -function M.save_state() | |
| 28 | - local any_open = false | |
| 29 | - for _, s in pairs(tabs) do | |
| 30 | - if s.win and vim.api.nvim_win_is_valid(s.win) then | |
| 31 | - any_open = true | |
| 32 | - break | |
| 33 | - end | |
| 34 | - end | |
| 35 | - local f = io.open(state_file(), "w") | |
| 36 | - if f then | |
| 37 | - f:write(any_open and "open" or "closed") | |
| 38 | - f:close() | |
| 39 | - end | |
| 40 | -end | |
| 41 | - | |
| 42 | -function M.load_state() | |
| 43 | - local f = io.open(state_file(), "r") | |
| 44 | - if not f then return false end | |
| 45 | - local content = f:read("*a") | |
| 46 | - f:close() | |
| 47 | - return content == "open" | |
| 48 | -end | |
| 49 | - | |
| 50 | -function M.setup(opts) | |
| 51 | - width = opts.width or 80 | |
| 52 | - | |
| 53 | - vim.api.nvim_create_autocmd("VimLeavePre", { | |
| 54 | - callback = function() | |
| 55 | - M.save_state() | |
| 56 | - end, | |
| 57 | - }) | |
| 58 | -end | |
| 59 | - | |
| 60 | -function M.is_open() | |
| 61 | - local state = get_state() | |
| 62 | - return state.win and vim.api.nvim_win_is_valid(state.win) | |
| 63 | -end | |
| 64 | - | |
| 65 | -local function setup_keymaps(buf) | |
| 66 | - vim.keymap.set("t", "<C-h>", "<C-\\><C-n><C-w>h", { buffer = buf, desc = "Claude: Focus left window" }) | |
| 67 | - vim.keymap.set("t", "<C-k>", "<C-\\><C-n>", { buffer = buf, desc = "Claude: Normal mode" }) | |
| 68 | - vim.keymap.set("t", "<C-CR>", "\n", { buffer = buf, desc = "Claude: New line" }) | |
| 69 | -end | |
| 70 | - | |
| 71 | -function M.open(cmd) | |
| 72 | - local state = get_state() | |
| 73 | - | |
| 74 | - if M.is_open() then | |
| 75 | - vim.api.nvim_set_current_win(state.win) | |
| 76 | - if state.was_insert then | |
| 77 | - vim.cmd("startinsert") | |
| 78 | - end | |
| 79 | - return | |
| 80 | - end | |
| 81 | - | |
| 82 | - -- Reuse existing buffer if it's still valid and job is running | |
| 83 | - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then | |
| 84 | - local chan = vim.bo[state.buf].channel | |
| 85 | - if chan and chan > 0 and vim.fn.jobwait({ chan }, 0)[1] == -1 then | |
| 86 | - vim.cmd("botright vsplit") | |
| 87 | - state.win = vim.api.nvim_get_current_win() | |
| 88 | - vim.api.nvim_win_set_buf(state.win, state.buf) | |
| 89 | - vim.api.nvim_win_set_width(state.win, width) | |
| 90 | - if state.was_insert then | |
| 91 | - vim.cmd("startinsert") | |
| 92 | - end | |
| 93 | - return | |
| 94 | - end | |
| 95 | - -- Job is dead, clean up the buffer | |
| 96 | - vim.api.nvim_buf_delete(state.buf, { force = true }) | |
| 97 | - state.buf = nil | |
| 98 | - end | |
| 99 | - | |
| 100 | - -- Create new terminal | |
| 101 | - vim.cmd("botright vsplit") | |
| 102 | - state.win = vim.api.nvim_get_current_win() | |
| 103 | - vim.api.nvim_win_set_width(state.win, width) | |
| 104 | - | |
| 105 | - vim.cmd("terminal " .. cmd) | |
| 106 | - state.buf = vim.api.nvim_get_current_buf() | |
| 107 | - | |
| 108 | - vim.bo[state.buf].buflisted = false | |
| 109 | - | |
| 110 | - setup_keymaps(state.buf) | |
| 111 | - | |
| 112 | - vim.api.nvim_create_autocmd("BufLeave", { | |
| 113 | - buffer = state.buf, | |
| 114 | - callback = function() | |
| 115 | - state.was_insert = (vim.fn.mode() == "t") | |
| 116 | - end, | |
| 117 | - }) | |
| 118 | - | |
| 119 | - state.was_insert = true | |
| 120 | - vim.cmd("startinsert") | |
| 121 | -end | |
| 122 | - | |
| 123 | -function M.close() | |
| 124 | - local state = get_state() | |
| 125 | - if M.is_open() then | |
| 126 | - vim.api.nvim_win_hide(state.win) | |
| 127 | - state.win = nil | |
| 128 | - end | |
| 129 | -end | |
| 130 | - | |
| 131 | -function M.toggle(cmd) | |
| 132 | - if M.is_open() then | |
| 133 | - M.close() | |
| 134 | - else | |
| 135 | - M.open(cmd) | |
| 136 | - end | |
| 137 | -end | |
| 138 | - | |
| 139 | -function M.kill() | |
| 140 | - local state = get_state() | |
| 141 | - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then | |
| 142 | - M.close() | |
| 143 | - vim.api.nvim_buf_delete(state.buf, { force = true }) | |
| 144 | - state.buf = nil | |
| 145 | - end | |
| 146 | -end | |
| 147 | - | |
| 148 | -function M.send(text) | |
| 149 | - local state = get_state() | |
| 150 | - if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then | |
| 151 | - return false | |
| 152 | - end | |
| 153 | - | |
| 154 | - local chan = vim.bo[state.buf].channel | |
| 155 | - if chan == 0 then | |
| 156 | - return false | |
| 157 | - end | |
| 158 | - | |
| 159 | - vim.api.nvim_chan_send(chan, text) | |
| 160 | - return true | |
| 161 | -end | |
| 162 | - | |
| 163 | -return M |
+326-0claude-nvim/lua/claude/ui.lua
| @@ -0,0 +1,326 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local panel = { | |
| 4 | + buf = nil, | |
| 5 | + win = nil, | |
| 6 | + sid = nil, | |
| 7 | + width = 80, | |
| 8 | +} | |
| 9 | + | |
| 10 | +local function ensure_buf() | |
| 11 | + if panel.buf and vim.api.nvim_buf_is_valid(panel.buf) then | |
| 12 | + return panel.buf | |
| 13 | + end | |
| 14 | + local buf = vim.api.nvim_create_buf(false, true) | |
| 15 | + vim.bo[buf].buftype = "nofile" | |
| 16 | + vim.bo[buf].swapfile = false | |
| 17 | + vim.bo[buf].filetype = "markdown" | |
| 18 | + vim.bo[buf].buflisted = false | |
| 19 | + pcall(vim.api.nvim_buf_set_name, buf, "claude://panel") | |
| 20 | + panel.buf = buf | |
| 21 | + | |
| 22 | + local function open_input() M.open_input() end | |
| 23 | + vim.keymap.set("n", "i", open_input, { buffer = buf, desc = "Claude: prompt" }) | |
| 24 | + vim.keymap.set("n", "a", open_input, { buffer = buf, desc = "Claude: prompt" }) | |
| 25 | + vim.keymap.set("n", "o", open_input, { buffer = buf, desc = "Claude: prompt" }) | |
| 26 | + vim.keymap.set("n", "<CR>", open_input, { buffer = buf, desc = "Claude: prompt" }) | |
| 27 | + vim.keymap.set("n", "q", function() M.close() end, { buffer = buf, desc = "Claude: close panel" }) | |
| 28 | + vim.bo[buf].modifiable = false | |
| 29 | + return buf | |
| 30 | +end | |
| 31 | + | |
| 32 | +local function scroll_to_bottom() | |
| 33 | + if panel.win and vim.api.nvim_win_is_valid(panel.win) then | |
| 34 | + local lc = vim.api.nvim_buf_line_count(panel.buf) | |
| 35 | + pcall(vim.api.nvim_win_set_cursor, panel.win, { lc, 0 }) | |
| 36 | + end | |
| 37 | +end | |
| 38 | + | |
| 39 | +local function append_lines(lines) | |
| 40 | + local buf = panel.buf | |
| 41 | + if not buf or not vim.api.nvim_buf_is_valid(buf) then return end | |
| 42 | + vim.bo[buf].modifiable = true | |
| 43 | + vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines) | |
| 44 | + vim.bo[buf].modifiable = false | |
| 45 | + scroll_to_bottom() | |
| 46 | +end | |
| 47 | + | |
| 48 | +local function append_text_streaming(text) | |
| 49 | + local buf = panel.buf | |
| 50 | + if not buf or not vim.api.nvim_buf_is_valid(buf) then return end | |
| 51 | + vim.bo[buf].modifiable = true | |
| 52 | + local lc = vim.api.nvim_buf_line_count(buf) | |
| 53 | + local last = vim.api.nvim_buf_get_lines(buf, lc - 1, lc, false)[1] or "" | |
| 54 | + local parts = vim.split(text, "\n", { plain = true }) | |
| 55 | + parts[1] = last .. parts[1] | |
| 56 | + vim.api.nvim_buf_set_lines(buf, lc - 1, lc, false, parts) | |
| 57 | + vim.bo[buf].modifiable = false | |
| 58 | + scroll_to_bottom() | |
| 59 | +end | |
| 60 | + | |
| 61 | +local function clear_buf() | |
| 62 | + local buf = panel.buf | |
| 63 | + if not buf or not vim.api.nvim_buf_is_valid(buf) then return end | |
| 64 | + vim.bo[buf].modifiable = true | |
| 65 | + vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) | |
| 66 | + vim.bo[buf].modifiable = false | |
| 67 | +end | |
| 68 | + | |
| 69 | +function M.setup(opts) | |
| 70 | + panel.width = opts.width or 80 | |
| 71 | +end | |
| 72 | + | |
| 73 | +function M.open_panel() | |
| 74 | + local buf = ensure_buf() | |
| 75 | + if panel.win and vim.api.nvim_win_is_valid(panel.win) then | |
| 76 | + vim.api.nvim_set_current_win(panel.win) | |
| 77 | + return | |
| 78 | + end | |
| 79 | + vim.cmd("botright vsplit") | |
| 80 | + panel.win = vim.api.nvim_get_current_win() | |
| 81 | + vim.api.nvim_win_set_buf(panel.win, buf) | |
| 82 | + vim.api.nvim_win_set_width(panel.win, panel.width) | |
| 83 | + vim.wo[panel.win].wrap = true | |
| 84 | + vim.wo[panel.win].linebreak = true | |
| 85 | + vim.wo[panel.win].number = false | |
| 86 | + vim.wo[panel.win].relativenumber = false | |
| 87 | + scroll_to_bottom() | |
| 88 | +end | |
| 89 | + | |
| 90 | +function M.close() | |
| 91 | + if panel.win and vim.api.nvim_win_is_valid(panel.win) then | |
| 92 | + vim.api.nvim_win_hide(panel.win) | |
| 93 | + panel.win = nil | |
| 94 | + end | |
| 95 | +end | |
| 96 | + | |
| 97 | +function M.is_open() | |
| 98 | + return panel.win and vim.api.nvim_win_is_valid(panel.win) | |
| 99 | +end | |
| 100 | + | |
| 101 | +function M.toggle() | |
| 102 | + if M.is_open() then | |
| 103 | + M.close() | |
| 104 | + else | |
| 105 | + if not panel.sid then | |
| 106 | + M.start_new() | |
| 107 | + else | |
| 108 | + M.open_panel() | |
| 109 | + end | |
| 110 | + end | |
| 111 | +end | |
| 112 | + | |
| 113 | +function M.start_new() | |
| 114 | + local runner = require("claude.runner") | |
| 115 | + local sid = runner.start_session() | |
| 116 | + panel.sid = sid | |
| 117 | + clear_buf() | |
| 118 | + M.open_panel() | |
| 119 | + append_lines({ | |
| 120 | + "# Claude — new session", | |
| 121 | + "_id: " .. sid:sub(1, 8) .. "_", | |
| 122 | + "", | |
| 123 | + "Press `i` to send a prompt. `q` closes the panel.", | |
| 124 | + "", | |
| 125 | + }) | |
| 126 | +end | |
| 127 | + | |
| 128 | +local function claude_jsonl_path(claude_id) | |
| 129 | + if not claude_id then return nil end | |
| 130 | + local cwd = vim.fn.getcwd() | |
| 131 | + local enc = cwd:gsub("[/%.]", "-") | |
| 132 | + local home = vim.uv.os_homedir() or os.getenv("HOME") or "" | |
| 133 | + return home .. "/.claude/projects/" .. enc .. "/" .. claude_id .. ".jsonl" | |
| 134 | +end | |
| 135 | + | |
| 136 | +local function replay_history(claude_id) | |
| 137 | + local path = claude_jsonl_path(claude_id) | |
| 138 | + if not path then return false end | |
| 139 | + local f = io.open(path, "r") | |
| 140 | + if not f then return false end | |
| 141 | + local turns = 0 | |
| 142 | + for line in f:lines() do | |
| 143 | + local ok, evt = pcall(vim.json.decode, line) | |
| 144 | + if ok and type(evt) == "table" and evt.message then | |
| 145 | + local msg = evt.message | |
| 146 | + if msg.role == "user" then | |
| 147 | + local text | |
| 148 | + if type(msg.content) == "string" then | |
| 149 | + text = msg.content | |
| 150 | + elseif type(msg.content) == "table" then | |
| 151 | + local parts = {} | |
| 152 | + for _, b in ipairs(msg.content) do | |
| 153 | + if type(b) == "table" and b.type == "text" and b.text then | |
| 154 | + table.insert(parts, b.text) | |
| 155 | + end | |
| 156 | + end | |
| 157 | + text = table.concat(parts, "\n") | |
| 158 | + end | |
| 159 | + if text and text ~= "" and not text:match("^<") then | |
| 160 | + append_lines({ "## You", "" }) | |
| 161 | + for _, l in ipairs(vim.split(text, "\n", { plain = true })) do | |
| 162 | + append_lines({ l }) | |
| 163 | + end | |
| 164 | + append_lines({ "" }) | |
| 165 | + turns = turns + 1 | |
| 166 | + end | |
| 167 | + elseif msg.role == "assistant" and type(msg.content) == "table" then | |
| 168 | + local parts = {} | |
| 169 | + local tools = {} | |
| 170 | + for _, b in ipairs(msg.content) do | |
| 171 | + if type(b) == "table" then | |
| 172 | + if b.type == "text" and b.text then | |
| 173 | + table.insert(parts, b.text) | |
| 174 | + elseif b.type == "tool_use" then | |
| 175 | + local p = (b.input and b.input.file_path) or "" | |
| 176 | + table.insert(tools, "> **" .. (b.name or "tool") .. "** `" .. p .. "`") | |
| 177 | + end | |
| 178 | + end | |
| 179 | + end | |
| 180 | + if #parts > 0 or #tools > 0 then | |
| 181 | + append_lines({ "## Claude", "" }) | |
| 182 | + for _, t in ipairs(parts) do | |
| 183 | + for _, l in ipairs(vim.split(t, "\n", { plain = true })) do | |
| 184 | + append_lines({ l }) | |
| 185 | + end | |
| 186 | + end | |
| 187 | + for _, t in ipairs(tools) do | |
| 188 | + append_lines({ "", t }) | |
| 189 | + end | |
| 190 | + append_lines({ "", "---", "" }) | |
| 191 | + end | |
| 192 | + end | |
| 193 | + end | |
| 194 | + end | |
| 195 | + f:close() | |
| 196 | + return turns > 0 | |
| 197 | +end | |
| 198 | + | |
| 199 | +function M.resume_last() | |
| 200 | + local runner = require("claude.runner") | |
| 201 | + local sid = runner.resume_from_pointer() | |
| 202 | + if not sid then | |
| 203 | + vim.notify("claude: no previous session to resume", vim.log.levels.WARN) | |
| 204 | + M.start_new() | |
| 205 | + return | |
| 206 | + end | |
| 207 | + panel.sid = sid | |
| 208 | + clear_buf() | |
| 209 | + M.open_panel() | |
| 210 | + local session = runner.get_session(sid) | |
| 211 | + append_lines({ | |
| 212 | + "# Claude — resumed", | |
| 213 | + "_id: " .. sid:sub(1, 8) .. (session.claude_id and (" ← " .. session.claude_id:sub(1, 8)) or "") .. "_", | |
| 214 | + "", | |
| 215 | + }) | |
| 216 | + local replayed = replay_history(session.claude_id) | |
| 217 | + if not replayed then | |
| 218 | + append_lines({ "_(no prior transcript found on disk)_", "" }) | |
| 219 | + end | |
| 220 | + append_lines({ "Press `i` to send a prompt.", "" }) | |
| 221 | +end | |
| 222 | + | |
| 223 | +function M.open_input() | |
| 224 | + if not panel.sid then | |
| 225 | + M.start_new() | |
| 226 | + end | |
| 227 | + local sid = panel.sid | |
| 228 | + local input_buf = vim.api.nvim_create_buf(false, true) | |
| 229 | + vim.bo[input_buf].bufhidden = "wipe" | |
| 230 | + vim.bo[input_buf].filetype = "markdown" | |
| 231 | + | |
| 232 | + local width = math.min(80, math.max(40, math.floor(vim.o.columns * 0.6))) | |
| 233 | + local height = 8 | |
| 234 | + local win = vim.api.nvim_open_win(input_buf, true, { | |
| 235 | + relative = "editor", | |
| 236 | + row = math.floor((vim.o.lines - height) / 2), | |
| 237 | + col = math.floor((vim.o.columns - width) / 2), | |
| 238 | + width = width, | |
| 239 | + height = height, | |
| 240 | + style = "minimal", | |
| 241 | + border = "rounded", | |
| 242 | + title = " Claude prompt — <C-s> send <C-c> cancel ", | |
| 243 | + title_pos = "center", | |
| 244 | + }) | |
| 245 | + vim.cmd("startinsert") | |
| 246 | + | |
| 247 | + local closed = false | |
| 248 | + local function close() | |
| 249 | + if closed then return end | |
| 250 | + closed = true | |
| 251 | + pcall(vim.cmd, "stopinsert") | |
| 252 | + if vim.api.nvim_win_is_valid(win) then | |
| 253 | + vim.api.nvim_win_close(win, true) | |
| 254 | + end | |
| 255 | + end | |
| 256 | + local function send() | |
| 257 | + if closed then return end | |
| 258 | + local lines = vim.api.nvim_buf_get_lines(input_buf, 0, -1, false) | |
| 259 | + local text = table.concat(lines, "\n") | |
| 260 | + close() | |
| 261 | + if text:match("^%s*$") then return end | |
| 262 | + local runner = require("claude.runner") | |
| 263 | + local ok, err = runner.send(sid, text) | |
| 264 | + if not ok then | |
| 265 | + vim.notify("claude: " .. tostring(err), vim.log.levels.WARN) | |
| 266 | + end | |
| 267 | + end | |
| 268 | + local km = { buffer = input_buf, nowait = true, silent = true } | |
| 269 | + vim.keymap.set({ "n", "i" }, "<C-s>", send, km) | |
| 270 | + vim.keymap.set({ "n", "i" }, "<C-c>", close, km) | |
| 271 | + vim.keymap.set("n", "q", close, km) | |
| 272 | + vim.keymap.set("n", "<Esc>", close, km) | |
| 273 | +end | |
| 274 | + | |
| 275 | +-- runner callbacks | |
| 276 | + | |
| 277 | +function M.append_user_prompt(sid, text) | |
| 278 | + if sid ~= panel.sid then return end | |
| 279 | + vim.schedule(function() | |
| 280 | + local lines = { "## You", "" } | |
| 281 | + for _, l in ipairs(vim.split(text, "\n", { plain = true })) do | |
| 282 | + table.insert(lines, l) | |
| 283 | + end | |
| 284 | + table.insert(lines, "") | |
| 285 | + table.insert(lines, "## Claude") | |
| 286 | + table.insert(lines, "") | |
| 287 | + append_lines(lines) | |
| 288 | + end) | |
| 289 | +end | |
| 290 | + | |
| 291 | +function M.append_assistant_text(sid, text) | |
| 292 | + if sid ~= panel.sid then return end | |
| 293 | + vim.schedule(function() append_text_streaming(text) end) | |
| 294 | +end | |
| 295 | + | |
| 296 | +function M.append_tool_use(sid, name, path) | |
| 297 | + if sid ~= panel.sid then return end | |
| 298 | + vim.schedule(function() | |
| 299 | + append_lines({ "", "> **" .. name .. "** `" .. path .. "`", "" }) | |
| 300 | + end) | |
| 301 | +end | |
| 302 | + | |
| 303 | +function M.append_separator(sid) | |
| 304 | + if sid ~= panel.sid then return end | |
| 305 | + vim.schedule(function() append_lines({ "", "---", "" }) end) | |
| 306 | +end | |
| 307 | + | |
| 308 | +function M.append_stderr(sid, line) | |
| 309 | + if sid ~= panel.sid then return end | |
| 310 | + vim.schedule(function() append_lines({ "_stderr: " .. line .. "_" }) end) | |
| 311 | +end | |
| 312 | + | |
| 313 | +function M.on_prompt_complete(sid, prompt, had_changes) | |
| 314 | + if sid ~= panel.sid then return end | |
| 315 | + if had_changes then | |
| 316 | + vim.schedule(function() | |
| 317 | + append_lines({ "_recorded — " .. #(prompt.files or {}) .. " file(s) changed_", "" }) | |
| 318 | + end) | |
| 319 | + end | |
| 320 | +end | |
| 321 | + | |
| 322 | +function M.current_sid() | |
| 323 | + return panel.sid | |
| 324 | +end | |
| 325 | + | |
| 326 | +return M |