-- UI utility functions
local M = {}

-- Navigate through prompt history with consideration for multi-line input
-- Only triggers history navigation at text boundaries when using arrow keys
function M.navigate_history(direction, key, prev_history_fn, next_history_fn)
  return

function()
    local is_arrow_key = key == '<up>' or key == '<down>'
    if is_arrow_key then
      local cursor_pos = vim.api.nvim_win_get_cursor(0)
      local current_line = cursor_pos[1]
      local line_count = vim.api.nvim_buf_line_count(0)
      if (direction == 'prev' and current_line <= 1) or
          (direction == 'next' and current_line >= line_count) then
        if direction == 'prev' then prev_history_fn() else next_history_fn() end
      else
        vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key, true, false, true), 'n', false)
      end
    else
      if direction == 'prev' then prev_history_fn() else next_history_fn() end
    end
  end

local M = {}

local INPUT_PLACEHOLDER = 'Plan, search, build anything'
local config = require("goose.config").get()
local state = require("goose.state")
local ui_util = require('goose.ui.util')

M.base_window_opts = {
  relative = 'editor',
  style = 'minimal',
  border = 'rounded',
  zindex = 50,
  width = 1,
  height = 1,
  col = 0,
  row = 0
}

function M.setup_options(windows)
  -- Input window/buffer options
  vim.api.nvim_win_set_option(windows.input_win, 'winhighlight', 'Normal:GooseBackground,FloatBorder:GooseBorder')
  vim.api.nvim_win_set_option(windows.input_win, 'signcolumn', 'yes')
  vim.api.nvim_win_set_option(windows.input_win, 'cursorline', false)
  vim.api.nvim_buf_set_option(windows.input_buf, 'buftype', 'nofile')
  vim.api.nvim_buf_set_option(windows.input_buf, 'swapfile', false)
  vim.b[windows.input_buf].completion = false

  -- Output window/buffer options
  vim.api.nvim_win_set_option(windows.output_win, 'winhighlight', 'Normal:GooseBackground,FloatBorder:GooseBorder')
  vim.api.nvim_buf_set_option(windows.output_buf, 'filetype', 'markdown')
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', false)
  vim.api.nvim_buf_set_option(windows.output_buf, 'buftype', 'nofile')
  vim.api.nvim_buf_set_option(windows.output_buf, 'swapfile', false)
end

function M.refresh_placeholder(windows, input_lines)
  -- show placeholder if input buffer is empty - otherwise clear it
  if not input_lines then
    input_lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false)
  end

  if #input_lines == 1 and input_lines[1] == "" then
    local ns_id = vim.api.nvim_create_namespace('input-placeholder')
    vim.api.nvim_buf_set_extmark(windows.input_buf, ns_id, 0, 0, {
      virt_text = { { INPUT_PLACEHOLDER, 'Comment' } },
      virt_text_pos = 'overlay',
    })
  else
    vim.api.nvim_buf_clear_namespace(windows.input_buf, vim.api.nvim_create_namespace('input-placeholder'), 0, -1)
  end
end

function M.setup_autocmds(windows)
  local group = vim.api.nvim_create_augroup('GooseWindows', { clear = true })

  -- Output window autocmds
  vim.api.nvim_create_autocmd({ 'WinEnter', 'BufEnter' }, {
    group = group,
    buffer = windows.output_buf,
    callback = function()
      vim.cmd('stopinsert')
      state.last_focused_goose_window = "output"
      M.refresh_placeholder(windows)
    end
  })

  -- Input window autocmds
  vim.api.nvim_create_autocmd('WinEnter', {
    group = group,
    buffer = windows.input_buf,
    callback = function()
      M.refresh_placeholder(windows)
      state.last_focused_goose_window = "input"
    end
  })

  vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
    buffer = windows.input_buf,
    callback = function()
      local input_lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false)
      state.input_content = input_lines
      M.refresh_placeholder(windows, input_lines)
    end
  })

  vim.api.nvim_create_autocmd('WinClosed', {
    group = group,
    pattern = tostring(windows.input_win) .. ',' .. tostring(windows.output_win),
    callback = function(opts)
      -- Get the window that was closed
      local closed_win = tonumber(opts.match)
      -- If either window is closed, close both
      if closed_win == windows.input_win or closed_win == windows.output_win then
        vim.schedule(function()
          require('goose.ui.ui').close_windows(windows)
        end)
      end
    end
  })

  vim.api.nvim_create_autocmd('WinLeave', {
    group = group,
    pattern = "*",
    callback = function()
      if not require('goose.ui.ui').is_goose_focused() then
        require('goose.context').load()
        state.last_code_win_before_goose = vim.api.nvim_get_current_win()
      end
    end
  })

  vim.api.nvim_create_autocmd('WinLeave', {
    group = group,
    buffer = windows.input_buf,
    callback = function()
      state.last_input_window_position = vim.api.nvim_win_get_cursor(0)
    end
  })

  vim.api.nvim_create_autocmd('WinLeave', {
    group = group,
    buffer = windows.output_buf,
    callback = function()
      state.last_output_window_position = vim.api.nvim_win_get_cursor(0)
    end
  })
end

function M.configure_window_dimensions(windows)
  local total_width = vim.api.nvim_get_option('columns')
  local total_height = vim.api.nvim_get_option('lines')
  local is_fullscreen = config.ui.fullscreen

  local width
  if is_fullscreen then
    width = total_width
  else
    width = math.floor(total_width * config.ui.window_width)
  end

  local layout = config.ui.layout
  local total_usable_height
  local row, col

  if layout == "center" then
    -- Use a smaller height for floating; allow an optional `floating_height` factor (e.g. 0.8).
    local fh = config.ui.floating_height
    total_usable_height = math.floor(total_height * fh)
    -- Center the floating window vertically and horizontally.
    row = math.floor((total_height - total_usable_height) / 2)
    col = is_fullscreen and 0 or math.floor((total_width - width) / 2)
  else
    -- "right" layout uses the original full usable height.
    total_usable_height = total_height - 3
    row = 0
    col = is_fullscreen and 0 or (total_width - width)
  end

  local input_height = math.floor(total_usable_height * config.ui.input_height)
  local output_height = total_usable_height - input_height - 2

  vim.api.nvim_win_set_config(windows.output_win, {
    relative = 'editor',
    width = width,
    height = output_height,
    col = col,
    row = row,
  })

  vim.api.nvim_win_set_config(windows.input_win, {
    relative = 'editor',
    width = width,
    height = input_height,
    col = col,
    row = row + output_height + 2,
  })
end

function M.setup_resize_handler(windows)
  local function cb()
    M.configure_window_dimensions(windows)
    require('goose.ui.topbar').render()
  end

  vim.api.nvim_create_autocmd('VimResized', {
    group = vim.api.nvim_create_augroup('GooseResize', { clear = true }),
    callback = cb
  })
end

local function recover_input(windows)
  local input_content = state.input_content
  require('goose.ui.ui').write_to_input(input_content, windows)
  require('goose.ui.mention').highlight_all_mentions(windows.input_buf)
end

function M.setup_after_actions(windows)
  recover_input(windows)
end

local function handle_submit(windows)
  local input_content = table.concat(vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false), '\n')
  vim.api.nvim_buf_set_lines(windows.input_buf, 0, -1, false, {})
  vim.api.nvim_exec_autocmds('TextChanged', {
    buffer = windows.input_buf,
    modeline = false
  })

  -- Switch to the output window
  vim.api.nvim_set_current_win(windows.output_win)

  -- Always scroll to the bottom when submitting a new prompt
  local line_count = vim.api.nvim_buf_line_count(windows.output_buf)
  vim.api.nvim_win_set_cursor(windows.output_win, { line_count, 0 })

  -- Run the command with the input content
  require("goose.core").run(input_content)
end

function M.setup_keymaps(windows)
  local window_keymap = config.keymap.window
  local api = require('goose.api')

  vim.keymap.set({  'n' }, window_keymap.submit, function()
    handle_submit(windows)
  end, { buffer = windows.input_buf, silent = false })

  vim.keymap.set({  'i' }, window_keymap.submit_insert, function()
    handle_submit(windows)
  end, { buffer = windows.input_buf, silent = false })

  vim.keymap.set('n', window_keymap.close, function()
    api.close()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set('n', window_keymap.close, function()
    api.close()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set('n', window_keymap.next_message, function()
    require('goose.ui.navigation').goto_next_message()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set('n', window_keymap.prev_message, function()
    require('goose.ui.navigation').goto_prev_message()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set('n', window_keymap.next_message, function()
    require('goose.ui.navigation').goto_next_message()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set('n', window_keymap.prev_message, function()
    require('goose.ui.navigation').goto_prev_message()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.stop, function()
    api.stop()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.stop, function()
    api.stop()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set('i', window_keymap.mention_file, function()
    require('goose.core').add_file_to_context()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.toggle_pane, function()
    api.toggle_pane()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.toggle_pane, function()
    api.toggle_pane()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.prev_prompt_history,
    ui_util.navigate_history('prev', window_keymap.prev_prompt_history, api.prev_history, api.next_history),
    { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.next_prompt_history,
    ui_util.navigate_history('next', window_keymap.next_prompt_history, api.prev_history, api.next_history),
    { buffer = windows.input_buf, silent = true })
end

return M

local M = {}

local mentions_namespace = vim.api.nvim_create_namespace("GooseMentions")

function M.highlight_all_mentions(buf)
  -- Pattern for mentions
  local mention_pattern = "@[%w_%-%.][%w_%-%.]*"

  -- Clear existing extmarks
  vim.api.nvim_buf_clear_namespace(buf, mentions_namespace, 0, -1)

  -- Get all lines in buffer
  local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)

  for row, line in ipairs(lines) do
    local start_idx = 1
    -- Find all mentions in the line
    while true do
      local mention_start, mention_end = line:find(mention_pattern, start_idx)
      if not mention_start then break end

      -- Add extmark for this mention
      vim.api.nvim_buf_set_extmark(buf, mentions_namespace, row - 1, mention_start - 1, {
        end_col = mention_end,
        hl_group = "GooseMention",
      })

      -- Move to search for the next mention
      start_idx = mention_end + 1
    end
  end
end

local function insert_mention(windows, row, col, name)
  local current_line = vim.api.nvim_buf_get_lines(windows.input_buf, row - 1, row, false)[1]

  local insert_name = '@' .. name .. " "

  local new_line = current_line:sub(1, col) .. insert_name .. current_line:sub(col + 2)
  vim.api.nvim_buf_set_lines(windows.input_buf, row - 1, row, false, { new_line })

  -- Highlight all mentions in the updated buffer
  M.highlight_all_mentions(windows.input_buf)

  vim.defer_fn(function()
    vim.cmd('startinsert')
    vim.api.nvim_set_current_win(windows.input_win)
    vim.api.nvim_win_set_cursor(windows.input_win, { row, col + 1 + #insert_name + 1 })
  end, 100)
end

function M.mention(get_name)
  local windows = require('goose.state').windows

  local mention_key = require('goose.config').get('keymap').window.mention_file
  -- insert @ in case we just want the character
  if mention_key == '@' then
    vim.api.nvim_feedkeys('@', 'in', true)
  end

  local cursor_pos = vim.api.nvim_win_get_cursor(windows.input_win)
  local row, col = cursor_pos[1], cursor_pos[2]

  get_name(function(name)
    insert_mention(windows, row, col, name)
  end)
end

return M

-- Default and user-provided settings for goose.nvim

local M = {}

-- Default configuration
M.defaults = {
  prefered_picker = nil,
  default_global_keymaps = true,
  keymap = {
    global = {
      toggle = '<leader>gg',
      open_input = '<leader>gi',
      open_input_new_session = '<leader>gI',
      open_output = '<leader>go',
      toggle_focus = '<leader>gt',
      close = '<leader>gq',
      toggle_fullscreen = '<leader>gf',
      select_session = '<leader>gs',
      goose_mode_chat = '<leader>gmc',
      goose_mode_auto = '<leader>gma',
      configure_provider = '<leader>gp',
      diff_open = '<leader>gd',
      diff_next = '<leader>g]',
      diff_prev = '<leader>g[',
      diff_close = '<leader>gc',
      diff_revert_all = '<leader>gra',
      diff_revert_this = '<leader>grt'
    },
    window = {
      submit = '<cr>',
      submit_insert = '<cr>',
      close = '<esc>',
      stop = '<C-c>',
      next_message = ']]',
      prev_message = '[[',
      mention_file = '@',
      toggle_pane = '<tab>',
      prev_prompt_history = '<up>',
      next_prompt_history = '<down>'
    }
  },
  ui = {
    window_width = 0.35,
    input_height = 0.15,
    fullscreen = false,
    layout = "right",
    floating_height = 0.8,
    display_model = true,
    display_goose_mode = true
  },
  providers = {
    --[[
    Define available providers and their models for quick model switching
    anthropic|azure|bedrock|databricks|google|groq|ollama|openai|openrouter
    Example:
    openrouter = {
      "anthropic/claude-3.5-sonnet",
      "openai/gpt-4.1",
    },
    ollama = {
      "cogito:14b"
    }
    --]]
  },
  context = {
    cursor_data = false, -- Send cursor position and current line content as context data
  },
}

-- Active configuration
M.values = vim.deepcopy(M.defaults)

function M.setup(opts)
  opts = opts or {}

  if opts.default_global_keymaps == false then
    M.values.keymap.global = {}
  end

  -- Merge user options with defaults (deep merge for nested tables)
  for k, v in pairs(opts) do
    if type(v) == "table" and type(M.values[k]) == "table" then
      M.values[k] = vim.tbl_deep_extend("force", M.values[k], v)
    else
      M.values[k] = v
    end
  end
end

function M.get(key)
  if key then
    return M.values[key]
  end
  return M.values
end

return M

local M = {}

local state = require("goose.state")
local renderer = require('goose.ui.output_renderer')

function M.scroll_to_bottom()
  local line_count = vim.api.nvim_buf_line_count(state.windows.output_buf)
  vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })

  vim.defer_fn(function()
    renderer.render_markdown()
  end, 200)
end

function M.close_windows(windows)
  if not windows then return end

  if M.is_goose_focused() then M.return_to_last_code_win() end

  renderer.stop()

  -- Close windows and delete buffers
  pcall(vim.api.nvim_win_close, windows.input_win, true)
  pcall(vim.api.nvim_win_close, windows.output_win, true)
  pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true })
  pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true })

  -- Clear autocmd groups
  pcall(vim.api.nvim_del_augroup_by_name, 'GooseResize')
  pcall(vim.api.nvim_del_augroup_by_name, 'GooseWindows')

  state.windows = nil
end

function M.return_to_last_code_win()
  local last_win = state.last_code_win_before_goose
  if last_win and vim.api.nvim_win_is_valid(last_win) then
    vim.api.nvim_set_current_win(last_win)
  end
end

function M.create_windows()
  local configurator = require("goose.ui.window_config")
  local input_buf = vim.api.nvim_create_buf(false, true)
  local output_buf = vim.api.nvim_create_buf(false, true)

  require('goose.ui.highlight').setup()

  local input_win = vim.api.nvim_open_win(input_buf, false, configurator.base_window_opts)
  local output_win = vim.api.nvim_open_win(output_buf, false, configurator.base_window_opts)
  local windows = {
    input_buf = input_buf,
    output_buf = output_buf,
    input_win = input_win,
    output_win = output_win
  }

  configurator.setup_options(windows)
  configurator.refresh_placeholder(windows)
  configurator.setup_autocmds(windows)
  configurator.setup_resize_handler(windows)
  configurator.setup_keymaps(windows)
  configurator.setup_after_actions(windows)
  configurator.configure_window_dimensions(windows)
  return windows
end

function M.focus_input(opts)
  opts = opts or {}
  local windows = state.windows
  vim.api.nvim_set_current_win(windows.input_win)

  if opts.restore_position and state.last_input_window_position then
    vim.api.nvim_win_set_cursor(0, state.last_input_window_position)
  end
end

function M.focus_output(opts)
  opts = opts or {}

  local windows = state.windows
  vim.api.nvim_set_current_win(windows.output_win)

  if opts.restore_position and state.last_output_window_position then
    vim.api.nvim_win_set_cursor(0, state.last_output_window_position)
  end
end

function M.is_goose_focused()
  if not state.windows then return false end
  -- are we in a goose window?
  local current_win = vim.api.nvim_get_current_win()
  return M.is_goose_window(current_win)
end

function M.is_goose_window(win)
  local windows = state.windows
  return win == windows.input_win or win == windows.output_win
end

function M.is_output_empty()
  local windows = state.windows
  if not windows or not windows.output_buf then return true end
  local lines = vim.api.nvim_buf_get_lines(windows.output_buf, 0, -1, false)
  return #lines == 0 or (#lines == 1 and lines[1] == "")
end

function M.clear_output()
  local windows = state.windows

  -- Clear any extmarks/namespaces first
  local ns_id = vim.api.nvim_create_namespace('loading_animation')
  vim.api.nvim_buf_clear_namespace(windows.output_buf, ns_id, 0, -1)

  -- Stop any running timers in the output module
  if renderer._animation.timer then
    pcall(vim.fn.timer_stop, renderer._animation.timer)
    renderer._animation.timer = nil
  end
  if renderer._refresh_timer then
    pcall(vim.fn.timer_stop, renderer._refresh_timer)
    renderer._refresh_timer = nil
  end

  -- Reset animation state
  renderer._animation.loading_line = nil

  -- Clear cache to force refresh on next render
  renderer._cache = {
    last_modified = 0,
    output_lines = nil,
    session_path = nil,
    check_counter = 0
  }

  -- Clear all buffer content
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', true)
  vim.api.nvim_buf_set_lines(windows.output_buf, 0, -1, false, {})
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', false)

  require('goose.ui.topbar').render()
  renderer.render_markdown()
end

function M.render_output()
  renderer.render(state.windows, false)
end

function M.stop_render_output()
  renderer.stop()
end

function M.toggle_fullscreen()
  local windows = state.windows
  if not windows then return end

  local ui_config = require("goose.config").get("ui")
  ui_config.fullscreen = not ui_config.fullscreen

  require("goose.ui.window_config").configure_window_dimensions(windows)
  require('goose.ui.topbar').render()

  if not M.is_goose_focused() then
    vim.api.nvim_set_current_win(windows.output_win)
  end
end

function M.select_session(sessions, cb)
  local util = require("goose.util")

  vim.ui.select(sessions, {
    prompt = "",
    format_item = function(session)
      local parts = {}

      if session.description then
        table.insert(parts, session.description)
      end

      if session.message_count then
        table.insert(parts, session.message_count .. " messages")
      end

      local modified = util.time_ago(session.modified)
      if modified then
        table.insert(parts, modified)
      end

      return table.concat(parts, " ~ ")
    end
  }, function(session_choice)
    cb(session_choice)
  end)
end

function M.toggle_pane()
  local current_win = vim.api.nvim_get_current_win()
  if current_win == state.windows.input_win then
    -- When moving from input to output, exit insert mode first
    vim.cmd('stopinsert')
    vim.api.nvim_set_current_win(state.windows.output_win)
  else
    -- When moving from output to input, just change window
    -- (don't automatically enter insert mode)
    vim.api.nvim_set_current_win(state.windows.input_win)

    -- Fix placeholder text when switching to input window
    local lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)
    if #lines == 1 and lines[1] == "" then
      -- Only show placeholder if the buffer is empty
      require('goose.ui.window_config').refresh_placeholder(state.windows)
    else
      -- Clear placeholder if there's text in the buffer
      vim.api.nvim_buf_clear_namespace(state.windows.input_buf, vim.api.nvim_create_namespace('input-placeholder'), 0, -1)
    end
  end
end

function M.write_to_input(text, windows)
  if not windows then windows = state.windows end
  if not windows then return end

  -- Check if input_buf is valid
  if not windows.input_buf or type(windows.input_buf) ~= "number" or not vim.api.nvim_buf_is_valid(windows.input_buf) then
    return
  end

  local lines

  -- Check if text is already a table/list of lines
  if type(text) == "table" then
    lines = text
  else
    -- If it's a string, split it into lines
    lines = {}
    for line in (text .. '\n'):gmatch('(.-)\n') do
      table.insert(lines, line)
    end

    -- If no newlines were found (empty result), use the original text
    if #lines == 0 then
      lines = { text }
    end
  end

  vim.api.nvim_buf_set_lines(windows.input_buf, 0, -1, false, lines)
end

return M

local M = {}

local state = require("goose.state")
local formatter = require("goose.ui.session_formatter")

local LABELS = {
  GENERATING_RESPONSE = "Thinking..."
}

M._cache = {
  last_modified = 0,
  output_lines = nil,
  session_path = nil,
  check_counter = 0
}

M._animation = {
  frames = { "·", "․", "•", "∙", "●", "⬤", "●", "∙", "•", "․" },
  current_frame = 1,
  timer = nil,
  loading_line = nil,
  fps = 10,
}

function M.render_markdown()
  if vim.fn.exists(":RenderMarkdown") > 0 then
    vim.cmd(':RenderMarkdown')
  end
end

function M._should_refresh_content()
  if not state.active_session then return true end

  local session_path = state.active_session.path

  if session_path ~= M._cache.session_path then
    M._cache.session_path = session_path
    return true
  end

  if vim.fn.filereadable(session_path) == 0 then return false end

  local stat = vim.loop.fs_stat(session_path)
  if not stat then return false end

  if state.goose_run_job then
    M._cache.check_counter = (M._cache.check_counter + 1) % 3
    if M._cache.check_counter == 0 then
      local has_file_changed = stat.mtime.sec > M._cache.last_modified
      if has_file_changed then
        M._cache.last_modified = stat.mtime.sec
        return true
      end
    end
  end

  if stat.mtime.sec > M._cache.last_modified then
    M._cache.last_modified = stat.mtime.sec
    return true
  end

  return false
end

function M._read_session(force_refresh)
  if not state.active_session then return nil end

  if not force_refresh and not M._should_refresh_content() and M._cache.output_lines then
    return M._cache.output_lines
  end

  local session_path = state.active_session.path
  local output_lines = formatter.format_session(session_path)
  M._cache.output_lines = output_lines
  return output_lines
end

function M._update_loading_animation(windows)
  if not M._animation.loading_line then return false end

  local buffer_line_count = vim.api.nvim_buf_line_count(windows.output_buf)
  if M._animation.loading_line <= 0 or M._animation.loading_line >= buffer_line_count then
    return false
  end

  local zero_index = M._animation.loading_line - 1
  local loading_text = LABELS.GENERATING_RESPONSE .. " " ..
      M._animation.frames[M._animation.current_frame]

  local ns_id = vim.api.nvim_create_namespace('loading_animation')
  vim.api.nvim_buf_clear_namespace(windows.output_buf, ns_id, zero_index, zero_index + 1)

  vim.api.nvim_buf_set_extmark(windows.output_buf, ns_id, zero_index, 0, {
    virt_text = { { loading_text, "Comment" } },
    virt_text_pos = "overlay",
    hl_mode = "replace"
  })

  return true
end

M._refresh_timer = nil

function M._start_content_refresh_timer(windows)
  if M._refresh_timer then
    pcall(vim.fn.timer_stop, M._refresh_timer)
    M._refresh_timer = nil
  end

  M._refresh_timer = vim.fn.timer_start(300, function()
    if state.goose_run_job then
      if M._should_refresh_content() then
        vim.schedule(function()
          local current_frame = M._animation.current_frame
          M.render(windows, true)
          M._animation.current_frame = current_frame
        end)
      end

      if state.goose_run_job then
        M._start_content_refresh_timer(windows)
      end
    else
      if M._refresh_timer then
        pcall(vim.fn.timer_stop, M._refresh_timer)
        M._refresh_timer = nil
      end
      vim.schedule(function() M.render(windows, true) end)
    end
  end)
end

function M._animate_loading(windows)
  local function start_animation_timer()
    if M._animation.timer then
      pcall(vim.fn.timer_stop, M._animation.timer)
      M._animation.timer = nil
    end

    M._animation.timer = vim.fn.timer_start(math.floor(1000 / M._animation.fps), function()
      M._animation.current_frame = (M._animation.current_frame % #M._animation.frames) + 1

      vim.schedule(function()
        M._update_loading_animation(windows)
      end)

      if state.goose_run_job then
        start_animation_timer()
      else
        M._animation.timer = nil
      end
    end)
  end

  M._start_content_refresh_timer(windows)

  start_animation_timer()
end

function M.render(windows, force_refresh)
  local function render()
    if not state.active_session and not state.new_session_name then
      return
    end

    if not force_refresh and state.goose_run_job and M._animation.loading_line then
      return
    end

    local output_lines = M._read_session(force_refresh)
    local is_new_session = state.new_session_name ~= nil

    if not output_lines then
      if is_new_session then
        output_lines = { "" }
      else
        return
      end
    else
      state.new_session_name = nil
    end

    M.handle_loading(windows, output_lines)

    M.write_output(windows, output_lines)

    M.handle_auto_scroll(windows)
  end
  render()
  require('goose.ui.mention').highlight_all_mentions(windows.output_buf)
  require('goose.ui.topbar').render()
  M.render_markdown()
end

function M.stop()
  if M._animation and M._animation.timer then
    pcall(vim.fn.timer_stop, M._animation.timer)
    M._animation.timer = nil
  end

  if M._refresh_timer then
    pcall(vim.fn.timer_stop, M._refresh_timer)
    M._refresh_timer = nil
  end

  M._animation.loading_line = nil
  M._cache = {
    last_modified = 0,
    output_lines = nil,
    session_path = nil,
    check_counter = 0
  }
end

function M.handle_loading(windows, output_lines)
  if state.goose_run_job then
    if #output_lines > 2 then
      for _, line in ipairs(formatter.separator) do
        table.insert(output_lines, line)
      end
    end

    -- Replace this line with our extmark animation
    local empty_loading_line = " " -- Just needs to be a non-empty string for the extmark to attach to
    table.insert(output_lines, empty_loading_line)
    table.insert(output_lines, "")

    M._animation.loading_line = #output_lines - 1

    -- Always ensure animation is running when there's an active job
    -- This is the key fix - we always start animation for an active job
    M._animate_loading(windows)

    vim.schedule(function()
      M._update_loading_animation(windows)
    end)
  else
    M._animation.loading_line = nil

    local ns_id = vim.api.nvim_create_namespace('loading_animation')
    vim.api.nvim_buf_clear_namespace(windows.output_buf, ns_id, 0, -1)

    if M._animation.timer then
      pcall(vim.fn.timer_stop, M._animation.timer)
      M._animation.timer = nil
    end

    if M._refresh_timer then
      pcall(vim.fn.timer_stop, M._refresh_timer)
      M._refresh_timer = nil
    end
  end
end

function M.write_output(windows, output_lines)
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', true)
  vim.api.nvim_buf_set_lines(windows.output_buf, 0, -1, false, output_lines)
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', false)
end

function M.handle_auto_scroll(windows)
  local line_count = vim.api.nvim_buf_line_count(windows.output_buf)
  local botline = vim.fn.line('w$', windows.output_win)

  local prev_line_count = vim.b[windows.output_buf].prev_line_count or 0
  vim.b[windows.output_buf].prev_line_count = line_count

  local was_at_bottom = (botline >= prev_line_count) or prev_line_count == 0

  if was_at_bottom then
    require("goose.ui.ui").scroll_to_bottom()
  end
end

return M

-- Gathers editor context

local template = require("goose.template")
local util = require("goose.util")
local config = require("goose.config");

local M = {}

M.context = {
  -- current file
  current_file = nil,
  cursor_data = nil,

  -- attachments
  mentioned_files = nil,
  selections = nil,
  linter_errors = nil
}

function M.unload_attachments()
  M.context.mentioned_files = nil
  M.context.selections = nil
  M.context.linter_errors = nil
end

function M.load()
  if util.is_current_buf_a_file() then
    local current_file = M.get_current_file()
    local cursor_data = M.get_current_cursor_data()

    M.context.current_file = current_file
    M.context.cursor_data = cursor_data
    M.context.linter_errors = M.check_linter_errors()
  end

  local current_selection = M.get_current_selection()
  if current_selection then
    local selection = M.new_selection(
      M.context.current_file,
      current_selection.text,
      current_selection.lines
    )
    M.add_selection(selection)
  end
end

function M.check_linter_errors()
  local diagnostics = vim.diagnostic.get(0, { severity = vim.diagnostic.severity.ERROR })
  if #diagnostics == 0 then
    return nil
  end

  local message = "Found " .. #diagnostics .. " error" .. (#diagnostics > 1 and "s" or "") .. ":"

  for i, diagnostic in ipairs(diagnostics) do
    local line_number = diagnostic.lnum + 1 -- Convert to 1-based line numbers
    local short_message = diagnostic.message:gsub("%s+", " "):gsub("^%s", ""):gsub("%s$", "")
    message = message .. "\n Line " .. line_number .. ": " .. short_message
  end

  return message
end

function M.new_selection(file, content, lines)
  return {
    file = file,
    content = util.indent_code_block(content),
    lines = lines
  }
end

function M.add_selection(selection)
  if not M.context.selections then
    M.context.selections = {}
  end

  table.insert(M.context.selections, selection)
end

function M.add_file(file)
  if not M.context.mentioned_files then
    M.context.mentioned_files = {}
  end

  if vim.fn.filereadable(file) ~= 1 then
    vim.notify("File not added to context. Could not read.")
    return
  end

  if not vim.tbl_contains(M.context.mentioned_files, file) then
    table.insert(M.context.mentioned_files, file)
  end
end

function M.delta_context()
  local context = vim.deepcopy(M.context)
  local last_context = require('goose.state').last_sent_context
  if not last_context then return context end

  -- no need to send file context again
  if context.current_file and context.current_file.name == last_context and last_context.current_file.name then
    context.current_file = nil
  end

  return context
end

function M.get_current_file()
  local file = vim.fn.expand('%:p')
  if not file or file == "" or vim.fn.filereadable(file) ~= 1 then
    return nil
  end
  return {
    path = file,
    name = vim.fn.fnamemodify(file, ":t"),
    extension = vim.fn.fnamemodify(file, ":e")
  }
end

function M.get_current_cursor_data()
  if not (config.get("context") and config.get("context").cursor_data) then
    return nil
  end

  local cursor_pos = vim.fn.getcurpos()
  local cursor_content = vim.trim(vim.api.nvim_get_current_line())
  return { line = cursor_pos[2], col = cursor_pos[3], line_content = cursor_content }
end

function M.get_current_selection()
  -- Return nil if not in a visual mode
  if not vim.fn.mode():match("[vV\022]") then
    return nil
  end

  -- Save current position and register state
  local current_pos = vim.fn.getpos(".")
  local old_reg = vim.fn.getreg('x')
  local old_regtype = vim.fn.getregtype('x')

  -- Capture selection text and position
  vim.cmd('normal! "xy')
  local text = vim.fn.getreg('x')

  -- Get line numbers
  vim.cmd("normal! `<")
  local start_line = vim.fn.line(".")
  vim.cmd("normal! `>")
  local end_line = vim.fn.line(".")

  -- Restore state
  vim.fn.setreg('x', old_reg, old_regtype)
  vim.cmd('normal! gv')
  vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', true)
  vim.fn.setpos('.', current_pos)

  return {
    text = text and text:match("[^%s]") and text or nil,
    lines = start_line .. ", " .. end_line
  }
end

function M.format_message(prompt)
  local info = require('goose.info')
  local context = nil

  if info.parse_goose_info().goose_mode == info.GOOSE_MODE.CHAT then
    -- For chat mode only send selection context
    context = {
      selections = M.context.selections
    }
  else
    context = M.delta_context()
  end

  context.prompt = prompt
  return template.render_template(context)
end

function M.extract_from_message(text)
  local context = {
    prompt = template.extract_tag('user-query', text) or text,
    selected_text = template.extract_tag('manually-added-selection', text)
  }
  return context
end

return M

local Path = require('plenary.path')
local M = {}

M.__changed_files = nil
M.__current_file_index = nil
M.__diff_tab = nil

local git = {
  is_project = function()
    if M.__is_git_project ~= nil then
      return M.__is_git_project
    end

    local git_dir = Path:new(vim.fn.getcwd()):joinpath('.git')
    M.__is_git_project = git_dir:exists() and git_dir:is_dir()
    return M.__is_git_project
  end,

  list_changed_files = function()
    local result = vim.fn.system('git ls-files -m -o --exclude-standard')
    return result
  end,

  is_tracked = function(file_path)
    local success = os.execute('git ls-files --error-unmatch "' .. file_path .. '" > /dev/null 2>&1')
    return success == 0
  end,

  get_head_content = function(file_path, output_path)
    local success = os.execute('git show HEAD:"' .. file_path .. '" > "' .. output_path .. '" 2>/dev/null')
    return success == 0
  end
}

local function require_git_project(fn, silent)
  return function(...)
    if not git.is_project() then
      if not silent then
        vim.notify("Error: Not in a git project.")
      end
      return
    end
    return fn(...)
  end
end

-- File helpers
local function get_snapshot_dir()
  local cwd = vim.fn.getcwd()
  local cwd_hash = vim.fn.sha256(cwd)
  return Path:new(vim.fn.stdpath('data')):joinpath('goose', 'snapshot', cwd_hash)
end

local function revert_file(file_path, snapshot_path)
  if snapshot_path then
    Path:new(snapshot_path):copy({ destination = file_path, override = true })
  elseif git.is_tracked(file_path) then
    local temp_file = Path:new(vim.fn.tempname())
    if git.get_head_content(file_path, tostring(temp_file)) then
      temp_file:copy({ destination = file_path, override = true })
      temp_file:rm()
    end
  else
    local absolute_path = vim.fn.fnamemodify(file_path, ":p")
    local bufnr = vim.fn.bufnr(absolute_path)
    if bufnr ~= -1 then
      vim.api.nvim_command('silent! bdelete! ' .. bufnr)
    end
    Path:new(file_path):rm()
  end

  vim.cmd('checktime')
  return true
end

local function close_diff_tab()
  if M.__diff_tab and vim.api.nvim_tabpage_is_valid(M.__diff_tab) then
    pcall(vim.api.nvim_del_augroup_by_name, "GooseDiffCleanup" .. M.__diff_tab)

    local windows = vim.api.nvim_tabpage_list_wins(M.__diff_tab)

    local buffers = {}
    for _, win in ipairs(windows) do
      local buf = vim.api.nvim_win_get_buf(win)
      table.insert(buffers, buf)
    end

    vim.api.nvim_set_current_tabpage(M.__diff_tab)
    pcall(vim.cmd, 'tabclose')

    for _, buf in ipairs(buffers) do
      if vim.api.nvim_buf_is_valid(buf) then
        local visible = false
        for _, win in ipairs(vim.api.nvim_list_wins()) do
          if vim.api.nvim_win_get_buf(win) == buf then
            visible = true
            break
          end
        end

        if not visible then
          pcall(vim.api.nvim_buf_delete, buf, { force = true })
        end
      end
    end
  end
  M.__diff_tab = nil
end

local function files_are_different(file1, file2)
  local result = vim.fn.system('cmp -s "' .. file1 .. '" "' .. file2 .. '"; echo $?')
  return tonumber(result) ~= 0
end

local function get_changed_files()
  local files = {}
  local snapshot_base = get_snapshot_dir()

  if not snapshot_base:exists() then return files end

  local git_files = git.list_changed_files()
  for file in git_files:gmatch("[^\n]+") do
    local snapshot_file = snapshot_base:joinpath(file)

    if snapshot_file:exists() then
      if files_are_different(file, tostring(snapshot_file)) then
        table.insert(files, { file, tostring(snapshot_file) })
      end
    else
      table.insert(files, { file, nil })
    end
  end

  M.__changed_files = files
  return files
end

local function show_file_diff(file_path, snapshot_path)
  close_diff_tab()

  vim.cmd('tabnew')
  M.__diff_tab = vim.api.nvim_get_current_tabpage()

  if snapshot_path then
    -- Compare with snapshot file
    vim.cmd('edit ' .. snapshot_path)
    vim.cmd('setlocal readonly buftype=nofile nomodifiable')
    vim.cmd('diffthis')

    vim.cmd('vsplit ' .. file_path)
    vim.cmd('diffthis')
  else
    -- If file is tracked by git, compare with HEAD, otherwise just open it
    if git.is_tracked(file_path) then
      -- Create a temporary file from the HEAD version
      local temp_file = vim.fn.tempname()
      if git.get_head_content(file_path, temp_file) then
        -- First edit the current file
        vim.cmd('edit ' .. file_path)
        local file_type = vim.bo.filetype

        -- Then split with HEAD version on the left
        vim.cmd('leftabove vsplit ' .. temp_file)
        vim.cmd('setlocal readonly buftype=nofile nomodifiable filetype=' .. file_type)
        vim.cmd('diffthis')

        -- Go back to current file window and enable diff there
        vim.cmd('wincmd l')
        vim.cmd('diffthis')
      else
        -- File is not tracked by git, just open it
        vim.cmd('edit ' .. file_path)
      end
    else
      -- File is not tracked by git, just open it
      vim.cmd('edit ' .. file_path)
    end
  end

  -- auto close tab if any diff window is closed
  local augroup = vim.api.nvim_create_augroup("GooseDiffCleanup" .. M.__diff_tab, { clear = true })
  local tab_windows = vim.api.nvim_tabpage_list_wins(M.__diff_tab)
  vim.api.nvim_create_autocmd("WinClosed", {
    group = augroup,
    pattern = tostring(tab_windows[1]) .. ',' .. tostring(tab_windows[2]),
    callback = function() close_diff_tab() end
  })
end

local function display_file_at_index(idx)
  local file_data = M.__changed_files[idx]
  local file_name = vim.fn.fnamemodify(file_data[1], ":t")
  vim.notify(string.format("Showing file %d of %d: %s", idx, #M.__changed_files, file_name))
  show_file_diff(file_data[1], file_data[2])
end

-- Public functions

M.review = require_git_project(function()
  local files = get_changed_files()

  if #files == 0 then
    vim.notify("No changes to review.")
    return
  end

  if #files == 1 then
    M.__current_file_index = 1
    show_file_diff(files[1][1], files[1][2])
  else
    vim.ui.select(vim.tbl_map(function(f) return f[1] end, files),
      { prompt = "Select a file to review:" },
      function(choice, idx)
        if not choice then return end
        M.__current_file_index = idx
        show_file_diff(files[idx][1], files[idx][2])
      end)
  end
end)

M.next_diff = require_git_project(function()
  if not M.__changed_files or not M.__current_file_index or M.__current_file_index >= #M.__changed_files then
    local files = get_changed_files()
    if #files == 0 then
      vim.notify("No changes to review.")
      return
    end
    M.__current_file_index = 1
  else
    M.__current_file_index = M.__current_file_index + 1
  end

  display_file_at_index(M.__current_file_index)
end)

M.prev_diff = require_git_project(function()
  if not M.__changed_files or #M.__changed_files == 0 then
    local files = get_changed_files()
    if #files == 0 then
      vim.notify("No changes to review.")
      return
    end
    M.__current_file_index = #files
  else
    if not M.__current_file_index or M.__current_file_index <= 1 then
      M.__current_file_index = #M.__changed_files
    else
      M.__current_file_index = M.__current_file_index - 1
    end
  end

  display_file_at_index(M.__current_file_index)
end)

M.close_diff = function()
  close_diff_tab()
end

M.check_cleanup_breakpoint = function()
  local changed_files = get_changed_files()
  if #changed_files == 0 then
    local snapshot_base = get_snapshot_dir()
    if snapshot_base:exists() then
      snapshot_base:rm({ recursive = true })
    end
  end
end

M.set_breakpoint = require_git_project(function()
  local snapshot_base = get_snapshot_dir()

  if snapshot_base:exists() then
    snapshot_base:rm({ recursive = true })
  end

  snapshot_base:mkdir({ parents = true })

  for file in git.list_changed_files():gmatch("[^\n]+") do
    local source_file = Path:new(file)
    local target_file = snapshot_base:joinpath(file)
    target_file:parent():mkdir({ parents = true })
    source_file:copy({ destination = target_file })
  end
end, true)

M.revert_all = require_git_project(function()
  local files = get_changed_files()

  if #files == 0 then
    vim.notify("No changes to revert.")
    return
  end

  if vim.fn.input("Revert all " .. #files .. " changed files? (y/n): "):lower() ~= "y" then
    return
  end

  local success_count = 0
  for _, file_data in ipairs(files) do
    if revert_file(file_data[1], file_data[2]) then
      success_count = success_count + 1
    end
  end

  vim.notify("Reverted " .. success_count .. " of " .. #files .. " files.")
end)

M.revert_current = require_git_project(function()
  local files = get_changed_files()
  local current_file = vim.fn.expand('%:p')
  local rel_path = vim.fn.fnamemodify(current_file, ':.')

  local changed_file = nil
  for _, file_data in ipairs(files) do
    if file_data[1] == rel_path then
      changed_file = file_data
      break
    end
  end

  if not changed_file then
    vim.notify("No changes to revert.")
    return
  end

  if vim.fn.input("Revert current file? (y/n): "):lower() ~= "y" then
    return
  end

  if revert_file(changed_file[1], changed_file[2]) then
    vim.cmd('e!')
  end
end)

M.reset_git_status = function()
  M.__is_git_project = nil
end

return M

local core = require("goose.core")

local ui = require("goose.ui.ui")
local state = require("goose.state")
local review = require("goose.review")
local history = require("goose.history")

local M = {}

-- Core API functions

function M.open_input()
  core.open({ new_session = false, focus = "input" })
  vim.cmd('startinsert')
end

function M.open_input_new_session()
  core.open({ new_session = true, focus = "input" })
  vim.cmd('startinsert')
end

function M.open_output()
  core.open({ new_session = false, focus = "output" })
end

function M.close()
  ui.close_windows(state.windows)
end

function M.toggle()
  if state.windows == nil then
    local focus = state.last_focused_goose_window or "input"
    core.open({ new_session = false, focus = focus })
  else
    M.close()
  end
end

function M.toggle_focus()
  if not ui.is_goose_focused() then
    local focus = state.last_focused_goose_window or "input"
    core.open({ new_session = false, focus = focus })
  else
    ui.return_to_last_code_win()
  end
end

function M.change_mode(mode)
  local info_mod = require("goose.info")
  info_mod.set_config_value(info_mod.GOOSE_INFO.MODE, mode)

  if state.windows then
    require('goose.ui.topbar').render()
  else
    vim.notify('Goose mode changed to ' .. mode)
  end
end

function M.goose_mode_chat()
  M.change_mode(require('goose.info').GOOSE_MODE.CHAT)
end

function M.goose_mode_auto()
  M.change_mode(require('goose.info').GOOSE_MODE.AUTO)
end

function M.configure_provider()
  core.configure_provider()
end

function M.stop()
  core.stop()
end

function M.run(prompt)
  core.run(prompt, {
    ensure_ui = true,
    new_session = false,
    focus = "output"
  })
end

function M.run_new_session(prompt)
  core.run(prompt, {
    ensure_ui = true,
    new_session = true,
    focus = "output"
  })
end

function M.toggle_fullscreen()
  if not state.windows then
    core.open({ new_session = false, focus = "output" })
  end

  ui.toggle_fullscreen()
end

function M.select_session()
  core.select_session()
end

function M.toggle_pane()
  if not state.windows then
    core.open({ new_session = false, focus = "output" })
    return
  end

  ui.toggle_pane()
end

function M.diff_open()
  review.review()
end

function M.diff_next()
  review.next_diff()
end

function M.diff_prev()
  review.prev_diff()
end

function M.diff_close()
  review.close_diff()
end

function M.diff_revert_all()
  review.revert_all()
end

function M.diff_revert_this()
  review.revert_current()
end

function M.set_review_breakpoint()
  review.set_breakpoint()
end

function M.prev_history()
  local prev_prompt = history.prev()
  if prev_prompt then
    ui.write_to_input(prev_prompt)
  end
end

function M.next_history()
  local next_prompt = history.next()
  if next_prompt then
    ui.write_to_input(next_prompt)
  end
end

-- Command definitions that call the API functions
M.commands = {
  toggle = {
    name = "Goose",
    desc = "Open goose. Close if opened",
    fn = function()
      M.toggle()
    end
  },

  toggle_focus = {
    name = "GooseToggleFocus",
    desc = "Toggle focus between goose and last window",
    fn = function()
      M.toggle_focus()
    end
  },

  open_input = {
    name = "GooseOpenInput",
    desc = "Opens and focuses on input window on insert mode",
    fn = function()
      M.open_input()
    end
  },

  open_input_new_session = {
    name = "GooseOpenInputNewSession",
    desc = "Opens and focuses on input window on insert mode. Creates a new session",
    fn = function()
      M.open_input_new_session()
    end
  },

  open_output = {
    name = "GooseOpenOutput",
    desc = "Opens and focuses on output window",
    fn = function()
      M.open_output()
    end
  },

  close = {
    name = "GooseClose",
    desc = "Close UI windows",
    fn = function()
      M.close()
    end
  },

  stop = {
    name = "GooseStop",
    desc = "Stop goose while it is running",
    fn = function()
      M.stop()
    end
  },

  toggle_fullscreen = {
    name = "GooseToggleFullscreen",
    desc = "Toggle between normal and fullscreen mode",
    fn = function()
      M.toggle_fullscreen()
    end
  },

  select_session = {
    name = "GooseSelectSession",
    desc = "Select and load a goose session",
    fn = function()
      M.select_session()
    end
  },

  toggle_pane = {
    name = "GooseTogglePane",
    desc = "Toggle between input and output panes",
    fn = function()
      M.toggle_pane()
    end
  },

  goose_mode_chat = {
    name = "GooseModeChat",
    desc = "Set goose mode to `chat`. (Tool calling disabled. No editor context besides selections)",
    fn = function()
      M.goose_mode_chat()
    end
  },

  goose_mode_auto = {
    name = "GooseModeAuto",
    desc = "Set goose mode to `auto`. (Default mode with full agent capabilities)",
    fn = function()
      M.goose_mode_auto()
    end
  },

  configure_provider = {
    name = "GooseConfigureProvider",
    desc = "Quick provider and model switch from predefined list",
    fn = function()
      M.configure_provider()
    end
  },

  run = {
    name = "GooseRun",
    desc = "Run goose with a prompt (continue last session)",
    fn = function(opts)
      M.run(opts.args)
    end
  },

  run_new_session = {
    name = "GooseRunNewSession",
    desc = "Run goose with a prompt (new session)",
    fn = function(opts)
      M.run_new_session(opts.args)
    end
  },

  -- Updated diff command names
  diff_open = {
    name = "GooseDiff",
    desc = "Opens a diff tab of a modified file since the last goose prompt",
    fn = function()
      M.diff_open()
    end
  },

  diff_next = {
    name = "GooseDiffNext",
    desc = "Navigate to next file diff",
    fn = function()
      M.diff_next()
    end
  },

  diff_prev = {
    name = "GooseDiffPrev",
    desc = "Navigate to previous file diff",
    fn = function()
      M.diff_prev()
    end
  },

  diff_close = {
    name = "GooseDiffClose",
    desc = "Close diff view tab and return to normal editing",
    fn = function()
      M.diff_close()
    end
  },

  diff_revert_all = {
    name = "GooseRevertAll",
    desc = "Revert all file changes since the last goose prompt",
    fn = function()
      M.diff_revert_all()
    end
  },

  diff_revert_this = {
    name = "GooseRevertThis",
    desc = "Revert current file changes since the last goose prompt",
    fn = function()
      M.diff_revert_this()
    end
  },

  set_review_breakpoint = {
    name = "GooseSetReviewBreakpoint",
    desc = "Set a review breakpoint to track changes",
    fn = function()
      M.set_review_breakpoint()
    end
  },
}

function M.setup()
  -- Register commands without arguments
  for key, cmd in pairs(M.commands) do
    if key ~= "run" and key ~= "run_new_session" then
      vim.api.nvim_create_user_command(cmd.name, cmd.fn, {
        desc = cmd.desc
      })
    end
  end

  -- Register commands with arguments
  vim.api.nvim_create_user_command(M.commands.run.name, M.commands.run.fn, {
    desc = M.commands.run.desc,
    nargs = "+"
  })

  vim.api.nvim_create_user_command(M.commands.run_new_session.name, M.commands.run_new_session.fn, {
    desc = M.commands.run_new_session.desc,
    nargs = "+"
  })
end

return M

local M = {}

function M.template(str, vars)
  return (str:gsub("{(.-)}", function(key)
    return tostring(vars[key] or "")
  end))
end

function M.uid()
  return tostring(os.time()) .. "-" .. tostring(math.random(1000, 9999))
end

function M.is_current_buf_a_file()
  local bufnr = vim.api.nvim_get_current_buf()
  local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype")
  local filepath = vim.fn.expand('%:p')

  -- Valid files have empty buftype
  -- This excludes special buffers like help, terminal, nofile, etc.
  return buftype == "" and filepath ~= ""
end

function M.indent_code_block(text)
  if not text then return nil end
  local lines = vim.split(text, "\n", true)

  local first, last = nil, nil
  for i, line in ipairs(lines) do
    if line:match("[^%s]") then
      first = first or i
      last = i
    end
  end

  if not first then return "" end

  local content = {}
  for i = first, last do
    table.insert(content, lines[i])
  end

  local min_indent = math.huge
  for _, line in ipairs(content) do
    if line:match("[^%s]") then
      min_indent = math.min(min_indent, line:match("^%s*"):len())
    end
  end

  if min_indent < math.huge and min_indent > 0 then
    for i, line in ipairs(content) do
      if line:match("[^%s]") then
        content[i] = line:sub(min_indent + 1)
      end
    end
  end

  return vim.trim(table.concat(content, "\n"))
end

-- Get timezone offset in seconds for various timezone formats
function M.get_timezone_offset(timezone)
  -- Handle numeric timezone formats (+HHMM, -HHMM)
  if timezone:match("^[%+%-]%d%d:?%d%d$") then
    local sign = timezone:sub(1, 1) == "+" and 1 or -1
    local hours = tonumber(timezone:match("^[%+%-](%d%d)"))
    local mins = tonumber(timezone:match("^[%+%-]%d%d:?(%d%d)$") or "00")
    return sign * (hours * 3600 + mins * 60)
  end

  -- Map of common timezone abbreviations to their offset in seconds from UTC
  local timezone_map = {
    -- Zero offset timezones
    ["UTC"] = 0,
    ["GMT"] = 0,

    -- North America
    ["EST"] = -5 * 3600,
    ["EDT"] = -4 * 3600,
    ["CST"] = -6 * 3600,
    ["CDT"] = -5 * 3600,
    ["MST"] = -7 * 3600,
    ["MDT"] = -6 * 3600,
    ["PST"] = -8 * 3600,
    ["PDT"] = -7 * 3600,
    ["AKST"] = -9 * 3600,
    ["AKDT"] = -8 * 3600,
    ["HST"] = -10 * 3600,

    -- Europe
    ["WET"] = 0,
    ["WEST"] = 1 * 3600,
    ["CET"] = 1 * 3600,
    ["CEST"] = 2 * 3600,
    ["EET"] = 2 * 3600,
    ["EEST"] = 3 * 3600,
    ["MSK"] = 3 * 3600,
    ["BST"] = 1 * 3600,

    -- Asia & Middle East
    ["IST"] = 5.5 * 3600,
    ["PKT"] = 5 * 3600,
    ["HKT"] = 8 * 3600,
    ["PHT"] = 8 * 3600,
    ["JST"] = 9 * 3600,
    ["KST"] = 9 * 3600,

    -- Australia & Pacific
    ["AWST"] = 8 * 3600,
    ["ACST"] = 9.5 * 3600,
    ["AEST"] = 10 * 3600,
    ["AEDT"] = 11 * 3600,
    ["NZST"] = 12 * 3600,
    ["NZDT"] = 13 * 3600,
  }

  -- Handle special cases for ambiguous abbreviations
  if timezone == "CST" and not timezone_map[timezone] then
    -- In most contexts, CST refers to Central Standard Time (US)
    return -6 * 3600
  end

  -- Return the timezone offset or default to UTC (0)
  return timezone_map[timezone] or 0
end

-- Reset all ANSI styling
function M.ansi_reset()
  return "\27[0m"
end

--- Convert a datetime string to a human-readable "time ago" format
-- @param dateTime string: Datetime string (e.g., "2025-03-02 12:39:02 UTC")
-- @return string: Human-readable time ago string (e.g., "2 hours ago")
function M.time_ago(dateTime)
  -- Parse the input datetime string
  local year, month, day, hour, min, sec, zone = dateTime:match(
    "(%d+)%-(%d+)%-(%d+)%s+(%d+):(%d+):(%d+)%s+([%w%+%-/:]+)")

  -- If parsing fails, try another common format
  if not year then
    year, month, day, hour, min, sec = dateTime:match("(%d+)%-(%d+)%-(%d+)[T ](%d+):(%d+):(%d+)")
    -- No timezone specified, treat as local time
  end

  -- Return early if we couldn't parse the date
  if not year then
    return "Invalid date format"
  end

  -- Convert string values to numbers
  year, month, day = tonumber(year), tonumber(month), tonumber(day)
  hour, min, sec = tonumber(hour), tonumber(min), tonumber(sec)

  -- Get current time for comparison
  local now = os.time()

  -- Create date table for the input time
  local date_table = {
    year = year,
    month = month,
    day = day,
    hour = hour,
    min = min,
    sec = sec,
    isdst = false -- Ignore DST for consistency
  }

  -- Calculate timestamp based on whether timezone is specified
  local timestamp

  if zone then
    -- Get the timezone offset from our comprehensive map
    local input_offset_seconds = M.get_timezone_offset(zone)

    -- Get the local timezone offset
    local local_offset_seconds = os.difftime(os.time(os.date("*t", now)), os.time(os.date("!*t", now)))

    -- Calculate the hour in the local timezone
    -- First convert the input time to UTC, then to local time
    local adjusted_hour = hour - (input_offset_seconds / 3600) + (local_offset_seconds / 3600)

    -- Update the date table with adjusted hours and minutes
    date_table.hour = math.floor(adjusted_hour)
    date_table.min = math.floor(min + ((adjusted_hour % 1) * 60))

    -- Get timestamp in local timezone
    timestamp = os.time(date_table)
  else
    -- No timezone specified, assume it's already in local time
    timestamp = os.time(date_table)
  end

  -- Calculate time difference in seconds
  local diff = now - timestamp

  -- Format the relative time based on the difference
  if diff < 0 then
    return "in the future"
  elseif diff < 60 then
    return "just now"
  elseif diff < 3600 then
    local mins = math.floor(diff / 60)
    return mins == 1 and "1 minute ago" or mins .. " minutes ago"
  elseif diff < 86400 then
    local hours = math.floor(diff / 3600)
    return hours == 1 and "1 hour ago" or hours .. " hours ago"
  elseif diff < 604800 then
    local days = math.floor(diff / 86400)
    return days == 1 and "1 day ago" or days .. " days ago"
  elseif diff < 2592000 then
    local weeks = math.floor(diff / 604800)
    return weeks == 1 and "1 week ago" or weeks .. " weeks ago"
  elseif diff < 31536000 then
    local months = math.floor(diff / 2592000)
    return months == 1 and "1 month ago" or months .. " months ago"
  else
    local years = math.floor(diff / 31536000)
    return years == 1 and "1 year ago" or years .. " years ago"
  end
end

-- Simple YAML key/value setter
-- Note: This is a basic implementation that assumes simple YAML structure
-- It will either update an existing key or append a new key at the end
function M.set_yaml_value(path, key, value)
  if not path then
    return false, "No file path provided"
  end

  -- Read the current content
  local lines = {}
  local key_pattern = "^%s*" .. vim.pesc(key) .. ":%s*"
  local found = false

  local file = io.open(path, "r")
  if not file then
    return false, "Could not open file"
  end

  for line in file:lines() do
    if line:match(key_pattern) then
      -- Update existing key
      lines[#lines + 1] = string.format("%s: %s", key, value)
      found = true
    else
      lines[#lines + 1] = line
    end
  end
  file:close()

  -- If key wasn't found, append it
  if not found then
    lines[#lines + 1] = string.format("%s: %s", key, value)
  end

  -- Write back to file
  file = io.open(path, "w")
  if not file then
    return false, "Could not open file for writing"
  end
  file:write(table.concat(lines, "\n"))
  file:close()

  return true
end

return M

local M = {}

local context_module = require('goose.context')

M.separator = {
  "---",
  ""
}

function M.format_session(session_path)
  if vim.fn.filereadable(session_path) == 0 then return nil end

  local session_lines = vim.fn.readfile(session_path)
  if #session_lines == 0 then return nil end

  local output_lines = { "" }

  local need_separator = false

  for i = 2, #session_lines do
    local success, message = pcall(vim.fn.json_decode, session_lines[i])
    if not success then goto continue end

    local message_lines = M._format_message(message)
    if message_lines then
      if need_separator then
        for _, line in ipairs(M.separator) do
          table.insert(output_lines, line)
        end
      else
        need_separator = true
      end

      vim.list_extend(output_lines, message_lines)
    end

    ::continue::
  end

  return output_lines
end

function M._format_user_message(lines, text)
  local context = context_module.extract_from_message(text)
  for _, line in ipairs(vim.split(context.prompt, "\n")) do
    table.insert(lines, "> " .. line)
  end

  if context.selected_text then
    table.insert(lines, "")
    for _, line in ipairs(vim.split(context.selected_text, "\n")) do
      table.insert(lines, line)
    end
  end
end

function M._format_message(message)
  if not message.content then return nil end

  local lines = {}
  local has_content = false

  for _, part in ipairs(message.content) do
    if part.type == 'text' and part.text and part.text ~= "" then
      local text = vim.trim(part.text)
      has_content = true

      if message.role == 'user' then
        M._format_user_message(lines, text)
      elseif message.role == 'assistant' then
        for _, line in ipairs(vim.split(text, "\n")) do
          table.insert(lines, line)
        end
      end
    elseif part.type == 'toolRequest' then
      if has_content then
        table.insert(lines, "")
      end
      M._format_tool(lines, part)
      has_content = true
    end
  end

  if has_content then
    table.insert(lines, "")
  end

  return has_content and lines or nil
end

function M._format_context(lines, type, value)
  if not type or not value then return end

  -- escape new lines
  value = value:gsub("\n", "\\n")

  local formatted_action = ' **' .. type .. '** ` ' .. value .. ' `'
  table.insert(lines, formatted_action)
end

function M._format_tool(lines, part)
  local tool = part.toolCall.value
  if not tool then return end

  if tool.name == 'developer__shell' then
    M._format_context(lines, '🚀 run', tool.arguments.command)
  elseif tool.name == 'developer__text_editor' then
    local path = tool.arguments.path
    local file_name = vim.fn.fnamemodify(path, ":t")

    if tool.arguments.command == 'str_replace' or tool.arguments.command == 'write' then
      M._format_context(lines, '✏ write to', file_name)
    elseif tool.arguments.command == 'view' then
      M._format_context(lines, '👀 view', file_name)
    else
      M._format_context(lines, '✨ command', tool.arguments.command)
    end
  else
    M._format_context(lines, '🔧 tool', tool.name)
  end
end

return M

local M = {}
local state = require("goose.state")
local context = require("goose.context")
local session = require("goose.session")
local ui = require("goose.ui.ui")
local job = require('goose.job')

function M.select_session()
  local all_sessions = session.get_all_workspace_sessions()
  local filtered_sessions = vim.tbl_filter(function(s)
    return s.description ~= '' and s ~= nil
  end, all_sessions)

  ui.select_session(filtered_sessions, function(selected_session)
    if not selected_session then return end
    state.active_session = selected_session
    if state.windows then
      ui.render_output()
      ui.scroll_to_bottom()
    else
      M.open()
    end
  end)
end

function M.open(opts)
  opts = opts or { focus = "input", new_session = false }

  if not M.goose_ok() then return end

  local are_windows_closed = state.windows == nil

  if are_windows_closed then
    state.windows = ui.create_windows()
  end

  if opts.new_session then
    state.active_session = nil
    state.last_sent_context = nil
    ui.clear_output()
  else
    if not state.active_session then
      state.active_session = session.get_last_workspace_session()
    end

    if are_windows_closed or ui.is_output_empty() then
      ui.render_output()
      ui.scroll_to_bottom()
    end
  end

  if opts.focus == "input" then
    ui.focus_input({ restore_position = are_windows_closed })
  elseif opts.focus == "output" then
    ui.focus_output({ restore_position = are_windows_closed })
  end
end

function M.run(prompt, opts)
  if not M.goose_ok() then return false end
  M.before_run(opts)

  -- Add small delay to ensure stop is complete
  vim.defer_fn(function()
    job.execute(prompt,
      {
        on_start = function()
          M.after_run(prompt)
        end,
        on_output = function(output)
          -- Reload all modified file buffers
          vim.cmd('checktime')

          -- for new sessions, session data can only be retrieved after running the command, retrieve once
          if not state.active_session and state.new_session_name then
            state.active_session = session.get_by_name(state.new_session_name)
          end
        end,
        on_error = function(err)
          vim.notify(
            err,
            vim.log.levels.ERROR
          )

          ui.close_windows(state.windows)
        end,
        on_exit = function()
          state.goose_run_job = nil
          require('goose.review').check_cleanup_breakpoint()
        end
      }
    )
  end, 10)
end

function M.after_run(prompt)
  require('goose.review').set_breakpoint()
  context.unload_attachments()
  state.last_sent_context = vim.deepcopy(context.context)
  require('goose.history').write(prompt)

  if state.windows then
    ui.render_output()
  end
end

function M.before_run(opts)
  M.stop()

  opts = opts or {}

  M.open({
    new_session = opts.new_session or not state.active_session,
  })

  -- sync session workspace to current workspace if there is missmatch
  if state.active_session then
    local session_workspace = state.active_session.workspace
    local current_workspace = vim.fn.getcwd()

    if session_workspace ~= current_workspace then
      session.update_session_workspace(state.active_session.name, current_workspace)
      state.active_session.workspace = current_workspace
    end
  end
end

function M.add_file_to_context()
  local picker = require('goose.ui.file_picker')
  require('goose.ui.mention').mention(function(mention_cb)
    picker.pick(function(file)
      mention_cb(file.name)
      context.add_file(file.path)
    end)
  end)
end

function M.configure_provider()
  local info_mod = require("goose.info")
  require("goose.provider").select(function(selection)
    if not selection then return end

    info_mod.set_config_value(info_mod.GOOSE_INFO.PROVIDER, selection.provider)
    info_mod.set_config_value(info_mod.GOOSE_INFO.MODEL, selection.model)

    if state.windows then
      require('goose.ui.topbar').render()
    else
      vim.notify("Changed provider to " .. selection.display, vim.log.levels.INFO)
    end
  end)
end

function M.stop()
  if (state.goose_run_job) then job.stop(state.goose_run_job) end
  state.goose_run_job = nil
  if state.windows then
    ui.stop_render_output()
    ui.render_output()
    ui.write_to_input({})
    require('goose.history').index = nil
  end
end

function M.goose_ok()
  if vim.fn.executable('goose') == 0 then
    vim.notify(
      "goose command not found - please install and configure goose before using this plugin",
      vim.log.levels.ERROR
    )
    return false
  end
  return true
end

return M

local api = require("goose.api")

local M = {}

-- Binds a keymap config with its api fn
-- Name of api fn & keymap global config should always be the same
function M.setup(keymap)
  local cmds = api.commands
  local global = keymap.global

  for key, mapping in pairs(global) do
    if type(mapping) == "string" then
      vim.keymap.set(
        { 'n', 'v' },
        mapping,
        function() api[key]() end,
        { silent = false, desc = cmds[key] and cmds[key].desc }
      )
    end
  end
end

return M

local M = {}

function M.get_all_sessions()
  local handle = io.popen('goose session list --format json')
  if not handle then return nil end

  local result = handle:read("*a")
  handle:close()

  local success, sessions = pcall(vim.fn.json_decode, result)
  if not success or not sessions or next(sessions) == nil then return nil end

  return vim.tbl_map(function(session)
    return {
      workspace = session.metadata.working_dir,
      description = session.metadata.description,
      message_count = session.metadata.message_count,
      tokens = session.metadata.total_tokens,
      modified = session.modified,
      name = session.id,
      path = session.path
    }
  end, sessions)
end

function M.get_all_workspace_sessions()
  local sessions = M.get_all_sessions()
  if not sessions then return nil end

  local workspace = vim.fn.getcwd()
  sessions = vim.tbl_filter(function(session)
    return session.workspace == workspace
  end, sessions)

  table.sort(sessions, function(a, b)
    return a.modified > b.modified
  end)

  return sessions
end

function M.get_last_workspace_session()
  local sessions = M.get_all_workspace_sessions()
  if not sessions then return nil end
  return sessions[1]
end

function M.get_by_name(name)
  local sessions = M.get_all_sessions()
  if not sessions then return nil end

  for _, session in ipairs(sessions) do
    if session.name == name then
      return session
    end
  end

  return nil
end

function M.update_session_workspace(session_name, workspace_path)
  local session = M.get_by_name(session_name)
  if not session then return false end

  local file = io.open(session.path, "r")
  if not file then return false end

  local first_line = file:read("*line")
  local rest = file:read("*all")
  file:close()

  -- Parse and update metadata
  local success, metadata = pcall(vim.fn.json_decode, first_line)
  if not success then return false end

  metadata.working_dir = workspace_path

  -- Write back: metadata line + rest of the file
  file = io.open(session.path, "w")
  if not file then return false end

  file:write(vim.fn.json_encode(metadata) .. "\n")
  if rest and rest ~= "" then
    file:write(rest)
  end
  file:close()

  return true
end

return M

-- Template rendering functionality

local M = {}

local Renderer = {}

function Renderer.escape(data)
  return tostring(data or ''):gsub("[\">/<'&]", {
    ["&"] = "&",
    ["<"] = "<",
    [">"] = ">",
    ['"'] = """,
    ["'"] = "'",
    ["/"] = "/"
  })
end

function Renderer.render(tpl, args)
  tpl = tpl:gsub("\n", "\\n")

  local compiled = load(Renderer.parse(tpl))()

  local buffer = {}
  local function exec(data)
    if type(data) == "function" then
      local args = args or {}
      setmetatable(args, { __index = _G })
      load(string.dump(data), nil, nil, args)(exec)
    else
      table.insert(buffer, tostring(data or ''))
    end
  end
  exec(compiled)

  -- First replace all escaped newlines with actual newlines
  local result = table.concat(buffer, ''):gsub("\\n", "\n")
  -- Then reduce multiple consecutive newlines to a single newline
  result = result:gsub("\n\n+", "\n")
  return vim.trim(result)
end

function Renderer.parse(tpl)
  local str =
      "return function(_)" ..
      "function __(...)" ..
      "_(require('template').escape(...))" ..
      "end " ..
      "_[=[" ..
      tpl:
      gsub("[][]=[][]", ']=]_"%1"_[=['):
      gsub("<%%=", "]=]_("):
      gsub("<%%", "]=]__("):
      gsub("%%>", ")_[=["):
      gsub("<%?", "]=] "):
      gsub("%?>", " _[=[") ..
      "]=] " ..
      "end"

  return str
end

-- Find the plugin root directory
local function get_plugin_root()
  local path = debug.getinfo(1, "S").source:sub(2)
  local lua_dir = vim.fn.fnamemodify(path, ":h:h")
  return vim.fn.fnamemodify(lua_dir, ":h") -- Go up one more level
end

-- Read the Jinja template file
local function read_template(template_path)
  local file = io.open(template_path, "r")
  if not file then
    error("Failed to read template file: " .. template_path)
    return nil
  end

  local content = file:read("*all")
  file:close()
  return content
end

function M.cleanup_indentation(template)
  local res = vim.split(template, "\n")
  for i, line in ipairs(res) do
    res[i] = line:gsub("^%s+", "")
  end
  return table.concat(res, "\n")
end

function M.render_template(template_vars)
  local plugin_root = get_plugin_root()
  local template_path = plugin_root .. "/template/prompt.tpl"

  local template = read_template(template_path)
  if not template then return nil end

  template = M.cleanup_indentation(template)

  return Renderer.render(template, template_vars)
end

function M.extract_tag(tag, text)
  local start_tag = "<" .. tag .. ">"
  local end_tag = "</" .. tag .. ">"

  -- Use pattern matching to find the content between the tags
  -- Make search start_tag and end_tag more robust with pattern escaping
  local pattern = vim.pesc(start_tag) .. "(.-)" .. vim.pesc(end_tag)
  local content = text:match(pattern)

  if content then
    return vim.trim(content)
  end

  -- Fallback to the original method if pattern matching fails
  local query_start = text:find(start_tag)
  local query_end = text:find(end_tag)

  if query_start and query_end then
    -- Extract and trim the content between the tags
    local query_content = text:sub(query_start + #start_tag, query_end - 1)
    return vim.trim(query_content)
  end

  return nil
end

return M

local M = {}

local state = require("goose.state")

local LABELS = {
  NEW_SESSION_TITLE = "New session",
}

local function format_model_info()
  local info = require("goose.info").parse_goose_info()
  local config = require("goose.config").get()
  local parts = {}

  if config.ui.display_model then
    local model = info.goose_model and (info.goose_model:match("[^/]+$") or info.goose_model) or ""
    if model ~= "" then
      table.insert(parts, model)
    end
  end

  if config.ui.display_goose_mode then
    local mode = info.goose_mode
    if mode then
      table.insert(parts, "[" .. mode .. "]")
    end
  end

  return table.concat(parts, " ")
end

local function create_winbar_text(description, model_info, win_width)
  local available_width = win_width - 2 -- 2 padding spaces

  -- If total length exceeds available width, truncate description
  if #description + 1 + #model_info > available_width then
    local space_for_desc = available_width - #model_info - 4 -- -4 for "... "
    description = description:sub(1, space_for_desc) .. "... "
  end

  local padding = string.rep(" ", available_width - #description - #model_info)
  return string.format(" %s%s%s ", description, padding, model_info)
end

local function update_winbar_highlights(win_id)
  local current = vim.api.nvim_win_get_option(win_id, 'winhighlight')
  local parts = vim.split(current, ",")

  -- Remove any existing winbar highlights
  parts = vim.tbl_filter(function(part)
    return not part:match("^WinBar:") and not part:match("^WinBarNC:")
  end, parts)

  if not vim.tbl_contains(parts, "Normal:GooseNormal") then
    table.insert(parts, "Normal:GooseNormal")
  end

  table.insert(parts, "WinBar:GooseSessionDescription")
  table.insert(parts, "WinBarNC:GooseSessionDescription")

  vim.api.nvim_win_set_option(win_id, 'winhighlight', table.concat(parts, ","))
end

local function get_session_desc()
  local session_desc = LABELS.NEW_SESSION_TITLE

  if state.active_session then
    local session = require('goose.session').get_by_name(state.active_session.name)
    if session and session.description ~= "" then
      session_desc = session.description
    end
  end

  return session_desc
end

function M.render()
  local win = state.windows.output_win

  vim.schedule(function()
    vim.wo[win].winbar = create_winbar_text(
      get_session_desc(),
      format_model_info(),
      vim.api.nvim_win_get_width(win)
    )

    update_winbar_highlights(win)
  end)
end

return M

local M = {}

local function get_best_picker()
  local config = require("goose.config")

  local prefered_picker =  config.get('prefered_picker')
  if prefered_picker and prefered_picker ~= "" then
    return prefered_picker
  end

  if pcall(require, "telescope") then return "telescope" end
  if pcall(require, "fzf-lua") then return "fzf" end
  if pcall(require, "mini.pick") then return "mini.pick" end
  if pcall(require, "snacks") then return "snacks" end
  return nil
end

local function format_file(path)
  -- when path is something like: file.extension dir1/dir2 -> format to dir1/dir2/file.extension
  local file_match, path_match = path:match("^(.-)\t(.-)$")
  if file_match and path_match then
    path = path_match .. "/" .. file_match
  end

  return {
    name = vim.fn.fnamemodify(path, ":t"),
    path = path
  }
end

local function telescope_ui(callback)
  local builtin = require("telescope.builtin")
  local actions = require("telescope.actions")
  local action_state = require("telescope.actions.state")

  builtin.find_files({
    attach_mappings = function(prompt_bufnr, map)
      actions.select_default:replace(function()
        local selection = action_state.get_selected_entry()
        actions.close(prompt_bufnr)

        if selection and callback then
          callback(selection.value)
        end
      end)
      return true
    end
  })
end

local function fzf_ui(callback)
  local fzf_lua = require("fzf-lua")

  fzf_lua.files({
    actions = {
      ["default"] = function(selected)
        if not selected or #selected == 0 then return end

        local file = fzf_lua.path.entry_to_file(selected[1])

        if file and file.path and callback then
          callback(file.path)
        end
      end,
    },
  })
end

local function mini_pick_ui(callback)
  local mini_pick = require("mini.pick")
  mini_pick.builtin.files(nil, {
    source = {
      choose = function(selected)
        if selected and callback then
          callback(selected)
        end
        return false
      end,
    },
  })
end

local function snacks_picker_ui(callback)
  local Snacks = require("snacks")

  Snacks.picker.files({
    confirm = function(picker)
      local items = picker:selected({ fallback = true })
      picker:close()

      if items and items[1] and callback then
        callback(items[1].file)
      end
    end,
  })
end

function M.pick(callback)
  local picker = get_best_picker()

  if not picker then
    return
  end

  local wrapped_callback = function(selected_file)
    local file_name = format_file(selected_file)
    callback(file_name)
  end

  vim.schedule(function()
    if picker == "telescope" then
      telescope_ui(wrapped_callback)
    elseif picker == "fzf" then
      fzf_ui(wrapped_callback)
    elseif picker == "mini.pick" then
      mini_pick_ui(wrapped_callback)
    elseif picker == "snacks" then
      snacks_picker_ui(wrapped_callback)
    else
      callback(nil)
    end
  end)
end

return M

-- goose.nvim/lua/goose/job.lua
-- Contains goose job execution logic

local context = require("goose.context")
local state = require("goose.state")
local Job = require('plenary.job')
local util = require("goose.util")

local M = {}

function M.build_args(prompt)
  if not prompt then return nil end
  local message = context.format_message(prompt)
  local args = { "run", "--text", message }

  if state.active_session then
    table.insert(args, "--name")
    table.insert(args, state.active_session.name)
    table.insert(args, "--resume")
  else
    local session_name = util.uid()
    state.new_session_name = session_name
    table.insert(args, "--name")
    table.insert(args, session_name)
  end

  return args
end

function M.execute(prompt, handlers)
  if not prompt then
    return nil
  end

  local args = M.build_args(prompt)

  state.goose_run_job = Job:new({
    command = 'goose',
    args = args,
    on_start = function()
      vim.schedule(function()
        handlers.on_start()
      end)
    end,
    on_stdout = function(_, out)
      if out then
        vim.schedule(function()
          handlers.on_output(out)
        end)
      end
    end,
    on_stderr = function(_, err)
      if err then
        vim.schedule(function()
          handlers.on_error(err)
        end)
      end
    end,
    on_exit = function()
      vim.schedule(function()
        handlers.on_exit()
      end)
    end
  })

  state.goose_run_job:start()
end

function M.stop(job)
  if job then
    pcall(function()
      vim.uv.process_kill(job.handle)
      job:shutdown()
    end)
  end
end

return M

local M = {}

function M.setup()
  vim.api.nvim_set_hl(0, 'GooseBorder', { fg = '#616161' })
  vim.api.nvim_set_hl(0, 'GooseBackground', { link = "Normal" })
  vim.api.nvim_set_hl(0, 'GooseSessionDescription', { link = "Comment" })
  vim.api.nvim_set_hl(0, "GooseMention", { link = "Special" })
end

return M

local M = {}
local util = require('goose.util')

M.GOOSE_INFO = {
  MODEL = "GOOSE_MODEL",
  PROVIDER = "GOOSE_PROVIDER",
  MODE = "GOOSE_MODE",
  CONFIG = "Config file"
}

M.GOOSE_MODE = {
  CHAT = "chat",
  AUTO = "auto"
}

-- Parse the output of `goose info -v` command
function M.parse_goose_info()
  local result = {}

  local handle = io.popen("goose info -v")
  if not handle then
    return result
  end

  local output = handle:read("*a")
  handle:close()

  local model = output:match(M.GOOSE_INFO.MODEL .. ":%s*(.-)\n") or output:match(M.GOOSE_INFO.MODEL .. ":%s*(.-)$")
  if model then
    result.goose_model = vim.trim(model)
  end

  local provider = output:match(M.GOOSE_INFO.PROVIDER .. ":%s*(.-)\n") or output:match(M.GOOSE_INFO.PROVIDER .. ":%s*(.-)$")
  if provider then
    result.goose_provider = vim.trim(provider)
  end

  local mode = output:match(M.GOOSE_INFO.MODE .. ":%s*(.-)\n") or output:match(M.GOOSE_INFO.MODE .. ":%s*(.-)$")
  if mode then
    result.goose_mode = vim.trim(mode)
  end

  local config_file = output:match(M.GOOSE_INFO.CONFIG .. ":%s*(.-)\n") or
      output:match(M.GOOSE_INFO.CONFIG .. ":%s*(.-)$")
  if config_file then
    result.config_file = vim.trim(config_file)
  end

  return result
end

-- Set a value in the goose config file
function M.set_config_value(key, value)
  local info = M.parse_goose_info()
  if not info.config_file then
    return false, "Could not find config file path"
  end

  return util.set_yaml_value(info.config_file, key, value)
end

return M

local M = {}

function M.select(cb)
  local config = require("goose.config")

  -- Create a flat list of all provider/model combinations
  local model_options = {}

  for provider, models in pairs(config.get("providers")) do
    for _, model in ipairs(models) do
      table.insert(model_options, {
        provider = provider,
        model = model,
        display = provider .. ": " .. model
      })
    end
  end

  if #model_options == 0 then
    vim.notify("No models configured in providers", vim.log.levels.ERROR)
    return
  end

  vim.ui.select(model_options, {
    prompt = "Select model:",
    format_item = function(item)
      return item.display
    end
  }, function(selection)
    cb(selection)
  end)
end

return M

-- Plugin loader for goose.nvim
if vim.g.loaded_goose_nvim == 1 then
  return
end
vim.g.loaded_goose_nvim = 1

local M = {}
local config = require("goose.config")
local keymap = require("goose.keymap")
local api = require("goose.api")

function M.setup(opts)
  config.setup(opts)
  api.setup()
  keymap.setup(config.get("keymap"))
end

return M

local M = {}

-- ui
M.windows = nil
M.input_content = {}
M.last_focused_goose_window = nil
M.last_input_window_position = nil
M.last_output_window_position = nil
M.last_code_win_before_goose = nil

-- context
M.last_sent_context = nil

-- session
M.active_session = nil
M.new_session_name = nil

-- job
M.goose_run_job = nil

return M

function () -- Check if using arrow keys end

This code segment functions to: Manage navigation behavior between user input history and text boundaries by conditionally triggering history functions or cursor movement based on cursor position relative to document edges when directional keys are pressed.

```lua
[TASK_BEGIN]
function()
  if (key == "Up" and direction == "prev") then
    local cursor = vim.api.nvim_win_get_cursor(0)
    local row, col = cursor[1], cursor[2]
    -- Check if at first line and beginning of line
    if row == 1 and col == 0 then
      prev_history_fn()
      return true
    end

  elseif (key == "Down" and direction == "next") then
    local cursor = vim.api.nvim_win_get_cursor(0)
    local row, col = cursor[1], cursor[2]
    local current_line = vim.fn.getline(row)
    -- Check if at last line and end of line
    if row == vim.fn.line('$') and col >= #current_line then
      next_history_fn()
      return true
    end
  end
  return false
end
[TASK_END]
```

function()
  if (key == "Up" and direction == "prev") then
    local cursor = vim.api.nvim_win_get_cursor(0)
    local row, col = cursor[1], cursor[2]
    if row == 1 and col == 0 then
      prev_history_fn()
      return true
    end
  elseif (key == "Down" and direction == "next") then
    local cursor = vim.api.nvim_win_get_cursor(0)
    local row, col = cursor[1], cursor[2]
    local current_line = vim.fn.getline(row)
    if row == vim.fn.line('$') and col >= #current_line then
      next_history_fn()
      return true
    end
  end
  return false
end