From 763ee39f6abaefe3d38e89f1e27e5e7ee31ad837 Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski <tk@coder.com>
Date: Thu, 7 Aug 2025 12:28:57 +0200
Subject: [PATCH] feat: redesign diff view with horizontal layout and new tab
 options

Change-Id: Ia8df210bad3a05b7eddd92fafbbb4f8aa90e2704
Signed-off-by: Thomas Kosiewski <tk@coder.com>
---
 CLAUDE.md                                     |   4 +
 dev-config.lua                                |  28 +-
 fixtures/nvim-tree/lazy-lock.json             |   2 +-
 lua/claudecode/config.lua                     |  20 +-
 lua/claudecode/diff.lua                       | 782 +++++++++++++-----
 lua/claudecode/types.lua                      |  13 +-
 tests/integration/command_args_spec.lua       |   7 +-
 tests/mocks/vim.lua                           | 254 +++++-
 tests/unit/config_spec.lua                    |  26 +-
 .../unit/diff_hide_terminal_new_tab_spec.lua  | 122 +++
 tests/unit/diff_mcp_spec.lua                  |   9 +-
 tests/unit/diff_spec.lua                      |  26 +-
 tests/unit/diff_ui_cleanup_spec.lua           | 119 +++
 13 files changed, 1141 insertions(+), 271 deletions(-)
 create mode 100644 tests/unit/diff_hide_terminal_new_tab_spec.lua
 create mode 100644 tests/unit/diff_ui_cleanup_spec.lua

diff --git a/CLAUDE.md b/CLAUDE.md
index b396b7b..0298fc1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -289,6 +289,8 @@ require("claudecode").setup({
 The `diff_opts` configuration allows you to customize diff behavior:
 
 - `keep_terminal_focus` (boolean, default: `false`) - When enabled, keeps focus in the Claude Code terminal when a diff opens instead of moving focus to the diff buffer. This allows you to continue using terminal keybindings like `<CR>` for accepting/rejecting diffs without accidentally triggering other mappings.
+- `open_in_new_tab` (boolean, default: `false`) - Open diffs in a new tab instead of the current tab.
+- `hide_terminal_in_new_tab` (boolean, default: `false`) - When opening diffs in a new tab, do not show the Claude terminal split in that new tab. The terminal remains in the original tab, giving maximum screen estate for reviewing the diff.
 
 **Example use case**: If you frequently use `<CR>` or arrow keys in the Claude Code terminal to accept/reject diffs, enable this option to prevent focus from moving to the diff buffer where `<CR>` might trigger unintended actions.
 
@@ -296,6 +298,8 @@ The `diff_opts` configuration allows you to customize diff behavior:
 require("claudecode").setup({
   diff_opts = {
     keep_terminal_focus = true,  -- If true, moves focus back to terminal after diff opens
+    open_in_new_tab = true,      -- Open diff in a separate tab
+    hide_terminal_in_new_tab = true, -- In the new tab, do not show Claude terminal
     auto_close_on_accept = true,
     show_diff_stats = true,
     vertical_split = true,
diff --git a/dev-config.lua b/dev-config.lua
index d1a34bc..d4d84dc 100644
--- a/dev-config.lua
+++ b/dev-config.lua
@@ -40,29 +40,29 @@ return {
   },
 
   -- Development configuration - all options shown with defaults commented out
+  ---@type ClaudeCodeConfig
   opts = {
     -- Server Configuration
-    -- port_range = { min = 10000, max = 65535 },  -- WebSocket server port range
-    -- auto_start = true,                          -- Auto-start server on Neovim startup
-    -- log_level = "info",                         -- "trace", "debug", "info", "warn", "error"
-    -- terminal_cmd = nil,                         -- Custom terminal command (default: "claude")
+    -- port_range = { min = 10000, max = 65535 }, -- WebSocket server port range
+    -- auto_start = true, -- Auto-start server on Neovim startup
+    -- log_level = "info", -- "trace", "debug", "info", "warn", "error"
+    -- terminal_cmd = nil, -- Custom terminal command (default: "claude")
 
     -- Selection Tracking
-    -- track_selection = true,                     -- Enable real-time selection tracking
-    -- visual_demotion_delay_ms = 50,             -- Delay before demoting visual selection (ms)
+    -- track_selection = true, -- Enable real-time selection tracking
+    -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms)
 
     -- Connection Management
-    -- connection_wait_delay = 200,                -- Wait time after connection before sending queued @ mentions (ms)
-    -- connection_timeout = 10000,                 -- Max time to wait for Claude Code connection (ms)
-    -- queue_timeout = 5000,                       -- Max time to keep @ mentions in queue (ms)
+    -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms)
+    -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms)
+    -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms)
 
     -- Diff Integration
     -- diff_opts = {
-    --   auto_close_on_accept = true,              -- Close diff view after accepting changes
-    --   show_diff_stats = true,                   -- Show diff statistics
-    --   vertical_split = true,                    -- Use vertical split for diffs
-    --   open_in_current_tab = true,               -- Open diffs in current tab vs new tab
-    --   keep_terminal_focus = false,              -- If true, moves focus back to terminal after diff opens
+    --   layout = "horizontal", -- "vertical" or "horizontal" diff layout
+    --   open_in_new_tab = true, -- Open diff in a new tab (false = use current tab)
+    --   keep_terminal_focus = true, -- Keep focus in terminal after opening diff
+    --   hide_terminal_in_new_tab = true, -- Hide Claude terminal in the new diff tab for more review space
     -- },
 
     -- Terminal Configuration
diff --git a/fixtures/nvim-tree/lazy-lock.json b/fixtures/nvim-tree/lazy-lock.json
index ebf5acd..aa68837 100644
--- a/fixtures/nvim-tree/lazy-lock.json
+++ b/fixtures/nvim-tree/lazy-lock.json
@@ -1,6 +1,6 @@
 {
   "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" },
-  "nvim-tree.lua": { "branch": "master", "commit": "0a7fcdf3f8ba208f4260988a198c77ec11748339" },
+  "nvim-tree.lua": { "branch": "master", "commit": "0a52012d611f3c1492b8d2aba363fabf734de91d" },
   "nvim-web-devicons": { "branch": "master", "commit": "3362099de3368aa620a8105b19ed04c2053e38c0" },
   "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" }
 }
diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua
index 5676781..4826ad8 100644
--- a/lua/claudecode/config.lua
+++ b/lua/claudecode/config.lua
@@ -19,11 +19,10 @@ M.defaults = {
   connection_timeout = 10000, -- Maximum time to wait for Claude Code to connect (milliseconds)
   queue_timeout = 5000, -- Maximum time to keep @ mentions in queue (milliseconds)
   diff_opts = {
-    auto_close_on_accept = true,
-    show_diff_stats = true,
-    vertical_split = true,
-    open_in_current_tab = true, -- Use current tab instead of creating new tab
+    layout = "vertical",
+    open_in_new_tab = false, -- Open diff in a new tab (false = use current tab)
     keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
+    hide_terminal_in_new_tab = false, -- If true and opening in a new tab, do not show Claude terminal there
   },
   models = {
     { name = "Claude Opus 4.1 (Latest)", value = "opus" },
@@ -104,11 +103,16 @@ function M.validate(config)
   assert(type(config.queue_timeout) == "number" and config.queue_timeout > 0, "queue_timeout must be a positive number")
 
   assert(type(config.diff_opts) == "table", "diff_opts must be a table")
-  assert(type(config.diff_opts.auto_close_on_accept) == "boolean", "diff_opts.auto_close_on_accept must be a boolean")
-  assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean")
-  assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean")
-  assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean")
+  assert(
+    config.diff_opts.layout == "vertical" or config.diff_opts.layout == "horizontal",
+    "diff_opts.layout must be 'vertical' or 'horizontal'"
+  )
+  assert(type(config.diff_opts.open_in_new_tab) == "boolean", "diff_opts.open_in_new_tab must be a boolean")
   assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
+  assert(
+    type(config.diff_opts.hide_terminal_in_new_tab) == "boolean",
+    "diff_opts.hide_terminal_in_new_tab must be a boolean"
+  )
 
   -- Validate env
   assert(type(config.env) == "table", "env must be a table")
diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua
index 934ff89..58dd045 100644
--- a/lua/claudecode/diff.lua
+++ b/lua/claudecode/diff.lua
@@ -4,14 +4,34 @@ local M = {}
 
 local logger = require("claudecode.logger")
 
--- Global state management for active diffs
+-- Window options for terminal display (internal type, not exposed in public API)
+---@class WindowOptions
+---@field number boolean Show line numbers
+---@field relativenumber boolean Show relative line numbers
+---@field signcolumn string Sign column display mode
+---@field statuscolumn string Status column format
+---@field foldcolumn string Fold column width
+---@field cursorline boolean Highlight cursor line
+---@field cursorcolumn boolean Highlight cursor column
+---@field colorcolumn string Columns to highlight
+---@field cursorlineopt string Cursor line options
+---@field spell boolean Enable spell checking
+---@field list boolean Show invisible characters
+---@field wrap boolean Wrap long lines
+---@field linebreak boolean Break lines at word boundaries
+---@field breakindent boolean Indent wrapped lines
+---@field showbreak string String to show at line breaks
+---@field scrolloff number Lines to keep above/below cursor
+---@field sidescrolloff number Columns to keep left/right of cursor
 
 ---@type ClaudeCodeConfig
 local config
 
 ---@type number
 local autocmd_group
----Get or create the autocmd group
+
+---Get or create the autocmd group for diff operations
+---@return number autocmd_group The autocmd group ID
 local function get_autocmd_group()
   if not autocmd_group then
     autocmd_group = vim.api.nvim_create_augroup("ClaudeCodeMCPDiff", { clear = true })
@@ -31,7 +51,6 @@ local function find_main_editor_window()
     local filetype = vim.api.nvim_buf_get_option(buf, "filetype")
     local win_config = vim.api.nvim_win_get_config(win)
 
-    -- Check if this is a suitable window
     local is_suitable = true
 
     -- Skip floating windows
@@ -39,12 +58,10 @@ local function find_main_editor_window()
       is_suitable = false
     end
 
-    -- Skip special buffer types
     if is_suitable and (buftype == "terminal" or buftype == "prompt") then
       is_suitable = false
     end
 
-    -- Skip known sidebar filetypes
     if
       is_suitable
       and (
@@ -60,7 +77,6 @@ local function find_main_editor_window()
       is_suitable = false
     end
 
-    -- This looks like a main editor window
     if is_suitable then
       return win
     end
@@ -87,7 +103,6 @@ local function find_claudecode_terminal_window()
   for _, win in ipairs(vim.api.nvim_list_wins()) do
     if vim.api.nvim_win_get_buf(win) == terminal_bufnr then
       local win_config = vim.api.nvim_win_get_config(win)
-      -- Skip floating windows
       if not (win_config.relative and win_config.relative ~= "") then
         return win
       end
@@ -97,6 +112,184 @@ local function find_claudecode_terminal_window()
   return nil
 end
 
+---Create a split based on configured layout
+local function create_split()
+  if config and config.diff_opts and config.diff_opts.layout == "horizontal" then
+    -- Ensure the new window is created below the current one regardless of user 'splitbelow' setting
+    vim.cmd("belowright split")
+  else
+    -- Ensure the new window is created to the right of the current one regardless of user 'splitright' setting
+    vim.cmd("rightbelow vsplit")
+  end
+end
+
+---Capture window-local options from a window
+---@param win_id number Window ID to capture options from
+---@return WindowOptions options Window options
+local function capture_window_options(win_id)
+  local options = {}
+
+  -- Display options
+  options.number = vim.api.nvim_get_option_value("number", { win = win_id })
+  options.relativenumber = vim.api.nvim_get_option_value("relativenumber", { win = win_id })
+  options.signcolumn = vim.api.nvim_get_option_value("signcolumn", { win = win_id })
+  options.statuscolumn = vim.api.nvim_get_option_value("statuscolumn", { win = win_id })
+  options.foldcolumn = vim.api.nvim_get_option_value("foldcolumn", { win = win_id })
+
+  -- Visual options
+  options.cursorline = vim.api.nvim_get_option_value("cursorline", { win = win_id })
+  options.cursorcolumn = vim.api.nvim_get_option_value("cursorcolumn", { win = win_id })
+  options.colorcolumn = vim.api.nvim_get_option_value("colorcolumn", { win = win_id })
+  options.cursorlineopt = vim.api.nvim_get_option_value("cursorlineopt", { win = win_id })
+
+  -- Text options
+  options.spell = vim.api.nvim_get_option_value("spell", { win = win_id })
+  options.list = vim.api.nvim_get_option_value("list", { win = win_id })
+  options.wrap = vim.api.nvim_get_option_value("wrap", { win = win_id })
+  options.linebreak = vim.api.nvim_get_option_value("linebreak", { win = win_id })
+  options.breakindent = vim.api.nvim_get_option_value("breakindent", { win = win_id })
+  options.showbreak = vim.api.nvim_get_option_value("showbreak", { win = win_id })
+
+  -- Scroll options
+  options.scrolloff = vim.api.nvim_get_option_value("scrolloff", { win = win_id })
+  options.sidescrolloff = vim.api.nvim_get_option_value("sidescrolloff", { win = win_id })
+
+  return options
+end
+
+---Apply window-local options to a window
+---@param win_id number Window ID to apply options to
+---@param options WindowOptions Window options to apply
+local function apply_window_options(win_id, options)
+  for opt_name, opt_value in pairs(options) do
+    pcall(vim.api.nvim_set_option_value, opt_name, opt_value, { win = win_id })
+  end
+end
+
+---Get default terminal window options
+---@return WindowOptions Default options for terminal windows
+local function get_default_terminal_options()
+  return {
+    number = false,
+    relativenumber = false,
+    signcolumn = "no",
+    statuscolumn = "",
+    foldcolumn = "0",
+    cursorline = false,
+    cursorcolumn = false,
+    colorcolumn = "",
+    cursorlineopt = "both",
+    spell = false,
+    list = false,
+    wrap = true,
+    linebreak = false,
+    breakindent = false,
+    showbreak = "",
+    scrolloff = 0,
+    sidescrolloff = 0,
+  }
+end
+
+---Display existing Claude Code terminal in new tab
+---@return number original_tab The original tab number
+---@return number? terminal_win Terminal window in new tab
+---@return boolean had_terminal_in_original True if terminal was visible in original tab
+---@return number? new_tab The handle of the newly created tab
+local function display_terminal_in_new_tab()
+  local original_tab = vim.api.nvim_get_current_tabpage()
+
+  -- Get existing terminal buffer
+  local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
+  if not terminal_ok then
+    vim.cmd("tabnew")
+    local new_tab = vim.api.nvim_get_current_tabpage()
+    return original_tab, nil, false, new_tab
+  end
+
+  local terminal_bufnr = terminal_module.get_active_terminal_bufnr()
+  if not terminal_bufnr or not vim.api.nvim_buf_is_valid(terminal_bufnr) then
+    vim.cmd("tabnew")
+    local new_tab = vim.api.nvim_get_current_tabpage()
+    return original_tab, nil, false, new_tab
+  end
+
+  local existing_terminal_win = find_claudecode_terminal_window()
+  local had_terminal_in_original = existing_terminal_win ~= nil
+  local terminal_options
+  if existing_terminal_win then
+    terminal_options = capture_window_options(existing_terminal_win)
+  else
+    terminal_options = get_default_terminal_options()
+  end
+
+  vim.cmd("tabnew")
+  local new_tab = vim.api.nvim_get_current_tabpage()
+
+  -- Mark the initial, unnamed buffer in the new tab as ephemeral to avoid leaks
+  -- When this buffer gets hidden (replaced or tab closed), wipe it automatically.
+  local initial_buf = vim.api.nvim_get_current_buf()
+  local name_ok, initial_name = pcall(vim.api.nvim_buf_get_name, initial_buf)
+  local mod_ok, initial_modified = pcall(vim.api.nvim_buf_get_option, initial_buf, "modified")
+  local linecount_ok, initial_linecount = pcall(function()
+    return vim.api.nvim_buf_line_count(initial_buf)
+  end)
+  if name_ok and mod_ok and linecount_ok then
+    if (initial_name == nil or initial_name == "") and initial_modified == false and initial_linecount <= 1 then
+      pcall(vim.api.nvim_buf_set_option, initial_buf, "bufhidden", "wipe")
+    end
+  end
+
+  local terminal_config = config.terminal or {}
+  local split_side = terminal_config.split_side or "right"
+  local split_width = terminal_config.split_width_percentage or 0.30
+
+  -- Optionally hide the Claude terminal in the new tab for more review space
+  local hide_in_new_tab = false
+  if config and config.diff_opts and type(config.diff_opts.hide_terminal_in_new_tab) == "boolean" then
+    hide_in_new_tab = config.diff_opts.hide_terminal_in_new_tab
+  end
+
+  if hide_in_new_tab or not terminal_bufnr or not vim.api.nvim_buf_is_valid(terminal_bufnr) then
+    -- Do not create a terminal split in the new tab
+    return original_tab, nil, had_terminal_in_original, new_tab
+  end
+
+  vim.cmd("vsplit")
+
+  local terminal_win = vim.api.nvim_get_current_win()
+
+  if split_side == "left" then
+    vim.cmd("wincmd H")
+  else
+    vim.cmd("wincmd L")
+  end
+
+  vim.api.nvim_win_set_buf(terminal_win, terminal_bufnr)
+
+  apply_window_options(terminal_win, terminal_options)
+
+  -- Set up autocmd to enter terminal mode when focusing this terminal window
+  vim.api.nvim_create_autocmd("BufEnter", {
+    buffer = terminal_bufnr,
+    group = get_autocmd_group(),
+    callback = function()
+      -- Only enter insert mode if we're in a terminal buffer and in normal mode
+      if vim.bo.buftype == "terminal" and vim.fn.mode() == "n" then
+        vim.cmd("startinsert")
+      end
+    end,
+    desc = "Auto-enter terminal mode when focusing Claude Code terminal",
+  })
+
+  local total_width = vim.o.columns
+  local terminal_width = math.floor(total_width * split_width)
+  vim.api.nvim_win_set_width(terminal_win, terminal_width)
+
+  vim.cmd("wincmd " .. (split_side == "right" and "h" or "l"))
+
+  return original_tab, terminal_win, had_terminal_in_original, new_tab
+end
+
 ---Check if a buffer has unsaved changes (is dirty).
 ---@param file_path string The file path to check
 ---@return boolean true if the buffer is dirty, false otherwise
@@ -113,10 +306,10 @@ local function is_buffer_dirty(file_path)
 end
 
 ---Setup the diff module
----@param user_config ClaudeCodeConfig? The configuration passed from init.lua
+---@param user_config ClaudeCodeConfig The configuration passed from init.lua
 function M.setup(user_config)
   -- Store the configuration for later use
-  config = user_config or {}
+  config = user_config
 end
 
 ---Open a diff view between two files
@@ -225,6 +418,9 @@ local function cleanup_temp_file(tmp_file)
 end
 
 ---Detect filetype from a path or existing buffer (best-effort)
+---@param path string The file path to detect filetype from
+---@param buf number? Optional buffer number to check for filetype
+---@return string? filetype The detected filetype or nil
 local function detect_filetype(path, buf)
   -- 1) Try Neovim's builtin matcher if available (>=0.10)
   if vim.filetype and type(vim.filetype.match) == "function" then
@@ -269,6 +465,170 @@ local function detect_filetype(path, buf)
   return simple_map[ext]
 end
 
+---Decide whether to reuse the target window or split for the original file
+---@param target_win NvimWin
+---@param old_file_path string
+---@param is_new_file boolean
+---@param terminal_win_in_new_tab NvimWin|nil
+---@return DiffWindowChoice
+local function choose_original_window(target_win, old_file_path, is_new_file, terminal_win_in_new_tab)
+  local in_new_tab = terminal_win_in_new_tab ~= nil
+  local current_buf = vim.api.nvim_win_get_buf(target_win)
+  local current_buf_path = vim.api.nvim_buf_get_name(current_buf)
+  local is_empty_buffer = current_buf_path == "" and vim.api.nvim_buf_get_option(current_buf, "modified") == false
+
+  if in_new_tab then
+    return {
+      decision = "reuse",
+      original_win = target_win,
+      reused_buf = current_buf,
+      in_new_tab = true,
+    }
+  end
+
+  if is_new_file then
+    if is_empty_buffer then
+      return { decision = "reuse", original_win = target_win, reused_buf = current_buf, in_new_tab = false }
+    else
+      return { decision = "split", original_win = target_win, reused_buf = nil, in_new_tab = false }
+    end
+  end
+
+  if current_buf_path == old_file_path then
+    return { decision = "reuse", original_win = target_win, reused_buf = current_buf, in_new_tab = false }
+  elseif is_empty_buffer then
+    return { decision = "reuse", original_win = target_win, reused_buf = current_buf, in_new_tab = false }
+  else
+    return { decision = "split", original_win = target_win, reused_buf = nil, in_new_tab = false }
+  end
+end
+
+---Ensure the original window displays the proper buffer for the diff
+---@param original_win NvimWin
+---@param old_file_path string
+---@param is_new_file boolean
+---@param existing_buffer NvimBuf|nil
+---@return NvimBuf original_buf
+local function load_original_buffer(original_win, old_file_path, is_new_file, existing_buffer)
+  if is_new_file then
+    local empty_buffer = vim.api.nvim_create_buf(false, true)
+    if not empty_buffer or empty_buffer == 0 then
+      local error_msg = "Failed to create empty buffer for new file diff"
+      logger.error("diff", error_msg)
+      error({ code = -32000, message = "Buffer creation failed", data = error_msg })
+    end
+
+    local ok, err = pcall(function()
+      vim.api.nvim_buf_set_name(empty_buffer, old_file_path .. " (NEW FILE)")
+      vim.api.nvim_buf_set_lines(empty_buffer, 0, -1, false, {})
+      vim.api.nvim_buf_set_option(empty_buffer, "buftype", "nofile")
+      vim.api.nvim_buf_set_option(empty_buffer, "modifiable", false)
+      vim.api.nvim_buf_set_option(empty_buffer, "readonly", true)
+    end)
+
+    if not ok then
+      pcall(vim.api.nvim_buf_delete, empty_buffer, { force = true })
+      local error_msg = "Failed to configure empty buffer: " .. tostring(err)
+      logger.error("diff", error_msg)
+      error({ code = -32000, message = "Buffer configuration failed", data = error_msg })
+    end
+
+    vim.api.nvim_win_set_buf(original_win, empty_buffer)
+    return empty_buffer
+  end
+
+  if existing_buffer and vim.api.nvim_buf_is_valid(existing_buffer) then
+    vim.api.nvim_win_set_buf(original_win, existing_buffer)
+    return existing_buffer
+  end
+
+  vim.api.nvim_set_current_win(original_win)
+  vim.cmd("edit " .. vim.fn.fnameescape(old_file_path))
+  return vim.api.nvim_win_get_buf(original_win)
+end
+
+---Create the proposed side split, set diff, filetype, context, and terminal focus/width
+---@param original_win NvimWin
+---@param original_buf NvimBuf
+---@param new_buf NvimBuf
+---@param old_file_path string
+---@param tab_name string
+---@param terminal_win_in_new_tab NvimWin|nil
+---@param target_win_for_meta NvimWin
+---@return NvimWin new_win
+local function setup_new_buffer(
+  original_win,
+  original_buf,
+  new_buf,
+  old_file_path,
+  tab_name,
+  terminal_win_in_new_tab,
+  target_win_for_meta
+)
+  vim.api.nvim_set_current_win(original_win)
+  vim.cmd("diffthis")
+
+  create_split()
+  local new_win = vim.api.nvim_get_current_win()
+  vim.api.nvim_win_set_buf(new_win, new_buf)
+
+  local original_ft = detect_filetype(old_file_path, original_buf)
+  if original_ft and original_ft ~= "" then
+    vim.api.nvim_set_option_value("filetype", original_ft, { buf = new_buf })
+  end
+  vim.cmd("diffthis")
+
+  vim.cmd("wincmd =")
+
+  vim.api.nvim_set_current_win(new_win)
+
+  vim.b[new_buf].claudecode_diff_tab_name = tab_name
+  vim.b[new_buf].claudecode_diff_new_win = new_win
+  vim.b[new_buf].claudecode_diff_target_win = target_win_for_meta
+
+  if config and config.diff_opts and config.diff_opts.keep_terminal_focus then
+    vim.schedule(function()
+      if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then
+        vim.api.nvim_set_current_win(terminal_win_in_new_tab)
+        vim.cmd("startinsert")
+        return
+      end
+
+      local terminal_win = find_claudecode_terminal_window()
+      if terminal_win then
+        vim.api.nvim_set_current_win(terminal_win)
+        vim.cmd("startinsert")
+      end
+    end)
+  end
+
+  if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then
+    local terminal_config = config.terminal or {}
+    local split_width = terminal_config.split_width_percentage or 0.30
+    local total_width = vim.o.columns
+    local terminal_width = math.floor(total_width * split_width)
+    vim.api.nvim_win_set_width(terminal_win_in_new_tab, terminal_width)
+  else
+    local terminal_win = find_claudecode_terminal_window()
+    if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
+      local current_tab = vim.api.nvim_get_current_tabpage()
+      local term_tab = nil
+      pcall(function()
+        term_tab = vim.api.nvim_win_get_tabpage(terminal_win)
+      end)
+      if term_tab == current_tab then
+        local terminal_config = config.terminal or {}
+        local split_width = terminal_config.split_width_percentage or 0.30
+        local total_width = vim.o.columns
+        local terminal_width = math.floor(total_width * split_width)
+        pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
+      end
+    end
+  end
+
+  return new_win
+end
+
 ---Open diff using native Neovim functionality
 ---@param old_file_path string Path to the original file
 ---@param new_file_path string Path to the new file (used for naming)
@@ -293,13 +653,13 @@ function M._open_native_diff(old_file_path, new_file_path, new_file_contents, ta
     local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
 
     if buftype == "terminal" or buftype == "nofile" then
-      vim.cmd("vsplit")
+      create_split()
     end
   end
 
   vim.cmd("edit " .. vim.fn.fnameescape(old_file_path))
   vim.cmd("diffthis")
-  vim.cmd("vsplit")
+  create_split()
   vim.cmd("edit " .. vim.fn.fnameescape(tmp_file))
   vim.api.nvim_buf_set_name(0, new_file_path .. " (New)")
 
@@ -356,7 +716,7 @@ function M._resolve_diff_as_saved(tab_name, buffer_id)
     return
   end
 
-  logger.debug("diff", "Resolving diff as saved for", tab_name, "from buffer", buffer_id)
+  logger.debug("diff", "Accepting diff for", tab_name)
 
   -- Get content from buffer
   local content_lines = vim.api.nvim_buf_get_lines(buffer_id, 0, -1, false)
@@ -366,14 +726,7 @@ function M._resolve_diff_as_saved(tab_name, buffer_id)
     final_content = final_content .. "\n"
   end
 
-  -- Close diff windows (unified behavior)
-  if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then
-    vim.api.nvim_win_close(diff_data.new_window, true)
-  end
-  if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then
-    vim.api.nvim_set_current_win(diff_data.target_window)
-    vim.cmd("diffoff")
-  end
+  -- Do not modify windows/tabs here; wait for explicit close_tab tool call to clean up UI
 
   -- Create MCP-compliant response
   local result = {
@@ -388,29 +741,19 @@ function M._resolve_diff_as_saved(tab_name, buffer_id)
 
   -- Resume the coroutine with the result (for deferred response system)
   if diff_data.resolution_callback then
-    logger.debug("diff", "Resuming coroutine for saved diff", tab_name)
     diff_data.resolution_callback(result)
   else
     logger.debug("diff", "No resolution callback found for saved diff", tab_name)
   end
 
-  -- Reload the original file buffer after a delay to ensure Claude CLI has written the file
-  vim.defer_fn(function()
-    local current_diff_data = active_diffs[tab_name]
-    local original_cursor_pos = current_diff_data and current_diff_data.original_cursor_pos
-    M.reload_file_buffers_manual(diff_data.old_file_path, original_cursor_pos)
-  end, 200)
-
-  -- NOTE: Diff state cleanup is handled by close_tab tool or explicit cleanup calls
-  logger.debug("diff", "Diff saved, awaiting close_tab command for cleanup")
+  -- NOTE: Diff state cleanup is handled exclusively by the close_tab tool call
+  logger.debug("diff", "Diff saved; awaiting close_tab for cleanup")
 end
 
----Reload file buffers after external changes (called when diff is closed)
+---Reload file buffers after external changes
 ---@param file_path string Path to the file that was externally modified
 ---@param original_cursor_pos table? Original cursor position to restore {row, col}
 local function reload_file_buffers(file_path, original_cursor_pos)
-  logger.debug("diff", "Reloading buffers for file:", file_path, original_cursor_pos and "(restoring cursor)" or "")
-
   local reloaded_count = 0
   -- Find and reload any open buffers for this file
   for _, buf in ipairs(vim.api.nvim_list_bufs()) do
@@ -421,8 +764,6 @@ local function reload_file_buffers(file_path, original_cursor_pos)
       if buf_name == file_path then
         -- Check if buffer is modified - only reload unmodified buffers for safety
         local modified = vim.api.nvim_buf_get_option(buf, "modified")
-        logger.debug("diff", "Found matching buffer", buf, "modified:", modified)
-
         if not modified then
           -- Try to find a window displaying this buffer for proper context
           local win_id = nil
@@ -452,8 +793,6 @@ local function reload_file_buffers(file_path, original_cursor_pos)
       end
     end
   end
-
-  logger.debug("diff", "Completed buffer reload - reloaded", reloaded_count, "buffers for file:", file_path)
 end
 
 ---Resolve diff as rejected (user closed/rejected)
@@ -475,23 +814,17 @@ function M._resolve_diff_as_rejected(tab_name)
   diff_data.status = "rejected"
   diff_data.result_content = result
 
-  -- Clean up diff state and resources BEFORE resolving to prevent any interference
-  M._cleanup_diff_state(tab_name, "diff rejected")
-
-  -- Use vim.schedule to ensure the resolution callback happens after all cleanup
-  vim.schedule(function()
-    -- Resume the coroutine with the result (for deferred response system)
-    if diff_data.resolution_callback then
-      logger.debug("diff", "Resuming coroutine for rejected diff", tab_name)
-      diff_data.resolution_callback(result)
-    end
-  end)
+  -- Do not perform UI cleanup here; wait for explicit close_tab tool call.
+  -- Resume the coroutine with the result (for deferred response system)
+  if diff_data.resolution_callback then
+    diff_data.resolution_callback(result)
+  end
 end
 
 ---Register autocmds for a specific diff
 ---@param tab_name string The diff identifier
 ---@param new_buffer number New file buffer ID
----@return table autocmd_ids List of autocmd IDs
+---@return table List of autocmd IDs
 local function register_diff_autocmds(tab_name, new_buffer)
   local autocmd_ids = {}
 
@@ -500,7 +833,6 @@ local function register_diff_autocmds(tab_name, new_buffer)
     group = get_autocmd_group(),
     buffer = new_buffer,
     callback = function()
-      logger.debug("diff", "BufWriteCmd (:w) triggered - accepting diff changes for", tab_name)
       M._resolve_diff_as_saved(tab_name, new_buffer)
       -- Prevent actual file write since we're handling it through MCP
       return true
@@ -514,7 +846,6 @@ local function register_diff_autocmds(tab_name, new_buffer)
     group = get_autocmd_group(),
     buffer = new_buffer,
     callback = function()
-      logger.debug("diff", "BufDelete triggered for new buffer", new_buffer, "tab:", tab_name)
       M._resolve_diff_as_rejected(tab_name)
     end,
   })
@@ -524,7 +855,6 @@ local function register_diff_autocmds(tab_name, new_buffer)
     group = get_autocmd_group(),
     buffer = new_buffer,
     callback = function()
-      logger.debug("diff", "BufUnload triggered for new buffer", new_buffer, "tab:", tab_name)
       M._resolve_diff_as_rejected(tab_name)
     end,
   })
@@ -534,7 +864,6 @@ local function register_diff_autocmds(tab_name, new_buffer)
     group = get_autocmd_group(),
     buffer = new_buffer,
     callback = function()
-      logger.debug("diff", "BufWipeout triggered for new buffer", new_buffer, "tab:", tab_name)
       M._resolve_diff_as_rejected(tab_name)
     end,
   })
@@ -546,109 +875,75 @@ local function register_diff_autocmds(tab_name, new_buffer)
 end
 
 ---Create diff view from a specific window
----@param target_window number The window to use as base for the diff
+---@param target_window NvimWin|nil The window to use as base for the diff
 ---@param old_file_path string Path to the original file
----@param new_buffer number New file buffer ID
+---@param new_buffer NvimBuf New file buffer ID
 ---@param tab_name string The diff identifier
 ---@param is_new_file boolean Whether this is a new file (doesn't exist yet)
----@return table layout Info about the created diff layout
-function M._create_diff_view_from_window(target_window, old_file_path, new_buffer, tab_name, is_new_file)
+---@param terminal_win_in_new_tab NvimWin|nil Terminal window in new tab if created
+---@param existing_buffer NvimBuf|nil Existing buffer for the file if already loaded
+---@return DiffLayoutInfo layout Info about the created diff layout
+function M._create_diff_view_from_window(
+  target_window,
+  old_file_path,
+  new_buffer,
+  tab_name,
+  is_new_file,
+  terminal_win_in_new_tab,
+  existing_buffer
+)
   -- If no target window provided, create a new window in suitable location
   if not target_window then
-    -- Try to create a new window in the main area
-    vim.cmd("wincmd t") -- Go to top-left
-    vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right)
+    -- If we have a terminal window in the new tab, we're already positioned correctly
+    if terminal_win_in_new_tab then
+      -- We're already in the main area after display_terminal_in_new_tab
+      target_window = vim.api.nvim_get_current_win()
+    else
+      -- Try to create a new window in the main area
+      vim.cmd("wincmd t") -- Go to top-left
+      vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right)
 
-    local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win())
-    local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
-    local filetype = vim.api.nvim_buf_get_option(buf, "filetype")
+      local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win())
+      local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
+      local filetype = vim.api.nvim_buf_get_option(buf, "filetype")
 
-    if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" then
-      vim.cmd("vsplit")
-    end
+      if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" then
+        create_split()
+      end
 
-    target_window = vim.api.nvim_get_current_win()
+      target_window = vim.api.nvim_get_current_win()
+    end
   else
     vim.api.nvim_set_current_win(target_window)
   end
 
-  local original_buffer
-  if is_new_file then
-    local empty_buffer = vim.api.nvim_create_buf(false, true)
-    if not empty_buffer or empty_buffer == 0 then
-      local error_msg = "Failed to create empty buffer for new file diff"
-      logger.error("diff", error_msg)
-      error({
-        code = -32000,
-        message = "Buffer creation failed",
-        data = error_msg,
-      })
-    end
-
-    -- Set buffer properties with error handling
-    local success, err = pcall(function()
-      vim.api.nvim_buf_set_name(empty_buffer, old_file_path .. " (NEW FILE)")
-      vim.api.nvim_buf_set_lines(empty_buffer, 0, -1, false, {})
-      vim.api.nvim_buf_set_option(empty_buffer, "buftype", "nofile")
-      vim.api.nvim_buf_set_option(empty_buffer, "modifiable", false)
-      vim.api.nvim_buf_set_option(empty_buffer, "readonly", true)
-    end)
+  -- Decide window placement for the original file
+  local choice = choose_original_window(target_window, old_file_path, is_new_file, terminal_win_in_new_tab)
 
-    if not success then
-      pcall(vim.api.nvim_buf_delete, empty_buffer, { force = true })
-      local error_msg = "Failed to configure empty buffer: " .. tostring(err)
-      logger.error("diff", error_msg)
-      error({
-        code = -32000,
-        message = "Buffer configuration failed",
-        data = error_msg,
-      })
-    end
-
-    vim.api.nvim_win_set_buf(target_window, empty_buffer)
-    original_buffer = empty_buffer
+  local original_window
+  if choice.decision == "split" then
+    vim.api.nvim_set_current_win(target_window)
+    create_split()
+    original_window = vim.api.nvim_get_current_win()
   else
-    vim.cmd("edit " .. vim.fn.fnameescape(old_file_path))
-    original_buffer = vim.api.nvim_win_get_buf(target_window)
-  end
-
-  vim.cmd("diffthis")
-
-  vim.cmd("vsplit")
-  local new_win = vim.api.nvim_get_current_win()
-  vim.api.nvim_win_set_buf(new_win, new_buffer)
-
-  -- Ensure new buffer inherits filetype from original for syntax highlighting (#20)
-  local original_ft = detect_filetype(old_file_path, original_buffer)
-  if original_ft and original_ft ~= "" then
-    vim.api.nvim_set_option_value("filetype", original_ft, { buf = new_buffer })
+    original_window = choice.original_win
   end
-  vim.cmd("diffthis")
-
-  vim.cmd("wincmd =")
-
-  -- Always focus the diff window first for proper visual flow and window arrangement
-  vim.api.nvim_set_current_win(new_win)
 
-  -- Store diff context in buffer variables for user commands
-  vim.b[new_buffer].claudecode_diff_tab_name = tab_name
-  vim.b[new_buffer].claudecode_diff_new_win = new_win
-  vim.b[new_buffer].claudecode_diff_target_win = target_window
+  local original_buffer = load_original_buffer(original_window, old_file_path, is_new_file, existing_buffer)
 
-  -- After all diff setup is complete, optionally return focus to terminal
-  if config and config.diff_opts and config.diff_opts.keep_terminal_focus then
-    vim.schedule(function()
-      local terminal_win = find_claudecode_terminal_window()
-      if terminal_win then
-        vim.api.nvim_set_current_win(terminal_win)
-      end
-    end)
-  end
+  local new_win = setup_new_buffer(
+    original_window,
+    original_buffer,
+    new_buffer,
+    old_file_path,
+    tab_name,
+    terminal_win_in_new_tab,
+    target_window
+  )
 
-  -- Return window information for later storage
   return {
     new_window = new_win,
-    target_window = target_window,
+    target_window = original_window,
     original_buffer = original_buffer,
   }
 end
@@ -667,32 +962,87 @@ function M._cleanup_diff_state(tab_name, reason)
     pcall(vim.api.nvim_del_autocmd, autocmd_id)
   end
 
-  -- Clean up the new buffer only (not the old buffer which is the user's file)
-  if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then
-    pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true })
+  -- Clean up new tab if we created one (do this first to avoid double cleanup)
+  if diff_data.created_new_tab then
+    -- Always switch to the original tab first (if valid)
+    if diff_data.original_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.original_tab_number) then
+      pcall(vim.api.nvim_set_current_tabpage, diff_data.original_tab_number)
+    end
+
+    -- Prefer closing the specific new tab we created, if we tracked its handle/number
+    if diff_data.new_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.new_tab_number) then
+      -- Prefer closing by switching to the new tab then executing :tabclose
+      pcall(vim.api.nvim_set_current_tabpage, diff_data.new_tab_number)
+      pcall(vim.cmd, "tabclose")
+      -- Restore original tab focus if still valid
+      if diff_data.original_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.original_tab_number) then
+        pcall(vim.api.nvim_set_current_tabpage, diff_data.original_tab_number)
+      end
+    else
+      -- Fallback: close the previously current tab if it's still around and not the original
+      local current_tab = vim.api.nvim_get_current_tabpage()
+      if vim.api.nvim_tabpage_is_valid(current_tab) and current_tab ~= diff_data.original_tab_number then
+        pcall(vim.cmd, "tabclose " .. vim.api.nvim_tabpage_get_number(current_tab))
+      end
+    end
+
+    -- Optionally ensure the Claude terminal remains visible in the original tab
+    local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
+    if terminal_ok and diff_data.had_terminal_in_original then
+      pcall(terminal_module.ensure_visible)
+      -- And restore its configured width if it is visible
+      local terminal_win = find_claudecode_terminal_window()
+      if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
+        local terminal_config = config.terminal or {}
+        local split_width = terminal_config.split_width_percentage or 0.30
+        local total_width = vim.o.columns
+        local terminal_width = math.floor(total_width * split_width)
+        pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
+      end
+    end
+  else
+    -- Close new diff window if still open (only if not in a new tab)
+    if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then
+      pcall(vim.api.nvim_win_close, diff_data.new_window, true)
+    end
+
+    -- Turn off diff mode in target window if it still exists
+    if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then
+      vim.api.nvim_win_call(diff_data.target_window, function()
+        vim.cmd("diffoff")
+      end)
+    end
+
+    -- After closing the diff in the same tab, restore terminal width if visible
+    local terminal_win = find_claudecode_terminal_window()
+    if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
+      local terminal_config = config.terminal or {}
+      local split_width = terminal_config.split_width_percentage or 0.30
+      local total_width = vim.o.columns
+      local terminal_width = math.floor(total_width * split_width)
+      pcall(vim.api.nvim_win_set_width, terminal_win, terminal_width)
+    end
   end
 
-  -- Close new diff window if still open
-  if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then
-    pcall(vim.api.nvim_win_close, diff_data.new_window, true)
+  -- ALWAYS clean up buffers regardless of tab mode (fixes buffer leak)
+  -- Clean up the new buffer (proposed changes)
+  if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then
+    pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true })
   end
 
-  -- Turn off diff mode in target window if it still exists
-  if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then
-    vim.api.nvim_win_call(diff_data.target_window, function()
-      vim.cmd("diffoff")
-    end)
+  -- Clean up the original buffer if it was created for a new file
+  if diff_data.is_new_file and diff_data.original_buffer and vim.api.nvim_buf_is_valid(diff_data.original_buffer) then
+    pcall(vim.api.nvim_buf_delete, diff_data.original_buffer, { force = true })
   end
 
   -- Remove from active diffs
   active_diffs[tab_name] = nil
 
-  logger.debug("diff", "Cleaned up diff state for", tab_name, "due to:", reason)
+  logger.debug("diff", "Cleaned up diff for", tab_name)
 end
 
 ---Clean up all active diffs
 ---@param reason string Reason for cleanup
--- NOTE: This will become a public closeAllDiffTabs tool in the future
 function M._cleanup_all_active_diffs(reason)
   for tab_name, _ in pairs(active_diffs) do
     M._cleanup_diff_state(tab_name, reason)
@@ -708,11 +1058,9 @@ function M._setup_blocking_diff(params, resolution_callback)
 
   -- Wrap the setup in error handling to ensure cleanup on failure
   local setup_success, setup_error = pcall(function()
-    -- Step 1: Check if the file exists (allow new files)
     local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1
     local is_new_file = not old_file_exists
 
-    -- Step 1.5: Check if the file buffer has unsaved changes
     if old_file_exists then
       local is_dirty = is_buffer_dirty(params.old_file_path)
       if is_dirty then
@@ -724,39 +1072,58 @@ function M._setup_blocking_diff(params, resolution_callback)
       end
     end
 
-    -- Step 2: Find if the file is already open in a buffer (only for existing files)
+    local original_tab_number = vim.api.nvim_get_current_tabpage()
+    local created_new_tab = false
+    local terminal_win_in_new_tab = nil
     local existing_buffer = nil
     local target_window = nil
+    -- Track new tab handle and original terminal visibility for robust cleanup
+    local new_tab_handle = nil
+    local had_terminal_in_original = false
+
+    if config and config.diff_opts and config.diff_opts.open_in_new_tab then
+      original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle =
+        display_terminal_in_new_tab()
+      created_new_tab = true
+
+      -- In new tab, no existing windows to use, so target_window will be created
+      target_window = nil
+      existing_buffer = nil
+      -- Track extra metadata about terminal/tab for robust cleanup
+      M._last_had_terminal_in_original = had_terminal_in_original -- for debugging
+      M._last_new_tab_number = new_tab_handle -- for debugging
+    end
 
-    if old_file_exists then
-      -- Look for existing buffer with this file
-      for _, buf in ipairs(vim.api.nvim_list_bufs()) do
-        if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then
-          local buf_name = vim.api.nvim_buf_get_name(buf)
-          if buf_name == params.old_file_path then
-            existing_buffer = buf
-            break
+    -- Only look for existing windows if we're NOT in a new tab
+    if not created_new_tab then
+      if old_file_exists then
+        for _, buf in ipairs(vim.api.nvim_list_bufs()) do
+          if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then
+            local buf_name = vim.api.nvim_buf_get_name(buf)
+            if buf_name == params.old_file_path then
+              existing_buffer = buf
+              break
+            end
           end
         end
-      end
 
-      -- Find window containing this buffer (if any)
-      if existing_buffer then
-        for _, win in ipairs(vim.api.nvim_list_wins()) do
-          if vim.api.nvim_win_get_buf(win) == existing_buffer then
-            target_window = win
-            break
+        if existing_buffer then
+          for _, win in ipairs(vim.api.nvim_list_wins()) do
+            if vim.api.nvim_win_get_buf(win) == existing_buffer then
+              target_window = win
+              break
+            end
           end
         end
       end
-    end
 
-    -- If no existing buffer/window, find a suitable main editor window
-    if not target_window then
-      target_window = find_main_editor_window()
+      if not target_window then
+        target_window = find_main_editor_window()
+      end
     end
-    -- If we still can't find a suitable window, error out
-    if not target_window then
+    -- If created_new_tab is true, target_window stays nil and will be created in the new tab
+    -- If we still can't find a suitable window AND we're not in a new tab, error out
+    if not target_window and not created_new_tab then
       error({
         code = -32000,
         message = "No suitable editor window found",
@@ -764,7 +1131,6 @@ function M._setup_blocking_diff(params, resolution_callback)
       })
     end
 
-    -- Step 3: Create scratch buffer for new content
     local new_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch
     if new_buffer == 0 then
       error({
@@ -786,16 +1152,18 @@ function M._setup_blocking_diff(params, resolution_callback)
     vim.api.nvim_buf_set_option(new_buffer, "buftype", "acwrite") -- Allows saving but stays as scratch-like
     vim.api.nvim_buf_set_option(new_buffer, "modifiable", true)
 
-    -- Step 4: Set up diff view using the target window
-    local diff_info =
-      M._create_diff_view_from_window(target_window, params.old_file_path, new_buffer, tab_name, is_new_file)
+    local diff_info = M._create_diff_view_from_window(
+      target_window,
+      params.old_file_path,
+      new_buffer,
+      tab_name,
+      is_new_file,
+      terminal_win_in_new_tab,
+      existing_buffer
+    )
 
-    -- Step 5: Register autocmds for user interaction monitoring
     local autocmd_ids = register_diff_autocmds(tab_name, new_buffer)
 
-    -- Step 6: Store diff state
-
-    -- Save the original cursor position before storing diff state
     local original_cursor_pos = nil
     if diff_info.target_window and vim.api.nvim_win_is_valid(diff_info.target_window) then
       original_cursor_pos = vim.api.nvim_win_get_cursor(diff_info.target_window)
@@ -810,6 +1178,11 @@ function M._setup_blocking_diff(params, resolution_callback)
       target_window = diff_info.target_window,
       original_buffer = diff_info.original_buffer,
       original_cursor_pos = original_cursor_pos,
+      original_tab_number = original_tab_number,
+      created_new_tab = created_new_tab,
+      new_tab_number = new_tab_handle,
+      had_terminal_in_original = had_terminal_in_original,
+      terminal_win_in_new_tab = terminal_win_in_new_tab,
       autocmd_ids = autocmd_ids,
       created_at = vim.fn.localtime(),
       status = "pending",
@@ -931,8 +1304,6 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t
 
   -- Yield and wait indefinitely for user interaction - the resolve functions will resume us
   local user_action_result = coroutine.yield()
-  logger.debug("diff", "User action completed for", tab_name)
-
   -- Return the result directly - this will be sent by the deferred response system
   return user_action_result
 end
@@ -967,21 +1338,33 @@ function M.close_diff_by_tab_name(tab_name)
     return true
   end
 
-  -- If still pending, treat as rejection
+  -- If the diff was already rejected, just clean up now
+  if diff_data.status == "rejected" then
+    M._cleanup_diff_state(tab_name, "diff tab closed after reject")
+    return true
+  end
+
+  -- If still pending, treat as rejection and clean up
   if diff_data.status == "pending" then
+    -- Mark as rejected and then clean up UI state now that we received explicit close request
     M._resolve_diff_as_rejected(tab_name)
+    M._cleanup_diff_state(tab_name, "diff tab closed after reject")
     return true
   end
 
   return false
 end
 
---Test helper function (only for testing)
+---Test helper function (only for testing)
+---@return table active_diffs The active diffs table
 function M._get_active_diffs()
   return active_diffs
 end
 
---Manual buffer reload function for testing/debugging
+---Manual buffer reload function for testing/debugging
+---@param file_path string Path to the file to reload
+---@param original_cursor_pos table? Original cursor position {row, col}
+---@return nil
 function M.reload_file_buffers_manual(file_path, original_cursor_pos)
   return reload_file_buffers(file_path, original_cursor_pos)
 end
@@ -1005,24 +1388,29 @@ end
 function M.deny_current_diff()
   local current_buffer = vim.api.nvim_get_current_buf()
   local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
-  local new_win = vim.b[current_buffer].claudecode_diff_new_win
-  local target_window = vim.b[current_buffer].claudecode_diff_target_win
 
   if not tab_name then
     vim.notify("No active diff found in current buffer", vim.log.levels.WARN)
     return
   end
 
-  -- Close windows and clean up (same logic as the original keymap)
-  if new_win and vim.api.nvim_win_is_valid(new_win) then
-    vim.api.nvim_win_close(new_win, true)
-  end
-  if target_window and vim.api.nvim_win_is_valid(target_window) then
-    vim.api.nvim_set_current_win(target_window)
-    vim.cmd("diffoff")
-  end
-
+  -- Do not close windows/tabs here; just mark as rejected.
   M._resolve_diff_as_rejected(tab_name)
 end
 
 return M
+---@alias NvimWin integer
+---@alias NvimBuf integer
+
+---@alias DiffWindowDecision "reuse"|"split"
+
+---@class DiffLayoutInfo
+---@field new_window NvimWin
+---@field target_window NvimWin
+---@field original_buffer NvimBuf
+
+---@class DiffWindowChoice
+---@field decision DiffWindowDecision
+---@field original_win NvimWin
+---@field reused_buf NvimBuf|nil
+---@field in_new_tab boolean
diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua
index 2dad779..1224cb2 100644
--- a/lua/claudecode/types.lua
+++ b/lua/claudecode/types.lua
@@ -1,3 +1,4 @@
+---@meta
 ---@brief [[
 --- Centralized type definitions for ClaudeCode.nvim public API.
 --- This module contains all user-facing types and configuration structures.
@@ -14,11 +15,10 @@
 
 -- Diff behavior configuration
 ---@class ClaudeCodeDiffOptions
----@field auto_close_on_accept boolean
----@field show_diff_stats boolean
----@field vertical_split boolean
----@field open_in_current_tab boolean
----@field keep_terminal_focus boolean
+---@field layout ClaudeCodeDiffLayout
+---@field open_in_new_tab boolean Open diff in a new tab (false = use current tab)
+---@field keep_terminal_focus boolean Keep focus in terminal after opening diff
+---@field hide_terminal_in_new_tab boolean Hide Claude terminal in newly created diff tab
 
 -- Model selection option
 ---@class ClaudeCodeModelOption
@@ -28,6 +28,9 @@
 -- Log level type alias
 ---@alias ClaudeCodeLogLevel "trace"|"debug"|"info"|"warn"|"error"
 
+-- Diff layout type alias
+---@alias ClaudeCodeDiffLayout "vertical"|"horizontal"
+
 -- Terminal split side positioning
 ---@alias ClaudeCodeSplitSide "left"|"right"
 
diff --git a/tests/integration/command_args_spec.lua b/tests/integration/command_args_spec.lua
index 05787c0..7e6539d 100644
--- a/tests/integration/command_args_spec.lua
+++ b/tests/integration/command_args_spec.lua
@@ -153,10 +153,9 @@ describe("ClaudeCode command arguments integration", function()
               track_selection = true,
               visual_demotion_delay_ms = 50,
               diff_opts = {
-                auto_close_on_accept = true,
-                show_diff_stats = true,
-                vertical_split = true,
-                open_in_current_tab = false,
+                layout = "vertical",
+                open_in_new_tab = true, -- Note: inverted from open_in_current_tab = false
+                keep_terminal_focus = false,
               },
             }, opts or {})
           end,
diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua
index d5eca8e..77a3302 100644
--- a/tests/mocks/vim.lua
+++ b/tests/mocks/vim.lua
@@ -61,12 +61,17 @@ end
 
 local vim = {
   _buffers = {},
-  _windows = { [1000] = { buf = 1 } }, -- Initialize with a default window
+  _windows = { [1000] = { buf = 1, width = 80 } }, -- winid -> { buf, width, cursor, config }
+  _win_tab = { [1000] = 1 }, -- winid -> tabpage
+  _tab_windows = { [1] = { 1000 } }, -- tabpage -> { winids }
+  _next_winid = 1001,
   _commands = {},
   _autocmds = {},
   _vars = {},
   _options = {},
   _current_window = 1000,
+  _tabs = { [1] = true },
+  _current_tabpage = 1,
 
   api = {
     nvim_create_user_command = function(name, callback, opts)
@@ -172,7 +177,7 @@ local vim = {
       -- buffer or window. Here, it's stored in a general options table if not
       -- a buffer-local option, or in the buffer's options table if `opts.buf` is provided.
       -- A more complex mock might be needed for intricate scope-related tests.
-      if opts and opts.scope == "local" and opts.buf then
+      if opts and opts.buf then
         if vim._buffers[opts.buf] then
           if not vim._buffers[opts.buf].options then
             vim._buffers[opts.buf].options = {}
@@ -273,7 +278,7 @@ local vim = {
     end,
 
     nvim_get_current_win = function()
-      return 1000 -- Mock window ID
+      return vim._current_window
     end,
 
     nvim_set_current_win = function(winid)
@@ -283,14 +288,17 @@ local vim = {
     end,
 
     nvim_list_wins = function()
-      -- Return a list of window IDs
+      -- Return a list of window IDs for the current tab
       local wins = {}
-      for winid, _ in pairs(vim._windows) do
-        table.insert(wins, winid)
+      local list = vim._tab_windows[vim._current_tabpage] or {}
+      for _, winid in ipairs(list) do
+        if vim._windows[winid] then
+          table.insert(wins, winid)
+        end
       end
       if #wins == 0 then
         -- Always have at least one window
-        table.insert(wins, 1000)
+        table.insert(wins, vim._current_window)
       end
       return wins
     end,
@@ -299,7 +307,24 @@ local vim = {
       if not vim._windows[winid] then
         vim._windows[winid] = {}
       end
+      local old_buf = vim._windows[winid].buf
       vim._windows[winid].buf = bufnr
+      -- If old buffer is no longer displayed in any window, and has bufhidden=wipe, delete it
+      if old_buf and vim._buffers[old_buf] then
+        local still_visible = false
+        for _, w in pairs(vim._windows) do
+          if w.buf == old_buf then
+            still_visible = true
+            break
+          end
+        end
+        if not still_visible then
+          local opts = vim._buffers[old_buf].options or {}
+          if opts.bufhidden == "wipe" then
+            vim._buffers[old_buf] = nil
+          end
+        end
+      end
     end,
 
     nvim_win_get_buf = function(winid)
@@ -314,7 +339,36 @@ local vim = {
     end,
 
     nvim_win_close = function(winid, force)
+      local old_buf = vim._windows[winid] and vim._windows[winid].buf
       vim._windows[winid] = nil
+      -- remove from tab mapping
+      local tab = vim._win_tab[winid]
+      if tab and vim._tab_windows[tab] then
+        local new_list = {}
+        for _, w in ipairs(vim._tab_windows[tab]) do
+          if w ~= winid then
+            table.insert(new_list, w)
+          end
+        end
+        vim._tab_windows[tab] = new_list
+      end
+      vim._win_tab[winid] = nil
+      -- Apply bufhidden=wipe if now hidden
+      if old_buf and vim._buffers[old_buf] then
+        local still_visible = false
+        for _, w in pairs(vim._windows) do
+          if w.buf == old_buf then
+            still_visible = true
+            break
+          end
+        end
+        if not still_visible then
+          local opts = vim._buffers[old_buf].options or {}
+          if opts.bufhidden == "wipe" then
+            vim._buffers[old_buf] = nil
+          end
+        end
+      end
     end,
 
     nvim_win_call = function(winid, callback)
@@ -333,13 +387,49 @@ local vim = {
       return {}
     end,
 
+    nvim_win_set_width = function(winid, width)
+      if vim._windows[winid] then
+        vim._windows[winid].width = width
+      end
+    end,
+
+    nvim_win_get_width = function(winid)
+      return (vim._windows[winid] and vim._windows[winid].width) or 80
+    end,
+
     nvim_get_current_tabpage = function()
-      return 1
+      return vim._current_tabpage
+    end,
+
+    nvim_set_current_tabpage = function(tab)
+      if vim._tabs[tab] then
+        vim._current_tabpage = tab
+      end
+    end,
+
+    nvim_tabpage_is_valid = function(tab)
+      return vim._tabs[tab] == true
+    end,
+
+    nvim_tabpage_get_number = function(tab)
+      return tab
     end,
 
     nvim_tabpage_set_var = function(tabpage, name, value)
       -- Mock tabpage variable setting
     end,
+
+    nvim_win_get_tabpage = function(winid)
+      return vim._win_tab[winid] or vim._current_tabpage
+    end,
+
+    nvim_buf_line_count = function(bufnr)
+      local b = vim._buffers[bufnr]
+      if not b or not b.lines then
+        return 0
+      end
+      return #b.lines
+    end,
   },
 
   fn = {
@@ -446,6 +536,133 @@ local vim = {
   cmd = function(command)
     -- Store the last command for test assertions.
     vim._last_command = command
+    -- Implement minimal behavior for essential commands
+    if command == "tabnew" then
+      -- Create new tab with a new window and an unnamed buffer
+      local new_tab = 1
+      for k, _ in pairs(vim._tabs) do
+        if k >= new_tab then
+          new_tab = k + 1
+        end
+      end
+      vim._tabs[new_tab] = true
+      vim._current_tabpage = new_tab
+
+      -- Create a new unnamed buffer
+      local bufnr = vim.api.nvim_create_buf(false, true)
+      vim._buffers[bufnr].name = ""
+      vim._buffers[bufnr].options = vim._buffers[bufnr].options or {}
+      vim._buffers[bufnr].options.modified = false
+      vim._buffers[bufnr].lines = { "" }
+
+      -- Create a new window for this tab
+      local winid = vim._next_winid
+      vim._next_winid = vim._next_winid + 1
+      vim._windows[winid] = { buf = bufnr, width = 80 }
+      vim._win_tab[winid] = new_tab
+      vim._tab_windows[new_tab] = { winid }
+      vim._current_window = winid
+    elseif command:match("vsplit") then
+      -- Split current window vertically; new window shows same buffer
+      local cur = vim._current_window
+      local curtab = vim._current_tabpage
+      local bufnr = vim._windows[cur] and vim._windows[cur].buf or 1
+      local winid = vim._next_winid
+      vim._next_winid = vim._next_winid + 1
+      vim._windows[winid] = { buf = bufnr, width = 80 }
+      vim._win_tab[winid] = curtab
+      local list = vim._tab_windows[curtab] or {}
+      table.insert(list, winid)
+      vim._tab_windows[curtab] = list
+      vim._current_window = winid
+    elseif command:match("[^%w]split$") or command == "split" then
+      -- Horizontal split: model similarly by creating a new window entry
+      local cur = vim._current_window
+      local curtab = vim._current_tabpage
+      local bufnr = vim._windows[cur] and vim._windows[cur].buf or 1
+      local winid = vim._next_winid
+      vim._next_winid = vim._next_winid + 1
+      vim._windows[winid] = { buf = bufnr, width = 80 }
+      vim._win_tab[winid] = curtab
+      local list = vim._tab_windows[curtab] or {}
+      table.insert(list, winid)
+      vim._tab_windows[curtab] = list
+      vim._current_window = winid
+    elseif command:match("^edit ") then
+      local path = command:sub(6)
+      -- Remove surrounding quotes if any
+      path = path:gsub("^'", ""):gsub("'$", "")
+      -- Find or create buffer for this path
+      local bufnr = -1
+      for id, b in pairs(vim._buffers) do
+        if b.name == path then
+          bufnr = id
+          break
+        end
+      end
+      if bufnr == -1 then
+        bufnr = vim.api.nvim_create_buf(true, false)
+        vim._buffers[bufnr].name = path
+        -- Try to read file content if exists
+        local f = io.open(path, "r")
+        if f then
+          -- Only read if the handle supports :read (avoid tests that stub io.open for writing only)
+          local ok_read = (type(f) == "userdata") or (type(f) == "table" and type(f.read) == "function")
+          if ok_read then
+            local content = f:read("*a") or ""
+            if type(f.close) == "function" then
+              pcall(f.close, f)
+            end
+            vim._buffers[bufnr].lines = {}
+            for line in (content .. "\n"):gmatch("(.-)\n") do
+              table.insert(vim._buffers[bufnr].lines, line)
+            end
+          else
+            -- Gracefully ignore non-readable stubs
+          end
+        end
+      end
+      vim.api.nvim_win_set_buf(vim._current_window, bufnr)
+    elseif command:match("^tabclose") then
+      -- Close current tab: remove all its windows and switch to the lowest-numbered remaining tab
+      local curtab = vim._current_tabpage
+      local wins = vim._tab_windows[curtab] or {}
+      for _, w in ipairs(wins) do
+        if vim._windows[w] then
+          vim.api.nvim_win_close(w, true)
+        end
+      end
+      vim._tab_windows[curtab] = nil
+      vim._tabs[curtab] = nil
+      -- switch to lowest-numbered existing tab
+      local new_cur = nil
+      for t, _ in pairs(vim._tabs) do
+        if not new_cur or t < new_cur then
+          new_cur = t
+        end
+      end
+      if not new_cur then
+        -- recreate a default tab and window
+        vim._tabs[1] = true
+        local bufnr = vim.api.nvim_create_buf(true, false)
+        vim._buffers[bufnr].name = "/home/user/project/test.lua"
+        local winid = vim._next_winid
+        vim._next_winid = vim._next_winid + 1
+        vim._windows[winid] = { buf = bufnr, width = 80 }
+        vim._win_tab[winid] = 1
+        vim._tab_windows[1] = { winid }
+        vim._current_window = winid
+        vim._current_tabpage = 1
+      else
+        vim._current_tabpage = new_cur
+        local list = vim._tab_windows[new_cur]
+        if list and #list > 0 then
+          vim._current_window = list[1]
+        end
+      end
+    else
+      -- other commands (wincmd etc.) are recorded but not simulated
+    end
   end,
 
   json = {
@@ -767,14 +984,18 @@ vim._mock = {
 
   add_window = function(winid, bufnr, cursor)
     vim._windows[winid] = {
-      buffer = bufnr,
+      buf = bufnr,
       cursor = cursor or { 1, 0 },
+      width = 80,
     }
   end,
 
   reset = function()
     vim._buffers = {}
     vim._windows = {}
+    vim._win_tab = {}
+    vim._tab_windows = {}
+    vim._next_winid = 1000
     vim._commands = {}
     vim._autocmds = {}
     vim._vars = {}
@@ -789,6 +1010,19 @@ if _G.vim == nil then
   _G.vim = vim
 end
 vim._mock.add_buffer(1, "/home/user/project/test.lua", "local test = {}\nreturn test")
-vim._mock.add_window(0, 1, { 1, 0 })
+vim._mock.add_window(1000, 1, { 1, 0 })
+vim._win_tab[1000] = 1
+vim._tab_windows[1] = { 1000 }
+vim._current_window = 1000
+
+-- Global options table (minimal)
+vim.o = setmetatable({ columns = 120, lines = 40 }, {
+  __index = function(_, k)
+    return vim._options[k]
+  end,
+  __newindex = function(_, k, v)
+    vim._options[k] = v
+  end,
+})
 
 return vim
diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua
index 3e0d63c..0fb32e5 100644
--- a/tests/unit/config_spec.lua
+++ b/tests/unit/config_spec.lua
@@ -84,10 +84,9 @@ describe("Configuration", function()
       track_selection = false,
       visual_demotion_delay_ms = 50,
       diff_opts = {
-        auto_close_on_accept = true,
-        show_diff_stats = true,
-        vertical_split = true,
-        open_in_current_tab = true,
+        layout = "vertical",
+        open_in_new_tab = false,
+        keep_terminal_focus = false,
       },
       models = {}, -- Empty models array should be rejected
     }
@@ -107,10 +106,9 @@ describe("Configuration", function()
       track_selection = false,
       visual_demotion_delay_ms = 50,
       diff_opts = {
-        auto_close_on_accept = true,
-        show_diff_stats = true,
-        vertical_split = true,
-        open_in_current_tab = true,
+        layout = "vertical",
+        open_in_new_tab = false,
+        keep_terminal_focus = false,
       },
       models = {
         { name = "Test Model" }, -- Missing value field
@@ -150,10 +148,8 @@ describe("Configuration", function()
       connection_timeout = 10000,
       queue_timeout = 5000,
       diff_opts = {
-        auto_close_on_accept = true,
-        show_diff_stats = true,
-        vertical_split = true,
-        open_in_current_tab = true,
+        layout = "vertical",
+        open_in_new_tab = false,
         keep_terminal_focus = true,
       },
       env = {},
@@ -177,10 +173,8 @@ describe("Configuration", function()
       connection_timeout = 10000,
       queue_timeout = 5000,
       diff_opts = {
-        auto_close_on_accept = true,
-        show_diff_stats = true,
-        vertical_split = true,
-        open_in_current_tab = true,
+        layout = "vertical",
+        open_in_new_tab = false,
         keep_terminal_focus = "invalid", -- Should be boolean
       },
       env = {},
diff --git a/tests/unit/diff_hide_terminal_new_tab_spec.lua b/tests/unit/diff_hide_terminal_new_tab_spec.lua
new file mode 100644
index 0000000..e3b624b
--- /dev/null
+++ b/tests/unit/diff_hide_terminal_new_tab_spec.lua
@@ -0,0 +1,122 @@
+require("tests.busted_setup")
+
+describe("Diff new-tab with hidden terminal", function()
+  local open_diff_tool = require("claudecode.tools.open_diff")
+  local diff = require("claudecode.diff")
+
+  local test_old_file = "/tmp/claudecode_diff_hide_old.txt"
+  local test_new_file = "/tmp/claudecode_diff_hide_new.txt"
+  local test_tab_name = "hide-term-in-new-tab"
+
+  before_each(function()
+    -- Create a real file so filereadable() returns 1 in mocks
+    local f = io.open(test_old_file, "w")
+    f:write("line1\nline2\n")
+    f:close()
+
+    -- Ensure a clean diff state
+    diff._cleanup_all_active_diffs("test_setup")
+
+    -- Provide minimal config directly to diff module
+    diff.setup({
+      terminal = { split_side = "right", split_width_percentage = 0.30 },
+      diff_opts = {
+        layout = "vertical",
+        open_in_new_tab = true,
+        keep_terminal_focus = false,
+        hide_terminal_in_new_tab = true,
+      },
+    })
+
+    -- Stub terminal provider with a valid terminal buffer (should be ignored due to hide flag)
+    local term_buf = vim.api.nvim_create_buf(false, true)
+    package.loaded["claudecode.terminal"] = {
+      get_active_terminal_bufnr = function()
+        return term_buf
+      end,
+      ensure_visible = function() end,
+    }
+  end)
+
+  after_each(function()
+    os.remove(test_old_file)
+    os.remove(test_new_file)
+    -- Clear stub to avoid side effects
+    package.loaded["claudecode.terminal"] = nil
+    diff._cleanup_all_active_diffs("test_teardown")
+  end)
+
+  it("does not place a terminal split in the new tab when hidden", function()
+    local params = {
+      old_file_path = test_old_file,
+      new_file_path = test_new_file,
+      new_file_contents = "updated content\n",
+      tab_name = test_tab_name,
+    }
+
+    local co = coroutine.create(function()
+      open_diff_tool.handler(params)
+    end)
+
+    -- Start the tool (it will yield waiting for user action)
+    local ok, err = coroutine.resume(co)
+    assert.is_true(ok, tostring(err))
+    assert.equal("suspended", coroutine.status(co))
+
+    -- Inspect active diff metadata
+    local active = diff._get_active_diffs()
+    assert.is_table(active[test_tab_name])
+    assert.is_true(active[test_tab_name].created_new_tab)
+    -- Key assertion: no terminal window was created in the new tab
+    assert.is_nil(active[test_tab_name].terminal_win_in_new_tab)
+
+    -- Resolve to finish the coroutine
+    vim.schedule(function()
+      diff._resolve_diff_as_rejected(test_tab_name)
+    end)
+    vim.wait(100, function()
+      return coroutine.status(co) == "dead"
+    end)
+  end)
+
+  it("wipes the initial unnamed buffer created by tabnew", function()
+    local params = {
+      old_file_path = test_old_file,
+      new_file_path = test_new_file,
+      new_file_contents = "updated content\n",
+      tab_name = test_tab_name,
+    }
+
+    -- Start handler
+    local co = coroutine.create(function()
+      open_diff_tool.handler(params)
+    end)
+
+    local ok, err = coroutine.resume(co)
+    assert.is_true(ok, tostring(err))
+    assert.equal("suspended", coroutine.status(co))
+
+    -- After diff opens, the initial unnamed buffer in the new tab should be gone
+    -- because plugin marks it bufhidden=wipe and then replaces it
+    local unnamed_count = 0
+    for _, buf in pairs(vim._buffers) do
+      if buf.name == nil or buf.name == "" then
+        unnamed_count = unnamed_count + 1
+      end
+    end
+    -- There may be zero unnamed buffers, or other tests may create scratch buffers with names
+    -- The important assertion is that there is no unnamed buffer with bufhidden=wipe lingering
+    for id, buf in pairs(vim._buffers) do
+      local bh = buf.options and buf.options.bufhidden or nil
+      assert.not_equal("wipe", bh, "Found lingering unnamed buffer with bufhidden=wipe (buf " .. tostring(id) .. ")")
+    end
+
+    -- Cleanup by rejecting
+    vim.schedule(function()
+      diff._resolve_diff_as_rejected(test_tab_name)
+    end)
+    vim.wait(100, function()
+      return coroutine.status(co) == "dead"
+    end)
+  end)
+end)
diff --git a/tests/unit/diff_mcp_spec.lua b/tests/unit/diff_mcp_spec.lua
index 3ba3d20..daf3212 100644
--- a/tests/unit/diff_mcp_spec.lua
+++ b/tests/unit/diff_mcp_spec.lua
@@ -153,9 +153,10 @@ describe("MCP-compliant diff operations", function()
       end)
       coroutine.resume(co)
 
-      -- Simulate completion
+      -- Simulate completion and explicit close_tab
       vim.schedule(function()
         diff._resolve_diff_as_saved(test_tab_name, 1)
+        diff.close_diff_by_tab_name(test_tab_name)
       end)
 
       vim.wait(1000, function()
@@ -180,9 +181,10 @@ describe("MCP-compliant diff operations", function()
       local mid_autocmd_count = #vim.api.nvim_get_autocmds({ group = "ClaudeCodeMCPDiff" })
       assert.is_true(mid_autocmd_count > initial_autocmd_count, "Autocmds should be created")
 
-      -- Simulate completion
+      -- Simulate completion and explicit close_tab
       vim.schedule(function()
         diff._resolve_diff_as_rejected(test_tab_name)
+        diff.close_diff_by_tab_name(test_tab_name)
       end)
 
       vim.wait(1000, function()
@@ -205,9 +207,10 @@ describe("MCP-compliant diff operations", function()
       -- Verify diff is tracked
       -- Note: This test may need adjustment based on actual buffer creation
 
-      -- Clean up
+      -- Clean up via reject + close_tab
       vim.schedule(function()
         diff._resolve_diff_as_rejected(test_tab_name)
+        diff.close_diff_by_tab_name(test_tab_name)
       end)
 
       vim.wait(1000, function()
diff --git a/tests/unit/diff_spec.lua b/tests/unit/diff_spec.lua
index 2c58e9e..6b6f98d 100644
--- a/tests/unit/diff_spec.lua
+++ b/tests/unit/diff_spec.lua
@@ -127,9 +127,9 @@ describe("Diff Module", function()
     it("should create diff with correct parameters", function()
       diff.setup({
         diff_opts = {
-          vertical_split = true,
-          show_diff_stats = false,
-          auto_close_on_accept = true,
+          layout = "vertical",
+          open_in_new_tab = false,
+          keep_terminal_focus = false,
         },
       })
 
@@ -182,9 +182,9 @@ describe("Diff Module", function()
     it("should use horizontal split when configured", function()
       diff.setup({
         diff_opts = {
-          vertical_split = false,
-          show_diff_stats = false,
-          auto_close_on_accept = true,
+          layout = "horizontal",
+          open_in_new_tab = false,
+          keep_terminal_focus = false,
         },
       })
 
@@ -209,19 +209,19 @@ describe("Diff Module", function()
 
       expect(result.success).to_be_true()
       local found_split = false
-      local found_vertical_split = false
+      local found_vsplit = false
 
       for _, cmd in ipairs(commands) do
-        if cmd:find("split", 1, true) and not cmd:find("vertical split", 1, true) then
-          found_split = true
+        if cmd:find("vsplit", 1, true) then
+          found_vsplit = true
         end
-        if cmd:find("vertical split", 1, true) then
-          found_vertical_split = true
+        if cmd:find("split", 1, true) and not cmd:find("vsplit", 1, true) then
+          found_split = true
         end
       end
 
-      expect(found_split).to_be_true()
-      expect(found_vertical_split).to_be_false()
+      expect(found_split).to_be_true() -- Should use horizontal split (accepts modifiers like belowright)
+      expect(found_vsplit).to_be_false() -- Should not use vertical split
 
       rawset(io, "open", old_io_open)
     end)
diff --git a/tests/unit/diff_ui_cleanup_spec.lua b/tests/unit/diff_ui_cleanup_spec.lua
new file mode 100644
index 0000000..454049b
--- /dev/null
+++ b/tests/unit/diff_ui_cleanup_spec.lua
@@ -0,0 +1,119 @@
+require("tests.busted_setup")
+
+local diff = require("claudecode.diff")
+
+describe("Diff UI cleanup behavior", function()
+  local test_old_file = "/tmp/test_ui_cleanup_old.txt"
+  local tab_name = "test_ui_cleanup_tab"
+
+  before_each(function()
+    -- Prepare a dummy file
+    local f = io.open(test_old_file, "w")
+    f:write("line1\nline2\n")
+    f:close()
+
+    -- Reset tabs mock
+    vim._tabs = { [1] = true, [2] = true }
+    vim._current_tabpage = 2 -- Simulate we're on the newly created tab during cleanup
+  end)
+
+  after_each(function()
+    os.remove(test_old_file)
+    -- Ensure cleanup doesn't leave state behind
+    diff._cleanup_all_active_diffs("test_teardown")
+  end)
+
+  it("closes the created new tab on accept only after close_tab is invoked", function()
+    -- Minimal windows/buffers for cleanup paths
+    local new_win = 2001
+    local target_win = 2002
+    vim._windows[new_win] = { buf = 2 }
+    vim._windows[target_win] = { buf = 3 }
+
+    -- Register a pending diff that was opened in a new tab
+    diff._register_diff_state(tab_name, {
+      old_file_path = test_old_file,
+      new_window = new_win,
+      target_window = target_win,
+      new_buffer = 2,
+      original_buffer = 3,
+      original_cursor_pos = { 1, 0 },
+      original_tab_number = 1,
+      created_new_tab = true,
+      new_tab_number = 2,
+      had_terminal_in_original = false,
+      autocmd_ids = {},
+      status = "pending",
+      resolution_callback = function(_) end,
+      is_new_file = false,
+    })
+
+    -- Resolve as saved: should NOT close the tab yet
+    diff._resolve_diff_as_saved(tab_name, 2)
+    assert.is_true(
+      vim._last_command == nil or vim._last_command:match("^tabclose") == nil,
+      "Did not expect ':tabclose' before close_tab tool call"
+    )
+
+    -- Simulate close_tab tool invocation
+    local closed = diff.close_diff_by_tab_name(tab_name)
+    assert.is_true(closed)
+    -- Verify a tabclose command was issued now
+    assert.is_true(
+      type(vim._last_command) == "string" and vim._last_command:match("^tabclose") ~= nil,
+      "Expected a ':tabclose' command to be executed on close_tab"
+    )
+  end)
+
+  it("keeps Claude terminal visible in original tab on reject when previously visible", function()
+    -- Spy on terminal.ensure_visible by preloading a stub module
+    local ensure_calls = 0
+    package.loaded["claudecode.terminal"] = {
+      ensure_visible = function()
+        ensure_calls = ensure_calls + 1
+        return true
+      end,
+      get_active_terminal_bufnr = function()
+        return nil
+      end,
+    }
+
+    -- Minimal windows/buffers for cleanup paths
+    local new_win = 2101
+    local target_win = 2102
+    vim._windows[new_win] = { buf = 4 }
+    vim._windows[target_win] = { buf = 5 }
+
+    -- Register a pending diff that was opened in a new tab, and track that
+    -- the terminal was visible in the original tab when the diff was created
+    diff._register_diff_state(tab_name, {
+      old_file_path = test_old_file,
+      new_window = new_win,
+      target_window = target_win,
+      new_buffer = 4,
+      original_buffer = 5,
+      original_cursor_pos = { 1, 0 },
+      original_tab_number = 1,
+      created_new_tab = true,
+      new_tab_number = 2,
+      had_terminal_in_original = true,
+      autocmd_ids = {},
+      status = "pending",
+      resolution_callback = function(_) end,
+      is_new_file = false,
+    })
+
+    -- Mark as rejected and verify no cleanup yet
+    diff._resolve_diff_as_rejected(tab_name)
+    assert.equals(0, ensure_calls)
+
+    -- Simulate close_tab tool invocation for a pending diff (treated as reject)
+    local closed = diff.close_diff_by_tab_name(tab_name)
+    assert.is_true(closed)
+    -- ensure_visible should have been called exactly once during cleanup
+    assert.equals(1, ensure_calls)
+
+    -- Clear the stub to avoid side effects for other tests
+    package.loaded["claudecode.terminal"] = nil
+  end)
+end)
