irongit

My nvim configuration

fix(claude): resume

huncholanehuncholaneauthored
parent 9cd9541commit af7a8889ce1f1404fa43bfa04fe3e87239d3a6aaBrowse files

6 files changed, +692 -24

+84-24claude-nvim/lua/claude/ui.lua
@@ -5,6 +5,9 @@ local default_width = 80
55 -- panels keyed by tabpage handle
66 local panels = {}
77
8+-- forward declarations
9+local ensure_panel_for_current_tab
10+
811 local SPINNER_FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }
912
1013 local function new_panel(tab)
@@ -14,6 +17,7 @@ local function new_panel(tab)
1417 win = nil,
1518 sid = nil,
1619 width = default_width,
20+ autoscroll = true,
1721 spinner = {
1822 timer = nil,
1923 frame = 1,
@@ -46,6 +50,7 @@ local function spinner_text(p)
4650 end
4751
4852 local function scroll_to_bottom_now(p)
53+ if not p.autoscroll then return end
4954 if p.win and vim.api.nvim_win_is_valid(p.win) then
5055 pcall(vim.api.nvim_win_call, p.win, function()
5156 vim.cmd("normal! G")
@@ -53,6 +58,17 @@ local function scroll_to_bottom_now(p)
5358 end
5459 end
5560
61+local function update_autoscroll_from_view(p)
62+ if not p.win or not vim.api.nvim_win_is_valid(p.win) then return end
63+ if not p.buf or not vim.api.nvim_buf_is_valid(p.buf) then return end
64+ local info = vim.fn.getwininfo(p.win)[1]
65+ if not info then return end
66+ local lc = vim.api.nvim_buf_line_count(p.buf)
67+ -- treat "near bottom" (within 1 line) as still autoscrolling, so the
68+ -- spinner row toggling does not yank the flag off.
69+ p.autoscroll = (info.botline >= lc - 1)
70+end
71+
5672 local function spinner_remove_if_present(p)
5773 if p.spinner.row == nil then return end
5874 if not p.buf or not vim.api.nvim_buf_is_valid(p.buf) then
@@ -194,6 +210,17 @@ function M.open_panel()
194210 vim.wo[p.win].linebreak = true
195211 vim.wo[p.win].number = false
196212 vim.wo[p.win].relativenumber = false
213+ p.autoscroll = true
214+
215+ vim.api.nvim_create_autocmd("WinScrolled", {
216+ group = vim.api.nvim_create_augroup("ClaudePanelScroll_" .. p.tab, { clear = true }),
217+ callback = function(args)
218+ local wid = tonumber(args.match)
219+ if wid ~= p.win then return end
220+ update_autoscroll_from_view(p)
221+ end,
222+ })
223+
197224 scroll_to_bottom_now(p)
198225 end
199226
@@ -211,16 +238,11 @@ function M.is_open()
211238 end
212239
213240 function M.toggle()
214- local p = get_panel()
215241 if M.is_open() then
216242 M.close()
217- else
218- if not p.sid then
219- M.start_new()
220- else
221- M.open_panel()
222- end
243+ return
223244 end
245+ ensure_panel_for_current_tab()
224246 end
225247
226248 function M.start_new()
@@ -310,6 +332,18 @@ local function replay_history(p, claude_id, cwd)
310332 return turns > 0
311333 end
312334
335+local function has_open_panel_for_cwd(p, cwd)
336+ if not cwd then return false end
337+ local runner = require("claude.runner")
338+ for tab, op in pairs(panels) do
339+ if tab ~= p.tab and op.sid then
340+ local osess = runner.get_session(op.sid)
341+ if osess and osess.cwd == cwd then return true end
342+ end
343+ end
344+ return false
345+end
346+
313347 function M.resume_session(claude_id)
314348 if not claude_id or claude_id == "" then
315349 vim.notify("claude: missing claude_id", vim.log.levels.WARN)
@@ -319,6 +353,11 @@ function M.resume_session(claude_id)
319353 local runner = require("claude.runner")
320354 local store = require("claude.store")
321355 local cwd = vim.fn.getcwd()
356+ if has_open_panel_for_cwd(p, cwd) then
357+ vim.notify("claude: dialog already open for this directory in another tab — starting new", vim.log.levels.INFO)
358+ M.start_new()
359+ return
360+ end
322361 local session
323362 for _, entry in ipairs(store.list_sessions(cwd)) do
324363 local s = store.load_path(entry.path)
@@ -353,7 +392,13 @@ end
353392 function M.resume_last()
354393 local p = get_panel()
355394 local runner = require("claude.runner")
356- local sid = runner.resume_from_pointer(vim.fn.getcwd())
395+ local cwd = vim.fn.getcwd()
396+ if has_open_panel_for_cwd(p, cwd) then
397+ vim.notify("claude: dialog already open for this directory in another tab — starting new", vim.log.levels.INFO)
398+ M.start_new()
399+ return
400+ end
401+ local sid = runner.resume_from_pointer(cwd)
357402 if not sid then
358403 vim.notify("claude: no previous session to resume", vim.log.levels.WARN)
359404 M.start_new()
@@ -478,6 +523,24 @@ local function capture_visual_selection(bufnr)
478523 return ""
479524 end
480525
526+ensure_panel_for_current_tab = function()
527+ local p = get_panel()
528+ if M.is_open() then return p end
529+ if p.sid then
530+ M.open_panel()
531+ return p
532+ end
533+ local cwd = vim.fn.getcwd()
534+ local store = require("claude.store")
535+ local ptr = store.read_pointer(cwd)
536+ if ptr and not has_open_panel_for_cwd(p, cwd) then
537+ M.resume_last()
538+ else
539+ M.start_new()
540+ end
541+ return p
542+end
543+
481544 function M.prompt_visual_chat()
482545 local origin_buf = vim.api.nvim_get_current_buf()
483546 local origin_win = vim.api.nvim_get_current_win()
@@ -492,27 +555,13 @@ function M.prompt_visual_chat()
492555 local ft = vim.bo[origin_buf].filetype or ""
493556 local context = string.format("```%s\n%s\n```", ft, sel)
494557
495- local p = get_panel()
496- if not M.is_open() then
497- if p.sid then
498- M.open_panel()
499- else
500- M.start_new()
501- end
502- end
558+ ensure_panel_for_current_tab()
503559 M.open_input({ return_to = origin_win, context = context })
504560 end
505561
506562 function M.open_prompt_keep_focus()
507563 local origin = vim.api.nvim_get_current_win()
508- local p = get_panel()
509- if not M.is_open() then
510- if p.sid then
511- M.open_panel()
512- else
513- M.start_new()
514- end
515- end
564+ ensure_panel_for_current_tab()
516565 M.open_input({ return_to = origin })
517566 end
518567
@@ -521,6 +570,8 @@ end
521570 function M.append_user_prompt(sid, text)
522571 local p = find_panel_by_sid(sid)
523572 if not p then return end
573+ -- new prompt: snap viewport back to bottom and re-enable autoscroll
574+ p.autoscroll = true
524575 local lines = { "## You", "" }
525576 for _, l in ipairs(vim.split(text, "\n", { plain = true })) do
526577 table.insert(lines, l)
@@ -642,4 +693,13 @@ function M.current_sid()
642693 return get_panel().sid
643694 end
644695
696+-- Test seams (do not use outside specs).
697+function M._test_get_panel(tab)
698+ return panels[tab or vim.api.nvim_get_current_tabpage()]
699+end
700+
701+function M._test_set_autoscroll(p, v)
702+ p.autoscroll = v
703+end
704+
645705 return M
+149-0claude-nvim/tests/leader_cp_spec.lua
@@ -0,0 +1,149 @@
1+-- <leader>cp = ui.open_prompt_keep_focus.
2+-- It should:
3+-- * resume the dir's saved session if a pointer exists AND no other tab
4+-- already has a panel open for the current cwd
5+-- * otherwise start a fresh session
6+--
7+-- Same rule applies to ui.prompt_visual_chat (which uses the same panel
8+-- bootstrap path).
9+
10+local function reload()
11+ package.loaded["claude.ui"] = nil
12+ package.loaded["claude.runner"] = nil
13+ package.loaded["claude.store"] = nil
14+ return require("claude.ui"), require("claude.runner"), require("claude.store")
15+end
16+
17+local function mk_session(store, cwd, claude_id)
18+ local s = {
19+ id = store.uuid(),
20+ started_at = os.time(),
21+ cwd = cwd,
22+ claude_id = claude_id,
23+ prompts = {},
24+ }
25+ store.save(s)
26+ store.write_pointer({
27+ session_id = s.id,
28+ claude_id = claude_id,
29+ ts = os.time(),
30+ cwd = cwd,
31+ })
32+ return s
33+end
34+
35+describe("<leader>cp (open_prompt_keep_focus)", function()
36+ local ui, runner, store
37+ local dir_a, dir_b
38+ local orig_cwd, tmp_data, orig_stdpath
39+ local orig_open_input
40+
41+ before_each(function()
42+ orig_cwd = vim.fn.getcwd()
43+ dir_a = vim.fn.tempname()
44+ dir_b = vim.fn.tempname()
45+ vim.fn.mkdir(dir_a, "p")
46+ vim.fn.mkdir(dir_b, "p")
47+
48+ tmp_data = vim.fn.tempname()
49+ vim.fn.mkdir(tmp_data, "p")
50+ orig_stdpath = vim.fn.stdpath
51+ vim.fn.stdpath = function(k) ---@diagnostic disable-line: duplicate-set-field
52+ if k == "data" then return tmp_data end
53+ return orig_stdpath(k)
54+ end
55+
56+ while vim.fn.tabpagenr("$") > 1 do vim.cmd("tabclose") end
57+
58+ ui, runner, store = reload()
59+ ui.setup({ width = 80, skip_permissions = false })
60+ runner.setup({ skip_permissions = false, debug = false })
61+
62+ -- stub the floating input window so headless tests don't try to
63+ -- create real prompt UIs
64+ orig_open_input = ui.open_input
65+ ui.open_input = function() end ---@diagnostic disable-line: duplicate-set-field
66+ end)
67+
68+ after_each(function()
69+ ui.open_input = orig_open_input
70+ while vim.fn.tabpagenr("$") > 1 do vim.cmd("tabclose") end
71+ vim.fn.stdpath = orig_stdpath
72+ vim.cmd("cd " .. vim.fn.fnameescape(orig_cwd))
73+ vim.fn.delete(dir_a, "rf")
74+ vim.fn.delete(dir_b, "rf")
75+ vim.fn.delete(tmp_data, "rf")
76+ end)
77+
78+ it("starts a new session when no pointer exists for the dir", function()
79+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
80+ ui.open_prompt_keep_focus()
81+ local sid = ui.current_sid()
82+ assert.is_truthy(sid)
83+ assert.is_nil(runner.get_session(sid).claude_id, "fresh session shouldn't have a claude_id")
84+ end)
85+
86+ it("resumes the dir's saved session when no other tab has it open", function()
87+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
88+ local sess = mk_session(store, dir_a, "aaaaaaaa-cccc-4ddd-9eee-fffffffffff1")
89+
90+ ui.open_prompt_keep_focus()
91+ local sid = ui.current_sid()
92+ local got = runner.get_session(sid)
93+ assert.equals(sess.claude_id, got.claude_id, "leader cp should resume pointer when no conflict")
94+ assert.equals(dir_a, got.cwd)
95+ end)
96+
97+ it("starts NEW session when another tab already has dir's panel open", function()
98+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
99+ local sess = mk_session(store, dir_a, "bbbbbbbb-cccc-4ddd-9eee-fffffffffff2")
100+
101+ -- tab1: open via leader cp -> resumes
102+ ui.open_prompt_keep_focus()
103+ local sid1 = ui.current_sid()
104+ assert.equals(sess.claude_id, runner.get_session(sid1).claude_id)
105+
106+ -- tab2 in same dir: leader cp must start new
107+ vim.cmd("tabnew")
108+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_a))
109+ ui.open_prompt_keep_focus()
110+ local sid2 = ui.current_sid()
111+
112+ assert.are_not.equals(sid1, sid2, "tab2 must not reuse tab1's sid")
113+ assert.is_nil(runner.get_session(sid2).claude_id,
114+ "tab2 leader cp should fall back to fresh session")
115+ end)
116+
117+ it("two tabs, different dirs: each leader cp resumes its own dir", function()
118+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
119+ local sess_a = mk_session(store, dir_a, "11111111-cccc-4ddd-9eee-fffffffffff3")
120+ vim.cmd("cd " .. vim.fn.fnameescape(dir_b))
121+ local sess_b = mk_session(store, dir_b, "22222222-cccc-4ddd-9eee-fffffffffff4")
122+
123+ -- tab1 in dir_a
124+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
125+ ui.open_prompt_keep_focus()
126+ local sid_a = ui.current_sid()
127+ assert.equals(sess_a.claude_id, runner.get_session(sid_a).claude_id)
128+
129+ -- tab2 in dir_b via :tcd
130+ vim.cmd("tabnew")
131+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_b))
132+ ui.open_prompt_keep_focus()
133+ local sid_b = ui.current_sid()
134+
135+ assert.are_not.equals(sid_a, sid_b)
136+ assert.equals(sess_b.claude_id, runner.get_session(sid_b).claude_id,
137+ "different-dir tab should resume its own pointer")
138+ assert.equals(dir_b, runner.get_session(sid_b).cwd)
139+ end)
140+
141+ it("subsequent leader cp in same tab is a no-op (keeps existing sid)", function()
142+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
143+ ui.open_prompt_keep_focus()
144+ local sid1 = ui.current_sid()
145+ ui.open_prompt_keep_focus()
146+ local sid2 = ui.current_sid()
147+ assert.equals(sid1, sid2, "same tab should not spawn a new sid each time")
148+ end)
149+end)
+11-0claude-nvim/tests/minimal_init.lua
@@ -0,0 +1,11 @@
1+-- Minimal init for plenary busted tests.
2+-- Run: nvim --headless -u tests/minimal_init.lua \
3+-- -c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"
4+
5+local plenary = vim.fn.expand("~/.local/share/nvim/lazy/plenary.nvim")
6+vim.opt.rtp:prepend(plenary)
7+
8+local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h")
9+vim.opt.rtp:prepend(plugin_root)
10+
11+vim.cmd("runtime plugin/plenary.vim")
+182-0claude-nvim/tests/multi_dir_spec.lua
@@ -0,0 +1,182 @@
1+-- Multi-tab + multi-directory behavior.
2+--
3+-- Rule: every resume entry point (resume_last, resume_session) must only
4+-- attach to the directory's saved session if there is no other open Claude
5+-- panel for that directory. Tabs in different directories must each be able
6+-- to resume their own session independently.
7+
8+local function reload()
9+ package.loaded["claude.ui"] = nil
10+ package.loaded["claude.runner"] = nil
11+ package.loaded["claude.store"] = nil
12+ return require("claude.ui"), require("claude.runner"), require("claude.store")
13+end
14+
15+local function mk_session(store, cwd, claude_id)
16+ local s = {
17+ id = store.uuid(),
18+ started_at = os.time(),
19+ cwd = cwd,
20+ claude_id = claude_id,
21+ prompts = {},
22+ }
23+ store.save(s)
24+ store.write_pointer({
25+ session_id = s.id,
26+ claude_id = claude_id,
27+ ts = os.time(),
28+ cwd = cwd,
29+ })
30+ return s
31+end
32+
33+describe("multi-tab + multi-directory", function()
34+ local ui, runner, store
35+ local dir_a, dir_b
36+ local orig_cwd, tmp_data, orig_stdpath
37+
38+ before_each(function()
39+ orig_cwd = vim.fn.getcwd()
40+ dir_a = vim.fn.tempname()
41+ dir_b = vim.fn.tempname()
42+ vim.fn.mkdir(dir_a, "p")
43+ vim.fn.mkdir(dir_b, "p")
44+
45+ tmp_data = vim.fn.tempname()
46+ vim.fn.mkdir(tmp_data, "p")
47+ orig_stdpath = vim.fn.stdpath
48+ vim.fn.stdpath = function(k) ---@diagnostic disable-line: duplicate-set-field
49+ if k == "data" then return tmp_data end
50+ return orig_stdpath(k)
51+ end
52+
53+ while vim.fn.tabpagenr("$") > 1 do vim.cmd("tabclose") end
54+
55+ ui, runner, store = reload()
56+ ui.setup({ width = 80, skip_permissions = false })
57+ runner.setup({ skip_permissions = false, debug = false })
58+ end)
59+
60+ after_each(function()
61+ while vim.fn.tabpagenr("$") > 1 do vim.cmd("tabclose") end
62+ vim.fn.stdpath = orig_stdpath
63+ vim.cmd("cd " .. vim.fn.fnameescape(orig_cwd))
64+ vim.fn.delete(dir_a, "rf")
65+ vim.fn.delete(dir_b, "rf")
66+ vim.fn.delete(tmp_data, "rf")
67+ end)
68+
69+ it("two tabs in different dirs each resume their own session", function()
70+ -- Pre-seed pointers + sessions in both dirs.
71+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
72+ local sess_a = mk_session(store, dir_a, "aaaaaaaa-1111-4222-8333-444444444444")
73+ vim.cmd("cd " .. vim.fn.fnameescape(dir_b))
74+ local sess_b = mk_session(store, dir_b, "bbbbbbbb-1111-4222-8333-444444444444")
75+
76+ -- Tab 1: cd dir_a, resume_last
77+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
78+ ui.resume_last()
79+ local sid_a = ui.current_sid()
80+ local got_a = runner.get_session(sid_a)
81+ assert.equals(sess_a.claude_id, got_a.claude_id, "tab1 resumes dir_a session")
82+ assert.equals(dir_a, got_a.cwd)
83+
84+ -- Tab 2: new tab, cd dir_b, resume_last
85+ vim.cmd("tabnew")
86+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_b))
87+ assert.is_nil(ui.current_sid())
88+ ui.resume_last()
89+ local sid_b = ui.current_sid()
90+ local got_b = runner.get_session(sid_b)
91+ assert.equals(sess_b.claude_id, got_b.claude_id, "tab2 resumes dir_b session")
92+ assert.equals(dir_b, got_b.cwd)
93+
94+ assert.are_not.equals(sid_a, sid_b)
95+ end)
96+
97+ it("second tab in SAME dir starts new session even if pointer exists", function()
98+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
99+ local sess_a = mk_session(store, dir_a, "cccccccc-1111-4222-8333-444444444444")
100+
101+ ui.resume_last()
102+ local sid1 = ui.current_sid()
103+ assert.equals(sess_a.claude_id, runner.get_session(sid1).claude_id)
104+
105+ vim.cmd("tabnew")
106+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_a))
107+ ui.resume_last()
108+ local sid2 = ui.current_sid()
109+ assert.are_not.equals(sid1, sid2)
110+ assert.is_nil(runner.get_session(sid2).claude_id,
111+ "second tab in same dir must get fresh session, not pointer's claude_id")
112+ end)
113+
114+ it("resume_session(claude_id) honors the same dir-conflict rule", function()
115+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
116+ local sess_a = mk_session(store, dir_a, "dddddddd-1111-4222-8333-444444444444")
117+
118+ -- Tab 1 in dir_a holds the dialog
119+ ui.resume_last()
120+ local sid1 = ui.current_sid()
121+ assert.equals(sess_a.claude_id, runner.get_session(sid1).claude_id)
122+
123+ -- Tab 2 in dir_a tries explicit resume_session -> must start new
124+ vim.cmd("tabnew")
125+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_a))
126+ ui.resume_session(sess_a.claude_id)
127+ local sid2 = ui.current_sid()
128+ assert.are_not.equals(sid1, sid2)
129+ assert.is_nil(runner.get_session(sid2).claude_id,
130+ "resume_session in same dir as open panel must fall back to start_new")
131+ end)
132+
133+ it("resume_session(claude_id) attaches when other tab is in a different dir", function()
134+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
135+ mk_session(store, dir_a, "eeeeeeee-1111-4222-8333-444444444444")
136+ ui.resume_last()
137+ local sid_a = ui.current_sid()
138+
139+ -- dir_b has its own session
140+ vim.cmd("tabnew")
141+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_b))
142+ local sess_b = mk_session(store, dir_b, "ffffffff-1111-4222-8333-444444444444")
143+ ui.resume_session(sess_b.claude_id)
144+ local sid_b = ui.current_sid()
145+
146+ assert.are_not.equals(sid_a, sid_b)
147+ assert.equals(sess_b.claude_id, runner.get_session(sid_b).claude_id,
148+ "different-dir tab should be able to resume_session normally")
149+ assert.equals(dir_b, runner.get_session(sid_b).cwd)
150+ end)
151+
152+ it("after first tab closes, second tab in same dir CAN resume", function()
153+ vim.cmd("cd " .. vim.fn.fnameescape(dir_a))
154+ local sess_a = mk_session(store, dir_a, "99999999-1111-4222-8333-444444444444")
155+ ui.resume_last()
156+ local sid1 = ui.current_sid()
157+
158+ -- close tab 1 (the dialog window) so panel.sid unbinds via TabClosed
159+ -- Simulate: open second tab, then drop the first by closing it.
160+ vim.cmd("tabnew")
161+ vim.cmd("tcd " .. vim.fn.fnameescape(dir_a))
162+ -- close tab 1 (which is now tab 1, current is tab 2). go back & close.
163+ vim.cmd("tabprevious")
164+ local prev_tab = vim.api.nvim_get_current_tabpage()
165+ vim.cmd("tabclose")
166+ -- Trigger TabClosed cleanup explicitly (autocmd may run already).
167+ vim.api.nvim_exec_autocmds("TabClosed", { data = { tabpage = prev_tab } })
168+
169+ -- Now only one tab remains, in dir_a; resume_last should attach to sess_a.
170+ -- (sid mirrors session.id, so reattaching the same session yields the
171+ -- same sid value — the point is that we attached, not that we made a
172+ -- new session.)
173+ ui.resume_last()
174+ local sid2 = ui.current_sid()
175+ local got = runner.get_session(sid2)
176+ assert.equals(sess_a.claude_id, got.claude_id,
177+ "no other open dialog -> resume should attach to dir's saved session")
178+ assert.equals(dir_a, got.cwd)
179+ assert.equals(sess_a.id, sid2,
180+ "reattached session should reuse its persisted session.id")
181+ end)
182+end)
+123-0claude-nvim/tests/multi_tab_spec.lua
@@ -0,0 +1,123 @@
1+-- Verifies: when tab1 holds an active session and tab2 calls resume_last,
2+-- tab2 must NOT attach to tab1's session — it must start a fresh one.
3+
4+local function reload()
5+ package.loaded["claude.ui"] = nil
6+ package.loaded["claude.runner"] = nil
7+ package.loaded["claude.store"] = nil
8+ return require("claude.ui"), require("claude.runner"), require("claude.store")
9+end
10+
11+describe("multi-tab session isolation", function()
12+ local ui, runner, store
13+ local tmp_cwd
14+ local orig_cwd
15+ local orig_data
16+ local tmp_data
17+
18+ before_each(function()
19+ orig_cwd = vim.fn.getcwd()
20+ tmp_cwd = vim.fn.tempname()
21+ vim.fn.mkdir(tmp_cwd, "p")
22+ vim.cmd("cd " .. vim.fn.fnameescape(tmp_cwd))
23+
24+ -- redirect stdpath("data") so store writes to tmp
25+ tmp_data = vim.fn.tempname()
26+ vim.fn.mkdir(tmp_data, "p")
27+ orig_data = vim.fn.stdpath
28+ vim.fn.stdpath = function(k) ---@diagnostic disable-line: duplicate-set-field
29+ if k == "data" then return tmp_data end
30+ return orig_data(k)
31+ end
32+
33+ -- close any extra tabs from prior test
34+ while vim.fn.tabpagenr("$") > 1 do
35+ vim.cmd("tabclose")
36+ end
37+
38+ ui, runner, store = reload()
39+ ui.setup({ width = 80, skip_permissions = false })
40+ runner.setup({ skip_permissions = false, debug = false })
41+ end)
42+
43+ after_each(function()
44+ while vim.fn.tabpagenr("$") > 1 do
45+ vim.cmd("tabclose")
46+ end
47+ vim.fn.stdpath = orig_data
48+ vim.cmd("cd " .. vim.fn.fnameescape(orig_cwd))
49+ vim.fn.delete(tmp_cwd, "rf")
50+ vim.fn.delete(tmp_data, "rf")
51+ end)
52+
53+ it("resume_last in second tab spawns new session, not the one bound to first tab", function()
54+ -- Tab 1: simulate an existing attached session with a known claude_id.
55+ local fake_claude_id = "11111111-aaaa-4bbb-bccc-222222222222"
56+ local sess1 = {
57+ id = store.uuid(),
58+ started_at = os.time(),
59+ cwd = tmp_cwd,
60+ claude_id = fake_claude_id,
61+ prompts = {},
62+ }
63+ store.save(sess1)
64+ store.write_pointer({
65+ session_id = sess1.id,
66+ claude_id = fake_claude_id,
67+ ts = os.time(),
68+ cwd = tmp_cwd,
69+ })
70+
71+ -- Bind sess1 to tab1's panel (no real claude job — attach_existing only registers state).
72+ local sid1 = runner.attach_existing(sess1)
73+ -- Force panel.sid for current tab via start_new path — but we want sid1 specifically:
74+ -- call resume_last from tab1 which will pick up the pointer and attach sess1.
75+ ui.resume_last()
76+
77+ local tab1_sid = ui.current_sid()
78+ assert.is_truthy(tab1_sid, "tab1 should have a sid after resume_last")
79+ local tab1_sess = runner.get_session(tab1_sid)
80+ assert.equals(fake_claude_id, tab1_sess.claude_id)
81+
82+ -- Tab 2: new tab in same cwd, call resume_last.
83+ vim.cmd("tabnew")
84+ -- Sanity: new tab, no panel yet.
85+ assert.is_nil(ui.current_sid())
86+
87+ ui.resume_last()
88+ local tab2_sid = ui.current_sid()
89+
90+ assert.is_truthy(tab2_sid, "tab2 should have a sid after resume_last")
91+ assert.are_not.equals(tab1_sid, tab2_sid, "tab2 must not reuse tab1's sid")
92+
93+ local tab2_sess = runner.get_session(tab2_sid)
94+ assert.is_truthy(tab2_sess)
95+ -- New session: distinct internal id, no inherited claude_id (start_new => nil)
96+ assert.are_not.equals(sess1.id, tab2_sess.id, "tab2 must have distinct session.id")
97+ assert.is_nil(tab2_sess.claude_id, "tab2 fresh session should not inherit claude_id")
98+ end)
99+
100+ it("resume_last in only tab still attaches when no conflict", function()
101+ local fake_claude_id = "33333333-cccc-4ddd-eeee-444444444444"
102+ local sess = {
103+ id = store.uuid(),
104+ started_at = os.time(),
105+ cwd = tmp_cwd,
106+ claude_id = fake_claude_id,
107+ prompts = {},
108+ }
109+ store.save(sess)
110+ store.write_pointer({
111+ session_id = sess.id,
112+ claude_id = fake_claude_id,
113+ ts = os.time(),
114+ cwd = tmp_cwd,
115+ })
116+
117+ ui.resume_last()
118+ local sid = ui.current_sid()
119+ assert.is_truthy(sid)
120+ local got = runner.get_session(sid)
121+ assert.equals(fake_claude_id, got.claude_id, "single tab should resume pointer's claude_id")
122+ end)
123+end)
+143-0claude-nvim/tests/scroll_spec.lua
@@ -0,0 +1,143 @@
1+-- Scroll + spinner behavior in the panel buffer.
2+--
3+-- Requirements covered:
4+-- 1. User can scroll up while streaming happens (no force-snap to bottom).
5+-- 2. Spinner appears at bottom immediately after a prompt is submitted.
6+-- 3. Spinner keeps spinning across streaming chunks.
7+-- 4. Panel keeps autoscrolling to bottom until the user interacts with it
8+-- (interaction = scroll away from bottom).
9+
10+local function reload()
11+ package.loaded["claude.ui"] = nil
12+ package.loaded["claude.runner"] = nil
13+ package.loaded["claude.store"] = nil
14+ return require("claude.ui"), require("claude.runner"), require("claude.store")
15+end
16+
17+local function last_line(buf)
18+ local lc = vim.api.nvim_buf_line_count(buf)
19+ return vim.api.nvim_buf_get_lines(buf, lc - 1, lc, false)[1] or ""
20+end
21+
22+describe("panel scroll + spinner", function()
23+ local ui, runner
24+ local tmp_cwd, orig_cwd, tmp_data, orig_stdpath
25+
26+ before_each(function()
27+ orig_cwd = vim.fn.getcwd()
28+ tmp_cwd = vim.fn.tempname()
29+ vim.fn.mkdir(tmp_cwd, "p")
30+ vim.cmd("cd " .. vim.fn.fnameescape(tmp_cwd))
31+
32+ tmp_data = vim.fn.tempname()
33+ vim.fn.mkdir(tmp_data, "p")
34+ orig_stdpath = vim.fn.stdpath
35+ vim.fn.stdpath = function(k) ---@diagnostic disable-line: duplicate-set-field
36+ if k == "data" then return tmp_data end
37+ return orig_stdpath(k)
38+ end
39+
40+ while vim.fn.tabpagenr("$") > 1 do vim.cmd("tabclose") end
41+
42+ ui, runner = reload()
43+ ui.setup({ width = 80, skip_permissions = false })
44+ runner.setup({ skip_permissions = false, debug = false })
45+ end)
46+
47+ after_each(function()
48+ while vim.fn.tabpagenr("$") > 1 do vim.cmd("tabclose") end
49+ vim.fn.stdpath = orig_stdpath
50+ vim.cmd("cd " .. vim.fn.fnameescape(orig_cwd))
51+ vim.fn.delete(tmp_cwd, "rf")
52+ vim.fn.delete(tmp_data, "rf")
53+ end)
54+
55+ it("spinner appears at bottom immediately after prompt submit", function()
56+ ui.start_new()
57+ local p = ui._test_get_panel()
58+ ui.append_user_prompt(p.sid, "hello")
59+
60+ assert.is_true(p.spinner.active, "spinner should be active right after prompt submit")
61+ assert.matches("thinking", last_line(p.buf), "last buffer line should show spinner text")
62+ assert.is_truthy(p.spinner.row, "spinner row should be tracked")
63+ end)
64+
65+ it("spinner keeps spinning across streaming chunks", function()
66+ ui.start_new()
67+ local p = ui._test_get_panel()
68+ ui.append_user_prompt(p.sid, "hi")
69+
70+ ui.append_assistant_text(p.sid, "first chunk ")
71+ vim.wait(40)
72+ ui.append_assistant_text(p.sid, "second chunk\n")
73+ vim.wait(40)
74+ ui.append_assistant_text(p.sid, "third\n")
75+ vim.wait(40)
76+
77+ assert.is_true(p.spinner.active, "spinner should still be active during streaming")
78+ assert.matches("thinking", last_line(p.buf), "spinner row should still be at bottom")
79+ end)
80+
81+ it("autoscrolls to bottom while user has not interacted", function()
82+ ui.start_new()
83+ local p = ui._test_get_panel()
84+ assert.is_true(p.autoscroll, "default autoscroll true")
85+
86+ ui.append_user_prompt(p.sid, "hi")
87+ for i = 1, 30 do
88+ ui.append_assistant_text(p.sid, "stream line " .. i .. "\n")
89+ end
90+ vim.wait(80)
91+
92+ local lc = vim.api.nvim_buf_line_count(p.buf)
93+ local cur = vim.api.nvim_win_get_cursor(p.win)
94+ assert.equals(lc, cur[1], "cursor should be parked on last line while autoscrolling")
95+ assert.is_true(p.autoscroll, "autoscroll should remain enabled")
96+ end)
97+
98+ it("does NOT force-scroll when user has scrolled away from bottom", function()
99+ ui.start_new()
100+ local p = ui._test_get_panel()
101+ ui.append_user_prompt(p.sid, "hi")
102+ for i = 1, 30 do
103+ ui.append_assistant_text(p.sid, "L" .. i .. "\n")
104+ end
105+ vim.wait(80)
106+
107+ -- Simulate user scrolling up: park cursor mid-buffer and disable autoscroll
108+ -- (mimicking what the WinScrolled autocmd would do once botline < lc).
109+ vim.api.nvim_win_set_cursor(p.win, { 5, 0 })
110+ ui._test_set_autoscroll(p, false)
111+
112+ -- Stream more content while user is "reading" upper part of buffer.
113+ for i = 1, 10 do
114+ ui.append_assistant_text(p.sid, "MORE" .. i .. "\n")
115+ end
116+ vim.wait(80)
117+
118+ local cur = vim.api.nvim_win_get_cursor(p.win)
119+ assert.equals(5, cur[1], "cursor must stay where user parked it; no force scroll")
120+ assert.is_false(p.autoscroll, "autoscroll stays disabled until next user prompt")
121+ end)
122+
123+ it("new user prompt re-enables autoscroll and snaps to bottom", function()
124+ ui.start_new()
125+ local p = ui._test_get_panel()
126+ ui.append_user_prompt(p.sid, "first")
127+ for i = 1, 20 do ui.append_assistant_text(p.sid, "x" .. i .. "\n") end
128+ vim.wait(60)
129+
130+ -- user scrolled away
131+ vim.api.nvim_win_set_cursor(p.win, { 3, 0 })
132+ ui._test_set_autoscroll(p, false)
133+
134+ -- second prompt: should reset autoscroll and snap cursor to bottom
135+ ui.append_user_prompt(p.sid, "second")
136+ vim.wait(40)
137+
138+ assert.is_true(p.autoscroll, "new prompt should re-enable autoscroll")
139+ local lc = vim.api.nvim_buf_line_count(p.buf)
140+ local cur = vim.api.nvim_win_get_cursor(p.win)
141+ assert.equals(lc, cur[1], "cursor should be at last line after new prompt")
142+ end)
143+end)