irongit

My nvim configuration

refactor: claude has it's own cli wrapper

huncholanehuncholaneauthored
parent 28d17bbcommit 526cb14dc84d7bb94a9020383cc9c5e92c7ce465Browse files

9 files changed, +953 -229

+1-0claude-nvim/lua/claude/edit.lua
@@ -121,6 +121,7 @@ function M.prompt_visual()
121121 local function close_popup()
122122 if closed then return end
123123 closed = true
124+ pcall(vim.cmd, "stopinsert")
124125 if vim.api.nvim_win_is_valid(pop_win) then
125126 vim.api.nvim_win_close(pop_win, true)
126127 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 @@
11 local M = {}
22
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")
57 local edit = require("claude.edit")
68
79 ---@class ClaudeConfig
810 local defaults = {
9- --- Width of the Claude terminal split (default: 80)
11+ --- Width of the Claude panel (default: 80)
1012 width = 80,
1113 --- Add --dangerously-skip-permissions flag to all claude commands (default: false)
1214 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,
1521 }
1622
1723 --- Setup the Claude CLI wrapper plugin.
1824 ---@param opts? ClaudeConfig
1925 function M.setup(opts)
2026 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)
2330 edit.setup(opts)
2431
2532 vim.keymap.set("n", "<leader>c", "", { desc = "Claude" })
2633
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" })
2939
30- -- Visual <leader>c → prompt popup that edits selection in place via claude -p
3140 vim.keymap.set("v", "<leader>c", function()
3241 edit.prompt_visual()
3342 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
6343 end
6444
6545 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