irongit

My nvim configuration

feat(claude): per tab directory for new sessions

huncholanehuncholaneauthored
parent 5184a92commit 9cd95416a35b83afee7bf68cb42741bdcca09937Browse files

3 files changed, +257 -246

+5-3claude-nvim/lua/claude/runner.lua
@@ -275,10 +275,11 @@ function M.attach_existing(session)
275275 return session.id
276276 end
277277
278-function M.resume_from_pointer()
279- local ptr = store.read_pointer()
278+function M.resume_from_pointer(cwd)
279+ cwd = cwd or vim.fn.getcwd()
280+ local ptr = store.read_pointer(cwd)
280281 if not ptr then return nil end
281- local existing = ptr.session_id and store.load(ptr.session_id) or nil
282+ local existing = ptr.session_id and store.load(ptr.session_id, cwd) or nil
282283 if existing then
283284 return M.attach_existing(existing)
284285 end
@@ -324,6 +325,7 @@ function M.send(sid, prompt_text, opts)
324325 log("CMD", table.concat(cmd, " | "))
325326
326327 st.job = vim.fn.jobstart(cmd, {
328+ cwd = st.session.cwd,
327329 stdout_buffered = false,
328330 on_stdout = function(_, data) on_stdout(sid, data) end,
329331 on_stderr = function(_, data)
+16-16claude-nvim/lua/claude/store.lua
@@ -6,32 +6,32 @@ local function data_dir()
66 return d
77 end
88
9-local function cwd_dir()
10- local cwd = vim.fn.getcwd()
9+local function cwd_dir(cwd)
10+ cwd = cwd or vim.fn.getcwd()
1111 local enc = cwd:gsub("/", "%%")
1212 local d = data_dir() .. "/" .. enc
1313 vim.fn.mkdir(d, "p")
1414 return d
1515 end
1616
17-function M.session_path(id)
18- return cwd_dir() .. "/session-" .. id .. ".json"
17+function M.session_path(id, cwd)
18+ return cwd_dir(cwd) .. "/session-" .. id .. ".json"
1919 end
2020
21-local function pointer_path()
22- return cwd_dir() .. "/last.json"
21+local function pointer_path(cwd)
22+ return cwd_dir(cwd) .. "/last.json"
2323 end
2424
2525 function M.write_pointer(info)
26- local f = io.open(pointer_path(), "w")
26+ local f = io.open(pointer_path(info and info.cwd), "w")
2727 if not f then return false end
2828 f:write(vim.json.encode(info))
2929 f:close()
3030 return true
3131 end
3232
33-function M.read_pointer()
34- local f = io.open(pointer_path(), "r")
33+function M.read_pointer(cwd)
34+ local f = io.open(pointer_path(cwd), "r")
3535 if not f then return nil end
3636 local content = f:read("*a")
3737 f:close()
@@ -40,8 +40,8 @@ function M.read_pointer()
4040 return data
4141 end
4242
43-function M.list_sessions()
44- local d = cwd_dir()
43+function M.list_sessions(cwd)
44+ local d = cwd_dir(cwd)
4545 local files = vim.fn.glob(d .. "/session-*.json", false, true)
4646 local out = {}
4747 for _, f in ipairs(files) do
@@ -64,21 +64,21 @@ function M.load_path(path)
6464 return data
6565 end
6666
67-function M.load(id)
68- return M.load_path(M.session_path(id))
67+function M.load(id, cwd)
68+ return M.load_path(M.session_path(id, cwd))
6969 end
7070
7171 function M.save(session)
72- local f = io.open(M.session_path(session.id), "w")
72+ local f = io.open(M.session_path(session.id, session.cwd), "w")
7373 if not f then return false end
7474 f:write(vim.json.encode(session))
7575 f:close()
7676 return true
7777 end
7878
79-function M.all_prompts()
79+function M.all_prompts(cwd)
8080 local out = {}
81- for _, entry in ipairs(M.list_sessions()) do
81+ for _, entry in ipairs(M.list_sessions(cwd)) do
8282 local s = M.load_path(entry.path)
8383 if s and s.prompts then
8484 for _, p in ipairs(s.prompts) do
+236-227claude-nvim/lua/claude/ui.lua
@@ -1,96 +1,122 @@
11 local M = {}
22
3-local panel = {
4- buf = nil,
5- win = nil,
6- sid = nil,
7- width = 80,
8-}
9-
10-local spinner = {
11- timer = nil,
12- frame = 1,
13- frames = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" },
14- active = false,
15- row = nil,
16-}
17-
18-local function spinner_text()
19- return spinner.frames[spinner.frame] .. " thinking…"
20-end
21-
22-local function scroll_to_bottom_now()
23- if panel.win and vim.api.nvim_win_is_valid(panel.win) then
24- pcall(vim.api.nvim_win_call, panel.win, function()
3+local default_width = 80
4+
5+-- panels keyed by tabpage handle
6+local panels = {}
7+
8+local SPINNER_FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }
9+
10+local function new_panel(tab)
11+ return {
12+ tab = tab,
13+ buf = nil,
14+ win = nil,
15+ sid = nil,
16+ width = default_width,
17+ spinner = {
18+ timer = nil,
19+ frame = 1,
20+ active = false,
21+ row = nil,
22+ },
23+ }
24+end
25+
26+local function get_panel()
27+ local tab = vim.api.nvim_get_current_tabpage()
28+ local p = panels[tab]
29+ if not p then
30+ p = new_panel(tab)
31+ panels[tab] = p
32+ end
33+ return p
34+end
35+
36+local function find_panel_by_sid(sid)
37+ if not sid then return nil end
38+ for _, p in pairs(panels) do
39+ if p.sid == sid then return p end
40+ end
41+ return nil
42+end
43+
44+local function spinner_text(p)
45+ return SPINNER_FRAMES[p.spinner.frame] .. " thinking…"
46+end
47+
48+local function scroll_to_bottom_now(p)
49+ if p.win and vim.api.nvim_win_is_valid(p.win) then
50+ pcall(vim.api.nvim_win_call, p.win, function()
2551 vim.cmd("normal! G")
2652 end)
2753 end
2854 end
2955
30-local function spinner_remove_if_present()
31- if spinner.row == nil then return end
32- if not panel.buf or not vim.api.nvim_buf_is_valid(panel.buf) then
33- spinner.row = nil
56+local function spinner_remove_if_present(p)
57+ if p.spinner.row == nil then return end
58+ if not p.buf or not vim.api.nvim_buf_is_valid(p.buf) then
59+ p.spinner.row = nil
3460 return
3561 end
36- local lc = vim.api.nvim_buf_line_count(panel.buf)
37- if spinner.row < lc then
38- vim.bo[panel.buf].modifiable = true
39- pcall(vim.api.nvim_buf_set_lines, panel.buf, spinner.row, spinner.row + 1, false, {})
40- vim.bo[panel.buf].modifiable = false
62+ local lc = vim.api.nvim_buf_line_count(p.buf)
63+ if p.spinner.row < lc then
64+ vim.bo[p.buf].modifiable = true
65+ pcall(vim.api.nvim_buf_set_lines, p.buf, p.spinner.row, p.spinner.row + 1, false, {})
66+ vim.bo[p.buf].modifiable = false
4167 end
42- spinner.row = nil
68+ p.spinner.row = nil
4369 end
4470
45-local function spinner_render()
46- if not spinner.active then return end
47- if not panel.buf or not vim.api.nvim_buf_is_valid(panel.buf) then return end
48- vim.bo[panel.buf].modifiable = true
49- if spinner.row ~= nil and spinner.row < vim.api.nvim_buf_line_count(panel.buf) then
50- pcall(vim.api.nvim_buf_set_lines, panel.buf, spinner.row, spinner.row + 1, false, { spinner_text() })
71+local function spinner_render(p)
72+ if not p.spinner.active then return end
73+ if not p.buf or not vim.api.nvim_buf_is_valid(p.buf) then return end
74+ vim.bo[p.buf].modifiable = true
75+ if p.spinner.row ~= nil and p.spinner.row < vim.api.nvim_buf_line_count(p.buf) then
76+ pcall(vim.api.nvim_buf_set_lines, p.buf, p.spinner.row, p.spinner.row + 1, false, { spinner_text(p) })
5177 else
52- local lc = vim.api.nvim_buf_line_count(panel.buf)
53- pcall(vim.api.nvim_buf_set_lines, panel.buf, lc, lc, false, { spinner_text() })
54- spinner.row = lc
55- end
56- vim.bo[panel.buf].modifiable = false
57- scroll_to_bottom_now()
58-end
59-
60-local function spinner_stop()
61- spinner.active = false
62- if spinner.timer then
63- spinner.timer:stop()
64- if not spinner.timer:is_closing() then spinner.timer:close() end
65- spinner.timer = nil
66- end
67- spinner_remove_if_present()
68-end
69-
70-local function spinner_start()
71- spinner_stop()
72- spinner.active = true
73- spinner.frame = 1
74- spinner_render()
75- spinner.timer = vim.uv.new_timer()
76- spinner.timer:start(90, 90, vim.schedule_wrap(function()
77- if not spinner.active then return end
78- spinner.frame = (spinner.frame % #spinner.frames) + 1
79- spinner_render()
78+ local lc = vim.api.nvim_buf_line_count(p.buf)
79+ pcall(vim.api.nvim_buf_set_lines, p.buf, lc, lc, false, { spinner_text(p) })
80+ p.spinner.row = lc
81+ end
82+ vim.bo[p.buf].modifiable = false
83+ scroll_to_bottom_now(p)
84+end
85+
86+local function spinner_stop(p)
87+ p.spinner.active = false
88+ if p.spinner.timer then
89+ p.spinner.timer:stop()
90+ if not p.spinner.timer:is_closing() then p.spinner.timer:close() end
91+ p.spinner.timer = nil
92+ end
93+ spinner_remove_if_present(p)
94+end
95+
96+local function spinner_start(p)
97+ spinner_stop(p)
98+ p.spinner.active = true
99+ p.spinner.frame = 1
100+ spinner_render(p)
101+ p.spinner.timer = vim.uv.new_timer()
102+ p.spinner.timer:start(90, 90, vim.schedule_wrap(function()
103+ if not p.spinner.active then return end
104+ p.spinner.frame = (p.spinner.frame % #SPINNER_FRAMES) + 1
105+ spinner_render(p)
80106 end))
81107 end
82108
83-local function ensure_buf()
84- if panel.buf and vim.api.nvim_buf_is_valid(panel.buf) then
85- return panel.buf
109+local function ensure_buf(p)
110+ if p.buf and vim.api.nvim_buf_is_valid(p.buf) then
111+ return p.buf
86112 end
87113 local buf = vim.api.nvim_create_buf(false, true)
88114 vim.bo[buf].buftype = "nofile"
89115 vim.bo[buf].swapfile = false
90116 vim.bo[buf].filetype = "markdown"
91117 vim.bo[buf].buflisted = false
92- pcall(vim.api.nvim_buf_set_name, buf, "claude://panel")
93- panel.buf = buf
118+ pcall(vim.api.nvim_buf_set_name, buf, "claude://panel/" .. tostring(p.tab))
119+ p.buf = buf
94120
95121 local function open_input() M.open_input() end
96122 vim.keymap.set("n", "i", open_input, { buffer = buf, desc = "Claude: prompt" })
@@ -102,25 +128,21 @@ local function ensure_buf()
102128 return buf
103129 end
104130
105-local function scroll_to_bottom()
106- scroll_to_bottom_now()
107-end
108-
109-local function append_lines(lines)
110- local buf = panel.buf
131+local function append_lines(p, lines)
132+ local buf = p.buf
111133 if not buf or not vim.api.nvim_buf_is_valid(buf) then return end
112- spinner_remove_if_present()
134+ spinner_remove_if_present(p)
113135 vim.bo[buf].modifiable = true
114136 vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines)
115137 vim.bo[buf].modifiable = false
116- spinner_render()
117- scroll_to_bottom()
138+ spinner_render(p)
139+ scroll_to_bottom_now(p)
118140 end
119141
120-local function append_text_streaming(text)
121- local buf = panel.buf
142+local function append_text_streaming(p, text)
143+ local buf = p.buf
122144 if not buf or not vim.api.nvim_buf_is_valid(buf) then return end
123- spinner_remove_if_present()
145+ spinner_remove_if_present(p)
124146 vim.bo[buf].modifiable = true
125147 local lc = vim.api.nvim_buf_line_count(buf)
126148 local last = vim.api.nvim_buf_get_lines(buf, lc - 1, lc, false)[1] or ""
@@ -128,56 +150,72 @@ local function append_text_streaming(text)
128150 parts[1] = last .. parts[1]
129151 vim.api.nvim_buf_set_lines(buf, lc - 1, lc, false, parts)
130152 vim.bo[buf].modifiable = false
131- spinner_render()
132- scroll_to_bottom()
153+ spinner_render(p)
154+ scroll_to_bottom_now(p)
133155 end
134156
135-local function clear_buf()
136- local buf = panel.buf
157+local function clear_buf(p)
158+ local buf = p.buf
137159 if not buf or not vim.api.nvim_buf_is_valid(buf) then return end
138- spinner.row = nil
160+ p.spinner.row = nil
139161 vim.bo[buf].modifiable = true
140162 vim.api.nvim_buf_set_lines(buf, 0, -1, false, {})
141163 vim.bo[buf].modifiable = false
142164 end
143165
144166 function M.setup(opts)
145- panel.width = opts.width or 80
167+ default_width = opts.width or 80
168+
169+ vim.api.nvim_create_autocmd("TabClosed", {
170+ callback = function(ev)
171+ local tabnr = tonumber(ev.file)
172+ if not tabnr then return end
173+ for tab, _ in pairs(panels) do
174+ if not vim.api.nvim_tabpage_is_valid(tab) then
175+ panels[tab] = nil
176+ end
177+ end
178+ end,
179+ })
146180 end
147181
148182 function M.open_panel()
149- local buf = ensure_buf()
150- if panel.win and vim.api.nvim_win_is_valid(panel.win) then
151- vim.api.nvim_set_current_win(panel.win)
183+ local p = get_panel()
184+ local buf = ensure_buf(p)
185+ if p.win and vim.api.nvim_win_is_valid(p.win) then
186+ vim.api.nvim_set_current_win(p.win)
152187 return
153188 end
154189 vim.cmd("botright vsplit")
155- panel.win = vim.api.nvim_get_current_win()
156- vim.api.nvim_win_set_buf(panel.win, buf)
157- vim.api.nvim_win_set_width(panel.win, panel.width)
158- vim.wo[panel.win].wrap = true
159- vim.wo[panel.win].linebreak = true
160- vim.wo[panel.win].number = false
161- vim.wo[panel.win].relativenumber = false
162- scroll_to_bottom()
190+ p.win = vim.api.nvim_get_current_win()
191+ vim.api.nvim_win_set_buf(p.win, buf)
192+ vim.api.nvim_win_set_width(p.win, p.width)
193+ vim.wo[p.win].wrap = true
194+ vim.wo[p.win].linebreak = true
195+ vim.wo[p.win].number = false
196+ vim.wo[p.win].relativenumber = false
197+ scroll_to_bottom_now(p)
163198 end
164199
165200 function M.close()
166- if panel.win and vim.api.nvim_win_is_valid(panel.win) then
167- vim.api.nvim_win_hide(panel.win)
168- panel.win = nil
201+ local p = get_panel()
202+ if p.win and vim.api.nvim_win_is_valid(p.win) then
203+ vim.api.nvim_win_hide(p.win)
204+ p.win = nil
169205 end
170206 end
171207
172208 function M.is_open()
173- return panel.win and vim.api.nvim_win_is_valid(panel.win)
209+ local p = get_panel()
210+ return p.win and vim.api.nvim_win_is_valid(p.win)
174211 end
175212
176213 function M.toggle()
214+ local p = get_panel()
177215 if M.is_open() then
178216 M.close()
179217 else
180- if not panel.sid then
218+ if not p.sid then
181219 M.start_new()
182220 else
183221 M.open_panel()
@@ -186,12 +224,13 @@ function M.toggle()
186224 end
187225
188226 function M.start_new()
227+ local p = get_panel()
189228 local runner = require("claude.runner")
190229 local sid = runner.start_session()
191- panel.sid = sid
192- clear_buf()
230+ p.sid = sid
231+ clear_buf(p)
193232 M.open_panel()
194- append_lines({
233+ append_lines(p, {
195234 "# Claude — new session",
196235 "_id: " .. sid:sub(1, 8) .. "_",
197236 "",
@@ -200,16 +239,16 @@ function M.start_new()
200239 })
201240 end
202241
203-local function claude_jsonl_path(claude_id)
242+local function claude_jsonl_path(claude_id, cwd)
204243 if not claude_id then return nil end
205- local cwd = vim.fn.getcwd()
244+ cwd = cwd or vim.fn.getcwd()
206245 local enc = cwd:gsub("[/%.]", "-")
207246 local home = vim.uv.os_homedir() or os.getenv("HOME") or ""
208247 return home .. "/.claude/projects/" .. enc .. "/" .. claude_id .. ".jsonl"
209248 end
210249
211-local function replay_history(claude_id)
212- local path = claude_jsonl_path(claude_id)
250+local function replay_history(p, claude_id, cwd)
251+ local path = claude_jsonl_path(claude_id, cwd)
213252 if not path then return false end
214253 local f = io.open(path, "r")
215254 if not f then return false end
@@ -232,11 +271,11 @@ local function replay_history(claude_id)
232271 text = table.concat(parts, "\n")
233272 end
234273 if text and text ~= "" and not text:match("^<") then
235- append_lines({ "## You", "" })
274+ append_lines(p, { "## You", "" })
236275 for _, l in ipairs(vim.split(text, "\n", { plain = true })) do
237- append_lines({ l })
276+ append_lines(p, { l })
238277 end
239- append_lines({ "" })
278+ append_lines(p, { "" })
240279 turns = turns + 1
241280 end
242281 elseif msg.role == "assistant" and type(msg.content) == "table" then
@@ -247,22 +286,22 @@ local function replay_history(claude_id)
247286 if b.type == "text" and b.text then
248287 table.insert(parts, b.text)
249288 elseif b.type == "tool_use" then
250- local p = (b.input and b.input.file_path) or ""
251- table.insert(tools, "> **" .. (b.name or "tool") .. "** `" .. p .. "`")
289+ local fp = (b.input and b.input.file_path) or ""
290+ table.insert(tools, "> **" .. (b.name or "tool") .. "** `" .. fp .. "`")
252291 end
253292 end
254293 end
255294 if #parts > 0 or #tools > 0 then
256- append_lines({ "## Claude", "" })
295+ append_lines(p, { "## Claude", "" })
257296 for _, t in ipairs(parts) do
258297 for _, l in ipairs(vim.split(t, "\n", { plain = true })) do
259- append_lines({ l })
298+ append_lines(p, { l })
260299 end
261300 end
262301 for _, t in ipairs(tools) do
263- append_lines({ "", t })
302+ append_lines(p, { "", t })
264303 end
265- append_lines({ "", "---", "" })
304+ append_lines(p, { "", "---", "" })
266305 end
267306 end
268307 end
@@ -276,11 +315,12 @@ function M.resume_session(claude_id)
276315 vim.notify("claude: missing claude_id", vim.log.levels.WARN)
277316 return
278317 end
318+ local p = get_panel()
279319 local runner = require("claude.runner")
280320 local store = require("claude.store")
281- -- find existing session.json with this claude_id, else build thin session
321+ local cwd = vim.fn.getcwd()
282322 local session
283- for _, entry in ipairs(store.list_sessions()) do
323+ for _, entry in ipairs(store.list_sessions(cwd)) do
284324 local s = store.load_path(entry.path)
285325 if s and s.claude_id == claude_id then session = s break end
286326 end
@@ -288,80 +328,51 @@ function M.resume_session(claude_id)
288328 session = {
289329 id = store.uuid(),
290330 started_at = os.time(),
291- cwd = vim.fn.getcwd(),
331+ cwd = cwd,
292332 claude_id = claude_id,
293333 prompts = {},
294334 }
295335 end
296336 local sid = runner.attach_existing(session)
297- panel.sid = sid
337+ p.sid = sid
298338 store.write_pointer({ session_id = session.id, claude_id = claude_id, ts = os.time(), cwd = session.cwd })
299- clear_buf()
339+ clear_buf(p)
300340 M.open_panel()
301- append_lines({
341+ append_lines(p, {
302342 "# Claude — resumed",
303343 "_id: " .. sid:sub(1, 8) .. " ← " .. claude_id:sub(1, 8) .. "_",
304344 "",
305345 })
306- local replayed = replay_history(claude_id)
346+ local replayed = replay_history(p, claude_id, session.cwd)
307347 if not replayed then
308- append_lines({ "_(no prior transcript found on disk)_", "" })
348+ append_lines(p, { "_(no prior transcript found on disk)_", "" })
309349 end
310- append_lines({ "Press `i` to send a prompt.", "" })
350+ append_lines(p, { "Press `i` to send a prompt.", "" })
311351 end
312352
313353 function M.resume_last()
354+ local p = get_panel()
314355 local runner = require("claude.runner")
315- local sid = runner.resume_from_pointer()
356+ local sid = runner.resume_from_pointer(vim.fn.getcwd())
316357 if not sid then
317358 vim.notify("claude: no previous session to resume", vim.log.levels.WARN)
318359 M.start_new()
319360 return
320361 end
321- panel.sid = sid
322- clear_buf()
362+ p.sid = sid
363+ clear_buf(p)
323364 M.open_panel()
324365 local session = runner.get_session(sid)
325- append_lines({
366+ append_lines(p, {
326367 "# Claude — resumed",
327368 "_id: " .. sid:sub(1, 8) .. (session.claude_id and (" ← " .. session.claude_id:sub(1, 8)) or "") .. "_",
328369 "",
329370 })
330- local replayed = replay_history(session.claude_id)
331- if not replayed then
332- append_lines({ "_(no prior transcript found on disk)_", "" })
333- end
334- append_lines({ "Press `i` to send a prompt.", "" })
335-end
336-
337-function M.resume_session(claude_id)
338- if not claude_id or claude_id == "" then
339- vim.notify("claude: missing session id", vim.log.levels.WARN)
340- return
341- end
342- local runner = require("claude.runner")
343- local store = require("claude.store")
344- local session = {
345- id = store.uuid(),
346- started_at = os.time(),
347- cwd = vim.fn.getcwd(),
348- claude_id = claude_id,
349- prompts = {},
350- }
351- local sid = runner.attach_existing(session)
352- panel.sid = sid
353- clear_buf()
354- M.open_panel()
355- append_lines({
356- "# Claude — resumed",
357- "_id: " .. sid:sub(1, 8) .. " ← " .. claude_id:sub(1, 8) .. "_",
358- "",
359- })
360- local replayed = replay_history(claude_id)
371+ local replayed = replay_history(p, session.claude_id, session.cwd)
361372 if not replayed then
362- append_lines({ "_(no prior transcript found on disk)_", "" })
373+ append_lines(p, { "_(no prior transcript found on disk)_", "" })
363374 end
364- append_lines({ "Press `i` to send a prompt.", "" })
375+ append_lines(p, { "Press `i` to send a prompt.", "" })
365376 end
366377
367378 local prompt_buf = nil
@@ -380,10 +391,11 @@ end
380391
381392 function M.open_input(opts)
382393 opts = opts or {}
383- if not panel.sid then
394+ local p = get_panel()
395+ if not p.sid then
384396 M.start_new()
385397 end
386- local sid = panel.sid
398+ local sid = p.sid
387399 local return_to = opts.return_to
388400 local context = opts.context
389401 local input_buf = ensure_prompt_buf()
@@ -402,7 +414,6 @@ function M.open_input(opts)
402414 or " Claude prompt — <C-s> send <C-c> cancel ",
403415 title_pos = "center",
404416 })
405- -- Place cursor at end of existing text so user can keep typing
406417 local lc = vim.api.nvim_buf_line_count(input_buf)
407418 local last = vim.api.nvim_buf_get_lines(input_buf, lc - 1, lc, false)[1] or ""
408419 pcall(vim.api.nvim_win_set_cursor, win, { lc, #last })
@@ -425,7 +436,6 @@ function M.open_input(opts)
425436 local lines = vim.api.nvim_buf_get_lines(input_buf, 0, -1, false)
426437 local text = table.concat(lines, "\n")
427438 if text:match("^%s*$") then close_keep(); return end
428- -- clear the persistent prompt buffer since prompt was sent
429439 if vim.api.nvim_buf_is_valid(input_buf) then
430440 vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, {})
431441 end
@@ -441,21 +451,12 @@ function M.open_input(opts)
441451 vim.notify("claude: " .. tostring(err), vim.log.levels.WARN)
442452 end
443453 end
444- if not prompt_keymaps_set then
445- local km = { buffer = input_buf, nowait = true, silent = true }
446- vim.keymap.set({ "n", "i" }, "<C-s>", send, km)
447- vim.keymap.set({ "n", "i" }, "<C-c>", close_keep, km)
448- vim.keymap.set("n", "q", close_keep, km)
449- vim.keymap.set("n", "<Esc>", close_keep, km)
450- prompt_keymaps_set = true
451- else
452- -- rebind send/close so they reference current win/sid/context closures
453- local km = { buffer = input_buf, nowait = true, silent = true }
454- vim.keymap.set({ "n", "i" }, "<C-s>", send, km)
455- vim.keymap.set({ "n", "i" }, "<C-c>", close_keep, km)
456- vim.keymap.set("n", "q", close_keep, km)
457- vim.keymap.set("n", "<Esc>", close_keep, km)
458- end
454+ local km = { buffer = input_buf, nowait = true, silent = true }
455+ vim.keymap.set({ "n", "i" }, "<C-s>", send, km)
456+ vim.keymap.set({ "n", "i" }, "<C-c>", close_keep, km)
457+ vim.keymap.set("n", "q", close_keep, km)
458+ vim.keymap.set("n", "<Esc>", close_keep, km)
459+ prompt_keymaps_set = true
459460 end
460461
461462 local function capture_visual_selection(bufnr)
@@ -491,8 +492,9 @@ function M.prompt_visual_chat()
491492 local ft = vim.bo[origin_buf].filetype or ""
492493 local context = string.format("```%s\n%s\n```", ft, sel)
493494
495+ local p = get_panel()
494496 if not M.is_open() then
495- if panel.sid then
497+ if p.sid then
496498 M.open_panel()
497499 else
498500 M.start_new()
@@ -503,25 +505,22 @@ end
503505
504506 function M.open_prompt_keep_focus()
505507 local origin = vim.api.nvim_get_current_win()
508+ local p = get_panel()
506509 if not M.is_open() then
507- if panel.sid then
510+ if p.sid then
508511 M.open_panel()
509512 else
510- local store = require("claude.store")
511- if store.read_pointer() then
512- M.resume_last()
513- else
514- M.start_new()
515- end
513+ M.start_new()
516514 end
517515 end
518516 M.open_input({ return_to = origin })
519517 end
520518
521--- runner callbacks
519+-- runner callbacks (resolve panel by sid)
522520
523521 function M.append_user_prompt(sid, text)
524- if sid ~= panel.sid then return end
522+ local p = find_panel_by_sid(sid)
523+ if not p then return end
525524 local lines = { "## You", "" }
526525 for _, l in ipairs(vim.split(text, "\n", { plain = true })) do
527526 table.insert(lines, l)
@@ -529,28 +528,30 @@ function M.append_user_prompt(sid, text)
529528 table.insert(lines, "")
530529 table.insert(lines, "## Claude")
531530 table.insert(lines, "")
532- append_lines(lines)
533- spinner_start()
534- scroll_to_bottom()
531+ append_lines(p, lines)
532+ spinner_start(p)
533+ scroll_to_bottom_now(p)
535534 pcall(vim.cmd, "redraw!")
536535 end
537536
538537 function M.append_assistant_text(sid, text)
539- if sid ~= panel.sid then return end
540538 vim.schedule(function()
541- append_text_streaming(text)
542- spinner_render()
539+ local p = find_panel_by_sid(sid)
540+ if not p then return end
541+ append_text_streaming(p, text)
542+ spinner_render(p)
543543 end)
544544 end
545545
546546 local ns_diff = vim.api.nvim_create_namespace("claude_panel_diff")
547547
548548 function M.append_diff(sid, lines)
549- if sid ~= panel.sid then return end
550549 vim.schedule(function()
551- local buf = panel.buf
550+ local p = find_panel_by_sid(sid)
551+ if not p then return end
552+ local buf = p.buf
552553 if not buf or not vim.api.nvim_buf_is_valid(buf) then return end
553- spinner_remove_if_present()
554+ spinner_remove_if_present(p)
554555 vim.bo[buf].modifiable = true
555556 local start_row = vim.api.nvim_buf_line_count(buf)
556557 vim.api.nvim_buf_set_lines(buf, start_row, start_row, false, lines)
@@ -567,44 +568,48 @@ function M.append_diff(sid, lines)
567568 pcall(vim.api.nvim_buf_set_extmark, buf, ns_diff, row, 0, { line_hl_group = hl })
568569 end
569570 end
570- spinner_render()
571- scroll_to_bottom_now()
571+ spinner_render(p)
572+ scroll_to_bottom_now(p)
572573 end)
573574 end
574575
575576 function M.append_tool_use(sid, name, summary)
576- if sid ~= panel.sid then return end
577577 vim.schedule(function()
578+ local p = find_panel_by_sid(sid)
579+ if not p then return end
578580 local line
579581 if summary and summary ~= "" then
580582 line = "> **" .. name .. "** `" .. summary .. "`"
581583 else
582584 line = "> **" .. name .. "**"
583585 end
584- append_lines({ "", line, "" })
585- spinner_render()
586+ append_lines(p, { "", line, "" })
587+ spinner_render(p)
586588 end)
587589 end
588590
589591 function M.append_separator(sid)
590- if sid ~= panel.sid then return end
591592 vim.schedule(function()
592- spinner_stop()
593- append_lines({ "", "---", "" })
593+ local p = find_panel_by_sid(sid)
594+ if not p then return end
595+ spinner_stop(p)
596+ append_lines(p, { "", "---", "" })
594597 end)
595598 end
596599
597600 function M.append_cancelled(sid)
598- if sid ~= panel.sid then return end
599601 vim.schedule(function()
600- spinner_stop()
601- append_lines({ "", "_— cancelled —_", "", "---", "" })
602+ local p = find_panel_by_sid(sid)
603+ if not p then return end
604+ spinner_stop(p)
605+ append_lines(p, { "", "_— cancelled —_", "", "---", "" })
602606 end)
603607 end
604608
605609 function M.cancel_current()
610+ local p = get_panel()
606611 local runner = require("claude.runner")
607- local sid = panel.sid
612+ local sid = p.sid
608613 if not sid then
609614 vim.notify("claude: no active session", vim.log.levels.WARN)
610615 return
@@ -615,22 +620,26 @@ function M.cancel_current()
615620 end
616621
617622 function M.append_stderr(sid, line)
618- if sid ~= panel.sid then return end
619- vim.schedule(function() append_lines({ "_stderr: " .. line .. "_" }) end)
623+ vim.schedule(function()
624+ local p = find_panel_by_sid(sid)
625+ if not p then return end
626+ append_lines(p, { "_stderr: " .. line .. "_" })
627+ end)
620628 end
621629
622630 function M.on_prompt_complete(sid, prompt, had_changes)
623- if sid ~= panel.sid then return end
624- vim.schedule(function() spinner_stop() end)
625- if had_changes then
626- vim.schedule(function()
627- append_lines({ "_recorded — " .. #(prompt.files or {}) .. " file(s) changed_", "" })
628- end)
629- end
631+ vim.schedule(function()
632+ local p = find_panel_by_sid(sid)
633+ if not p then return end
634+ spinner_stop(p)
635+ if had_changes then
636+ append_lines(p, { "_recorded — " .. #(prompt.files or {}) .. " file(s) changed_", "" })
637+ end
638+ end)
630639 end
631640
632641 function M.current_sid()
633- return panel.sid
642+ return get_panel().sid
634643 end
635644
636645 return M