My nvim configuration
fix(claude): resume
6 files changed, +692 -24
+84-24claude-nvim/lua/claude/ui.lua
| @@ -5,6 +5,9 @@ local default_width = 80 | ||
| 5 | 5 | -- panels keyed by tabpage handle |
| 6 | 6 | local panels = {} |
| 7 | 7 | |
| 8 | +-- forward declarations | |
| 9 | +local ensure_panel_for_current_tab | |
| 10 | + | |
| 8 | 11 | local SPINNER_FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } |
| 9 | 12 | |
| 10 | 13 | local function new_panel(tab) |
| @@ -14,6 +17,7 @@ local function new_panel(tab) | ||
| 14 | 17 | win = nil, |
| 15 | 18 | sid = nil, |
| 16 | 19 | width = default_width, |
| 20 | + autoscroll = true, | |
| 17 | 21 | spinner = { |
| 18 | 22 | timer = nil, |
| 19 | 23 | frame = 1, |
| @@ -46,6 +50,7 @@ local function spinner_text(p) | ||
| 46 | 50 | end |
| 47 | 51 | |
| 48 | 52 | local function scroll_to_bottom_now(p) |
| 53 | + if not p.autoscroll then return end | |
| 49 | 54 | if p.win and vim.api.nvim_win_is_valid(p.win) then |
| 50 | 55 | pcall(vim.api.nvim_win_call, p.win, function() |
| 51 | 56 | vim.cmd("normal! G") |
| @@ -53,6 +58,17 @@ local function scroll_to_bottom_now(p) | ||
| 53 | 58 | end |
| 54 | 59 | end |
| 55 | 60 | |
| 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 | + | |
| 56 | 72 | local function spinner_remove_if_present(p) |
| 57 | 73 | if p.spinner.row == nil then return end |
| 58 | 74 | if not p.buf or not vim.api.nvim_buf_is_valid(p.buf) then |
| @@ -194,6 +210,17 @@ function M.open_panel() | ||
| 194 | 210 | vim.wo[p.win].linebreak = true |
| 195 | 211 | vim.wo[p.win].number = false |
| 196 | 212 | 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 | + | |
| 197 | 224 | scroll_to_bottom_now(p) |
| 198 | 225 | end |
| 199 | 226 | |
| @@ -211,16 +238,11 @@ function M.is_open() | ||
| 211 | 238 | end |
| 212 | 239 | |
| 213 | 240 | function M.toggle() |
| 214 | - local p = get_panel() | |
| 215 | 241 | if M.is_open() then |
| 216 | 242 | M.close() |
| 217 | - else | |
| 218 | - if not p.sid then | |
| 219 | - M.start_new() | |
| 220 | - else | |
| 221 | - M.open_panel() | |
| 222 | - end | |
| 243 | + return | |
| 223 | 244 | end |
| 245 | + ensure_panel_for_current_tab() | |
| 224 | 246 | end |
| 225 | 247 | |
| 226 | 248 | function M.start_new() |
| @@ -310,6 +332,18 @@ local function replay_history(p, claude_id, cwd) | ||
| 310 | 332 | return turns > 0 |
| 311 | 333 | end |
| 312 | 334 | |
| 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 | + | |
| 313 | 347 | function M.resume_session(claude_id) |
| 314 | 348 | if not claude_id or claude_id == "" then |
| 315 | 349 | vim.notify("claude: missing claude_id", vim.log.levels.WARN) |
| @@ -319,6 +353,11 @@ function M.resume_session(claude_id) | ||
| 319 | 353 | local runner = require("claude.runner") |
| 320 | 354 | local store = require("claude.store") |
| 321 | 355 | 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 | |
| 322 | 361 | local session |
| 323 | 362 | for _, entry in ipairs(store.list_sessions(cwd)) do |
| 324 | 363 | local s = store.load_path(entry.path) |
| @@ -353,7 +392,13 @@ end | ||
| 353 | 392 | function M.resume_last() |
| 354 | 393 | local p = get_panel() |
| 355 | 394 | 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) | |
| 357 | 402 | if not sid then |
| 358 | 403 | vim.notify("claude: no previous session to resume", vim.log.levels.WARN) |
| 359 | 404 | M.start_new() |
| @@ -478,6 +523,24 @@ local function capture_visual_selection(bufnr) | ||
| 478 | 523 | return "" |
| 479 | 524 | end |
| 480 | 525 | |
| 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 | + | |
| 481 | 544 | function M.prompt_visual_chat() |
| 482 | 545 | local origin_buf = vim.api.nvim_get_current_buf() |
| 483 | 546 | local origin_win = vim.api.nvim_get_current_win() |
| @@ -492,27 +555,13 @@ function M.prompt_visual_chat() | ||
| 492 | 555 | local ft = vim.bo[origin_buf].filetype or "" |
| 493 | 556 | local context = string.format("```%s\n%s\n```", ft, sel) |
| 494 | 557 | |
| 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() | |
| 503 | 559 | M.open_input({ return_to = origin_win, context = context }) |
| 504 | 560 | end |
| 505 | 561 | |
| 506 | 562 | function M.open_prompt_keep_focus() |
| 507 | 563 | 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() | |
| 516 | 565 | M.open_input({ return_to = origin }) |
| 517 | 566 | end |
| 518 | 567 | |
| @@ -521,6 +570,8 @@ end | ||
| 521 | 570 | function M.append_user_prompt(sid, text) |
| 522 | 571 | local p = find_panel_by_sid(sid) |
| 523 | 572 | if not p then return end |
| 573 | + -- new prompt: snap viewport back to bottom and re-enable autoscroll | |
| 574 | + p.autoscroll = true | |
| 524 | 575 | local lines = { "## You", "" } |
| 525 | 576 | for _, l in ipairs(vim.split(text, "\n", { plain = true })) do |
| 526 | 577 | table.insert(lines, l) |
| @@ -642,4 +693,13 @@ function M.current_sid() | ||
| 642 | 693 | return get_panel().sid |
| 643 | 694 | end |
| 644 | 695 | |
| 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 | + | |
| 645 | 705 | 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) |