Repository: neovim/neovim
Pull request #27855: feat(ui): ext_cmdline/messages for the TUI
Author: login: luukvbaal
Labels: name: ui
name: lua
description: stdlib
name: ui-extensibility
description: UI extensibility, events, protocol, externalized UI
name: messages
description: UI messages, log messages, dialogs, progress, errors, warnings, feedback
name: cmdline-mode
description: command line, also cmdwin
Created: 2024-03-14T10:10:17Z | Merged: 2025-05-02T09:02:02Z

Problem:  We have an unmaintained Vimscript parser and cmdline
highlighting mechanism, with which it is hard to leverage the
treesitter highlighter. Long messages result in a hit-enter-prompt.

Solution: Implement a vim.ui_attach() UI, that replaces the message
grid (orphaning some 3000+ LOC core C code). Introduce an experimental
vim._extui module, because removing the message grid at the same time is
too risky. The new UI leverages the bundled treesitter highlighter and
parser for Vimscript, as well as the matchparen plugin, to highlight the
cmdline. Messages are truncated in the cmdline area, or placed in a
floating message box in the bottom right corner. Special ("list_cmd")
messages and the message history are shown in a, "more prompt" (now a
fully interactive regular window). Various default UI elements ('showcmd',
'ruler') are also placed in the cmdline area, as virtual text.

`require('vim._extui').enable({})` enables the experimental UI.
`{ msg.pos = 'box' }` or `:set cmdheight=0` enables the message
box variant.

Followup:
  - Come to a consensus for how best to represent messages (by default).
  - Start removing message grid when this is deemed a successful replacement.
    When that is finished, make this new UI the default and update a lot of tests.

---
Given the following highlighted cmdline:
![image](https://github.com/user-attachments/assets/28d8995d-a5e6-4f5a-a1c7-5df9d0e890b8)

Pressing enter with `cfg.msg.pos = 'cmd'` yields a message in the cmdline with duplicate and overflow indicators. User can press <kbd>g<</kbd> to open the more-prompt, now in a (fully interactive) regular window.
![image](https://github.com/user-attachments/assets/ed52d935-e854-477f-8cb8-caf23e2ba08e)

`cfg.msg.pos = 'box'` yields:
![image](https://github.com/user-attachments/assets/56f1c645-de1a-4a02-8bbc-bbdaa94f4f09)


Diff:
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
index 06844982b5fdf4..ecf2246de48f57 100644
--- a/runtime/doc/lua.txt
+++ b/runtime/doc/lua.txt
@@ -2663,6 +2663,28 @@ vim.ui.select({items}, {opts}, {on_choice})                  *vim.ui.select()*
                      within `items`. `nil` if the user aborted the dialog.
 
 
+==============================================================================
+Lua module: vim._extui                                            *vim._extui*
+
+WARNING: This is an experimental interface intended to replace the message
+grid in the TUI.
+
+To enable the experimental UI (default opts shown): >lua
+    require('vim._extui').enable({
+     enable = true, -- Whether to enable or disable the UI.
+     msg = { -- Options related to the message module.
+       ---@type 'box'|'cmd' Type of window used to place messages, either in the
+       ---cmdline or in a separate message box window with ephemeral messages.
+       pos = 'cmd',
+       box = { -- Options related to the message box window.
+         timeout = 4000, -- Time a message is visible.
+       },
+     },
+    })
+<
+
+
+
 ==============================================================================
 Lua module: vim.filetype                                        *vim.filetype*
 
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
index a8362ccdb07d20..b319636f39aade 100644
--- a/runtime/doc/news.txt
+++ b/runtime/doc/news.txt
@@ -186,6 +186,8 @@ UI
 
 • |:checkhealth| shows a summary in the header for every healthcheck.
 • |ui-multigrid| provides composition information and absolute coordinates.
+• `vim._extui` provides an experimental commandline and message UI intended to
+  replace the message grid in the TUI.
 
 VIMSCRIPT
 
diff --git a/runtime/lua/vim/_extui.lua b/runtime/lua/vim/_extui.lua
new file mode 100644
index 00000000000000..8f8ebfcd2b3d05
--- /dev/null
+++ b/runtime/lua/vim/_extui.lua
@@ -0,0 +1,129 @@
+--- @brief
+---
+---WARNING: This is an experimental interface intended to replace the message
+---grid in the TUI.
+---
+---To enable the experimental UI (default opts shown):
+---```lua
+---require('vim._extui').enable({
+---  enable = true, -- Whether to enable or disable the UI.
+---  msg = { -- Options related to the message module.
+---    ---@type 'box'|'cmd' Type of window used to place messages, either in the
+---    ---cmdline or in a separate message box window with ephemeral messages.
+---    pos = 'cmd',
+---    box = { -- Options related to the message box window.
+---      timeout = 4000, -- Time a message is visible.
+---    },
+---  },
+---})
+---```
+
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+---@nodoc
+function M.enable(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  -- The visibility and appearance of the cmdline and message box window is
+  -- dependent on some option values. Reconfigure windows when option value
+  -- has changed and after VimEnter when the user configured value is known.
+  local function check_opt(name, value)
+    if name == 'cmdheight' then
+      -- 'cmdheight' set; (un)hide cmdline window and set its height.
+      ext.cmdheight = value
+      ext.cfg.msg.pos = ext.cmdheight == 0 and 'box' or ext.cfg.msg.pos
+      local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+      api.nvim_win_set_config(ext.wins[ext.tab].cmd, cfg)
+    elseif name == 'termguicolors' then
+      -- 'termguicolors' toggled; add or remove border and set 'winblend' for box windows.
+      for _, tab in ipairs(api.nvim_list_tabpages()) do
+        api.nvim_win_set_config(ext.wins[tab].box, { border = value and 'none' or 'single' })
+        vim.wo[ext.wins[tab].box].winblend = value and 30 or 0
+      end
+    end
+  end
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      check_opt(ev.match, vim.v.option_new)
+      ext.msg.set_pos()
+    end,
+    desc = 'Set cmdline and message window dimensions for changed option values.',
+  })
+
+  api.nvim_create_autocmd({ 'VimEnter', 'VimResized' }, {
+    group = ext.augroup,
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.event == 'VimEnter' then
+        check_opt('cmdheight', vim.o.cmdheight)
+        check_opt('termguicolors', vim.o.termguicolors)
+      end
+      ext.msg.set_pos()
+    end,
+    desc = 'Set cmdline and message window dimensions after startup and shell resize.',
+  })
+
+  api.nvim_create_autocmd('WinEnter', {
+    callback = function()
+      local win = api.nvim_get_current_win()
+      if vim.tbl_contains(ext.wins[ext.tab] or {}, win) and api.nvim_win_get_config(win).hide then
+        vim.cmd.wincmd('p')
+      end
+    end,
+    desc = 'Make sure hidden extui window is never current.',
+  })
+end
+
+return M
diff --git a/runtime/lua/vim/_extui/cmdline.lua b/runtime/lua/vim/_extui/cmdline.lua
new file mode 100644
index 00000000000000..2210ecd8ba78e9
--- /dev/null
+++ b/runtime/lua/vim/_extui/cmdline.lua
@@ -0,0 +1,158 @@
+local ext = require('vim._extui.shared')
+local api, fn = vim.api, vim.fn
+---@class vim._extui.cmdline
+local M = {
+  highlighter = nil, ---@type vim.treesitter.highlighter?
+  indent = 0, -- Current indent for block event.
+  prompt = false, -- Whether a prompt is active; messages are placed in the 'prompt' window.
+  row = 0, -- Current row in the cmdline buffer, > 0 for block events.
+}
+
+--- Set the 'cmdheight' and cmdline window height. Reposition message windows.
+---
+---@param win integer Cmdline window in the current tabpage.
+---@param hide boolean Whether to hide or show the window.
+---@param height integer (Text)height of the cmdline window.
+local function win_config(win, hide, height)
+  if ext.cmdheight == 0 and api.nvim_win_get_config(win).hide ~= hide then
+    api.nvim_win_set_config(win, { hide = hide, height = not hide and height or nil })
+  elseif api.nvim_win_get_height(win) ~= height then
+    api.nvim_win_set_height(win, height)
+  end
+  if vim.o.cmdheight ~= height then
+    vim.cmd('noautocmd set cmdheight=' .. height)
+    ext.msg.set_pos()
+  end
+end
+
+local promptlen = 0 -- Current length of the prompt, stored for use in "cmdline_pos"
+--- Concatenate content chunks and set the text for the current row in the cmdline buffer.
+---
+---@alias CmdChunk [integer, string]
+---@alias CmdContent CmdChunk[]
+---@param content CmdContent
+---@param prompt string
+local function set_text(content, prompt)
+  promptlen = #prompt
+  for _, chunk in ipairs(content) do
+    prompt = prompt .. chunk[2]
+  end
+  api.nvim_buf_set_lines(ext.bufs.cmd, M.row, -1, false, { prompt .. ' ' })
+end
+
+--- Set the cmdline buffer text and cursor position.
+---
+---@param content CmdContent
+---@param pos integer
+---@param firstc string
+---@param prompt string
+---@param indent integer
+--@param level integer
+---@param hl_id integer
+function M.cmdline_show(content, pos, firstc, prompt, indent, _, hl_id)
+  M.indent, M.prompt = indent, #prompt > 0
+  -- Only enable TS highlighter for Ex commands (not search or filter commands).
+  M.highlighter.active[ext.bufs.cmd] = firstc == ':' and M.highlighter or nil
+  set_text(content, ('%s%s%s'):format(firstc, prompt, (' '):rep(indent)))
+  if promptlen > 0 and hl_id > 0 then
+    api.nvim_buf_set_extmark(ext.bufs.cmd, ext.ns, 0, 0, { hl_group = hl_id, end_col = promptlen })
+  end
+
+  local height = math.max(ext.cmdheight, api.nvim_win_text_height(ext.wins[ext.tab].cmd, {}).all)
+  win_config(ext.wins[ext.tab].cmd, false, height)
+  M.cmdline_pos(pos)
+
+  -- Clear message cmdline state; should not be shown during, and reset after cmdline.
+  if ext.cfg.msg.pos == 'cmd' and ext.msg.cmd.msg_row ~= -1 then
+    ext.msg.prev_msg, ext.msg.dupe, ext.msg.cmd.msg_row = '', 0, -1
+    api.nvim_buf_clear_namespace(ext.bufs.cmd, ext.ns, 0, -1)
+    ext.msg.virt.msg = { {}, {} }
+  end
+  ext.msg.virt.last = { {}, {}, {}, {} }
+end
+
+--- Insert special character at cursor position.
+---
+---@param c string
+---@param shift boolean
+--@param level integer
+function M.cmdline_special_char(c, shift)
+  api.nvim_win_call(ext.wins[ext.tab].cmd, function()
+    api.nvim_put({ c }, shift and '' or 'c', false, false)
+  end)
+end
+
+local curpos = { 0, 0 } -- Last drawn cursor position.
+--- Set the cmdline cursor position.
+---
+---@param pos integer
+--@param level integer
+function M.cmdline_pos(pos)
+  if curpos[1] ~= M.row + 1 or curpos[2] ~= promptlen + pos then
+    curpos[1], curpos[2] = M.row + 1, promptlen + pos
+    -- Add matchparen highlighting to non-prompt part of cmdline.
+    if pos > 0 and fn.exists('#matchparen') then
+      api.nvim_win_set_cursor(ext.wins[ext.tab].cmd, { curpos[1], curpos[2] - 1 })
+      vim.wo[ext.wins[ext.tab].cmd].eventignorewin = ''
+      fn.win_execute(ext.wins[ext.tab].cmd, 'doautocmd CursorMoved')
+      vim.wo[ext.wins[ext.tab].cmd].eventignorewin = 'all'
+    end
+    api.nvim_win_set_cursor(ext.wins[ext.tab].cmd, curpos)
+  end
+end
+
+--- Leaving the cmdline, restore 'cmdheight' and 'ruler'.
+---
+--@param level integer
+---@param abort boolean
+function M.cmdline_hide(_, abort)
+  if M.row > 0 then
+    return -- No need to hide when still in cmdline_block.
+  end
+
+  fn.clearmatches(ext.wins[ext.tab].cmd) -- Clear matchparen highlights.
+  if abort then
+    -- Clear cmd buffer for aborted command (non-abort is left visible).
+    api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
+  end
+
+  -- Avoid clearing prompt window when it is re-entered before the next event
+  -- loop iteration. E.g. when a non-choice confirm button is pressed.
+  if M.prompt then
+    vim.schedule(function()
+      if not M.prompt then
+        api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
+        api.nvim_win_set_config(ext.wins[ext.tab].prompt, { hide = true })
+      end
+    end)
+  end
+
+  M.prompt, curpos[1], curpos[2] = false, 0, 0
+  win_config(ext.wins[ext.tab].cmd, true, ext.cmdheight)
+end
+
+--- Set multi-line cmdline buffer text.
+---
+---@param lines CmdContent[]
+function M.cmdline_block_show(lines)
+  for _, content in ipairs(lines) do
+    set_text(content, ':')
+    M.row = M.row + 1
+  end
+end
+
+--- Append line to a multiline cmdline.
+---
+---@param line CmdContent
+function M.cmdline_block_append(line)
+  set_text(line, ':')
+  M.row = M.row + 1
+end
+
+--- Clear cmdline buffer and leave the cmdline.
+function M.cmdline_block_hide()
+  M.cmdline_hide(nil, true)
+  M.row = 0
+end
+
+return M
diff --git a/runtime/lua/vim/_extui/messages.lua b/runtime/lua/vim/_extui/messages.lua
new file mode 100644
index 00000000000000..ad8511ac6d4572
--- /dev/null
+++ b/runtime/lua/vim/_extui/messages.lua
@@ -0,0 +1,410 @@
+local api, fn, o = vim.api, vim.fn, vim.o
+local ext = require('vim._extui.shared')
+
+---@class vim._extui.messages
+local M = {
+  -- Message box window. Used for regular messages with 'cmdheight' == 0 or,
+  -- cfg.msg.pos == 'box'. Also used for verbose messages regardless of
+  -- cfg.msg.pos. Automatically resizes to the text dimensions up to a point,
+  -- at which point only the most recent messages will fit and be shown.
+  -- A timer is started for each message whose callback will remove the message
+  -- from the window again.
+  box = {
+    count = 0, -- Number of messages currently in the message window.
+    width = 1, -- Current width of the message window.
+    timer = nil, ---@type uv.uv_timer_t Timer that removes the most recent message.
+  },
+  -- Cmdline message window. Used for regular messages with 'cmdheight' > 0.
+  -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text.
+  -- Messages that don't fit the 'cmdheight' are cut off and virt_text is added
+  -- to indicate the number of spilled lines and repeated messages.
+  cmd = {
+    count = 0, -- Number of messages currently in the message window.
+    lines = 0, -- Number of lines in cmdline buffer (including wrapped lines).
+    msg_row = -1, -- Last row of message to distinguish for placing virt_text.
+    last_col = o.columns, -- Crop text to start column of 'last' virt_text.
+    last_emsg = 0, -- Time an error was printed that should not be overwritten.
+  },
+  dupe = 0, -- Number of times message is repeated.
+  prev_msg = '', -- Concatenated content of the previous message.
+  virt = { -- Stored virt_text state.
+    last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row.
+    msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in message window.
+    idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 },
+    ids = {}, ---@type { ['last'|'msg']: integer? } Table of mark IDs.
+    delayed = false, -- Whether placement of 'last' virt_text is delayed.
+  },
+}
+
+--- Start a timer whose callback will remove the message from the message window.
+---
+---@param buf integer Buffer the message was written to.
+---@param len integer Number of rows that should be removed.
+function M.box:start_timer(buf, len)
+  self.timer = vim.defer_fn(function()
+    if self.count == 0 or not api.nvim_buf_is_valid(buf) then
+      return -- Messages moved to split or buffer was closed.
+    end
+    api.nvim_buf_set_lines(buf, 0, len, false, {})
+    self.count = self.count - 1
+    -- Resize or hide message box for removed message.
+    if self.count > 0 then
+      M.set_pos('box')
+    else
+      self.width = 1
+      M.prev_msg = ext.cfg.msg.pos == 'box' and '' or M.prev_msg
+      api.nvim_buf_clear_namespace(ext.bufs.box, -1, 0, -1)
+      if api.nvim_win_is_valid(ext.wins[ext.tab].box) then
+        api.nvim_win_set_config(ext.wins[ext.tab].box, { hide = true })
+      end
+    end
+  end, ext.cfg.msg.box.timeout)
+end
+
+--- Place or delete a virtual text mark in the cmdline or message window.
+---
+---@param type 'last'|'msg'
+local function set_virttext(type)
+  if type == 'last' and ext.cmdheight == 0 or M.virt.delayed then
+    return
+  end
+
+  -- Concatenate the components of M.virt[type] and calculate the concatenated width.
+  local width, chunks = 0, {} ---@type integer, [string, integer|string][]
+  local contents = type == 'last' and M.virt.last or M.virt.msg
+  for _, content in ipairs(contents) do
+    for _, chunk in ipairs(content) do
+      chunks[#chunks + 1] = { chunk[2], chunk[3] }
+      width = width + api.nvim_strwidth(chunk[2])
+    end
+  end
+
+  if M.virt.ids[type] and #chunks == 0 then
+    api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, M.virt.ids[type])
+    M.virt.ids[type] = nil
+    M.cmd.last_col = type == 'last' and o.columns or M.cmd.last_col
+  elseif #chunks > 0 then
+    local tar = type == 'msg' and ext.cfg.msg.pos or 'cmd'
+    local win = ext.wins[ext.tab][tar]
+    local max = api.nvim_win_get_height(win)
+    local erow = tar == 'cmd' and M.cmd.msg_row or nil
+    local srow = tar == 'box' and fn.line('w0', ext.wins[ext.tab].box) - 1 or nil
+    local h = api.nvim_win_text_height(win, { start_row = srow, end_row = erow, max_height = max })
+    local row = h.end_row ---@type integer
+    local col = fn.virtcol2col(win, row + 1, h.end_vcol)
+    local scol = fn.screenpos(win, row + 1, col).col ---@type integer
+
+    if type == 'msg' then
+      -- Calculate at which column to place the virt_text such that it is at the end
+      -- of the last visible message line, overlapping the message text if necessary,
+      -- but not overlapping the 'last' virt_text.
+      local offset = tar ~= 'box' and 0
+        or api.nvim_win_get_position(win)[2] + (api.nvim_win_get_config(win).border and 1 or 0)
+
+      M.box.width = math.min(o.columns, scol - offset + width)
+      if tar == 'box' and api.nvim_win_get_width(win) < M.box.width then
+        api.nvim_win_set_width(win, M.box.width)
+      end
+
+      local mwidth = tar == 'box' and M.box.width or M.cmd.last_col
+      if scol - offset + width > mwidth then
+        col = fn.virtcol2col(win, row + 1, h.end_vcol - (scol - offset + width - mwidth))
+      end
+
+      -- Give virt_text the same highlight as the message tail.
+      local hl = api.nvim_buf_get_extmarks(ext.bufs[tar], ext.ns, { row, col }, { row, col }, {
+        details = true,
+        overlap = true,
+        type = 'highlight',
+      })
+      chunks[1][2] = hl[1] and hl[1][4].hl_group
+    else
+      local mode = #M.virt.last[M.virt.idx.mode]
+      local pad = o.columns - width ---@type integer
+      local newlines = math.max(0, ext.cmdheight - h.all)
+      row = row + newlines
+      M.cmd.last_col = newlines > 0 and o.columns or mode > 0 and 0 or o.columns - width
+
+      if newlines > 0 then
+        -- Add empty lines to place virt_text on the last screen row.
+        api.nvim_buf_set_lines(ext.bufs.cmd, -1, -1, false, fn['repeat']({ '' }, newlines))
+        col = 0
+      else
+        if scol > M.cmd.last_col then
+          -- Give the user some time to read an important message.
+          if os.time() - M.cmd.last_emsg < 2 then
+            M.virt.delayed = true
+            vim.defer_fn(function()
+              M.virt.delayed = false
+              set_virttext('last')
+            end, 2000)
+            return
+          end
+
+          -- Crop text on last screen row and find byte offset to place mark at.
+          local vcol = h.end_vcol - (scol - M.cmd.last_col) ---@type integer
+          col = vcol <= 0 and 0 or fn.virtcol2col(win, row + 1, vcol)
+          M.prev_msg = mode > 0 and '' or M.prev_msg
+          M.virt.msg = mode > 0 and { {}, {} } or M.virt.msg
+          api.nvim_buf_set_text(ext.bufs.cmd, row, col, row, -1, { mode > 0 and ' ' or '' })
+        end
+
+        pad = pad - ((mode > 0 or col == 0) and 0 or math.min(M.cmd.last_col, scol))
+      end
+      table.insert(chunks, mode + 1, { (' '):rep(pad) })
+      set_virttext('msg') -- Readjust to new M.cmd.last_col or clear for mode.
+    end
+
+    M.virt.ids[type] = api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, row, col, {
+      virt_text = chunks,
+      virt_text_pos = 'overlay',
+      right_gravity = false,
+      undo_restore = false,
+      invalidate = true,
+      id = M.virt.ids[type],
+      priority = type == 'msg' and 2 or 1,
+    })
+  end
+end
+
+---@param tar 'box'|'cmd'|'more'|'prompt'
+---@param content MsgContent
+---@param replace_last boolean
+---@param more boolean? If true, route messages that exceed the target window to more window.
+function M.show_msg(tar, content, replace_last, more)
+  local msg, restart, dupe = '', false, 0
+  if M[tar] then -- tar == 'box'|'cmd'
+    if tar == ext.cfg.msg.pos then
+      -- Save the concatenated message to identify repeated messages.
+      for _, chunk in ipairs(content) do
+        msg = msg .. chunk[2]
+      end
+
+      -- Check if messages that should be sent to more prompt exceeds maximum newlines.
+      local max = (tar == 'cmd' and ext.cmdheight or math.ceil(o.lines * 0.5))
+      if more and select(2, msg:gsub('\n', '')) > max then
+        M.msg_history_show({ { 'spill', content } })
+        return
+      end
+
+      M.dupe = (msg == M.prev_msg and M.dupe + 1 or 0)
+      dupe = M.dupe
+      M.prev_msg = msg
+    end
+
+    restart = M[tar].count > 0 and (replace_last or dupe > 0)
+    -- Reset indicators the next event loop iteration.
+    if M.cmd.count == 0 and tar == 'cmd' then
+      vim.schedule(function()
+        M.cmd.lines, M.cmd.count = 0, 0
+      end)
+    end
+    M[tar].count = M[tar].count + ((restart or msg == '\n') and 0 or 1)
+  end
+
+  -- Filter out empty newline messages. TODO: don't emit them.
+  if msg == '\n' then
+    return
+  end
+
+  ---@type integer Start row after last line in the target buffer, unless
+  ---this is the first message, or in case of a repeated or replaced message.
+  local row = M[tar] and M[tar].count <= 1 and (tar == 'cmd' and ext.cmd.row or 0)
+    or api.nvim_buf_line_count(ext.bufs[tar]) - ((replace_last or dupe > 0) and 1 or 0)
+  local start_row, col = row, 0
+  local lines, marks = {}, {} ---@type string[], [integer, integer, vim.api.keyset.set_extmark][]
+
+  -- Accumulate to be inserted and highlighted message chunks for a non-repeated message.
+  for i, chunk in ipairs(dupe > 0 and tar == ext.cfg.msg.pos and {} or content) do
+    local srow, scol = row, col
+    -- Split at newline and concatenate first and last message chunks.
+    for str in (chunk[2] .. '\0'):gmatch('.-[\n%z]') do
+      local idx = i > 1 and row == srow and 0 or 1
+      lines[#lines + idx] = idx > 0 and str:sub(1, -2) or lines[#lines] .. str:sub(1, -2)
+      col = #lines[#lines]
+      row = row + (str:sub(-1) == '\0' and 0 or 1)
+      if tar == 'box' then
+        M.box.width = math.max(M.box.width, api.nvim_strwidth(lines[#lines]))
+      end
+    end
+    if chunk[3] > 0 then
+      marks[#marks + 1] = { srow, scol, { end_col = col, end_row = row, hl_group = chunk[3] } }
+    end
+  end
+
+  if tar ~= ext.cfg.msg.pos or dupe == 0 then
+    -- Add highlighted message to buffer.
+    api.nvim_buf_set_lines(ext.bufs[tar], start_row, -1, false, lines)
+    for _, mark in ipairs(marks) do
+      api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, mark[1], mark[2], mark[3])
+    end
+    M.virt.msg[M.virt.idx.dupe][1] = dupe ~= 0 and M.virt.msg[M.virt.idx.dupe][1] or nil
+  else
+    -- Place (x) indicator for repeated messages. Mainly to mitigate unnecessary
+    -- resizing of the message box window, but also placed in the cmdline.
+    M.virt.msg[M.virt.idx.dupe][1] = { 0, ('(%d)'):format(dupe) }
+  end
+
+  if tar == 'box' then
+    api.nvim_win_set_width(ext.wins[ext.tab].box, M.box.width)
+    M.set_pos('box')
+    if restart then
+      M.box.timer:stop()
+      M.box.timer:set_repeat(4000)
+      M.box.timer:again()
+    else
+      M.box:start_timer(ext.bufs.box, row - start_row + 1)
+    end
+  elseif tar == 'cmd' and dupe == 0 then
+    fn.clearmatches(ext.wins[ext.tab].cmd) -- Clear matchparen highlights.
+    if ext.cmd.row > 0 then
+      -- In block mode the cmdheight is already dynamic, so just print the full message
+      -- regardless of height. Spoof cmdline_show to put cmdline below message.
+      ext.cmd.row = ext.cmd.row + 1 + row - start_row
+      ext.cmd.cmdline_show({}, 0, ':', '', ext.cmd.indent, 0, 0)
+      api.nvim__redraw({ flush = true, cursor = true, win = ext.wins[ext.tab].cmd })
+    else
+      -- Place [+x] indicator for lines that spill over 'cmdheight'.
+      local h = api.nvim_win_text_height(ext.wins[ext.tab].cmd, {})
+      M.cmd.lines, M.cmd.msg_row = h.all, h.end_row
+      local spill = M.cmd.lines > ext.cmdheight and ('[+%d]'):format(M.cmd.lines - ext.cmdheight)
+      M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
+      api.nvim_win_set_cursor(ext.wins[ext.tab][tar], { 1, 0 })
+      ext.cmd.highlighter.active[ext.bufs.cmd] = nil
+    end
+  end
+
+  if M[tar] then
+    set_virttext('msg')
+  end
+end
+
+local replace_bufwrite = false
+--- Route the message to the appropriate sink.
+---
+---@param kind string
+---@alias MsgChunk [integer, string, integer]
+---@alias MsgContent MsgChunk[]
+---@param content MsgContent
+function M.msg_show(kind, content)
+  if kind == 'search_cmd' then
+    -- Set the entered search command in the cmdline.
+    api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, { content[1][2] })
+    M.virt.msg = ext.cfg.msg.pos == 'cmd' and { {}, {} } or M.virt.msg
+    M.prev_msg = ext.cfg.msg.pos == 'cmd' and '' or M.prev_msg
+  elseif kind == 'search_count' then
+    -- Extract only the search_count, not the entered search command.
+    -- Match any of search.c:cmdline_search_stat():' [(x | >x | ?)/(y | >y | ??)]'
+    content[1][2] = content[1][2]:match('W? %[>?%d*%??/>?%d*%?*%]') .. '  '
+    M.virt.last[M.virt.idx.search] = content
+    M.virt.last[M.virt.idx.cmd] = { { 0, (' '):rep(11) } }
+    set_virttext('last')
+  elseif kind == 'return_prompt' then
+    -- Bypass hit enter prompt.
+    vim.api.nvim_feedkeys(vim.keycode('<CR>'), 'n', false)
+  elseif kind == 'verbose' then
+    -- Verbose messages are sent too often to be meaningful in the cmdline:
+    -- always route to box regardless of cfg.msg.pos.
+    M.show_msg('box', content, false)
+  elseif ext.cmd.prompt then
+    -- Route to prompt that stays open so long as the cmdline prompt is active.
+    api.nvim_buf_set_lines(ext.bufs.prompt, 0, -1, false, { '' })
+    M.show_msg('prompt', content, true)
+    M.set_pos('prompt')
+  else
+    local tar = ext.cfg.msg.pos
+    M.virt.last[M.virt.idx.search][1] = tar ~= 'cmd' and M.virt.last[M.virt.idx.search][1] or nil
+    M.show_msg(tar, content, replace_bufwrite, kind == 'list_cmd')
+    -- Replace message for every second bufwrite message.
+    replace_bufwrite = not replace_bufwrite and kind == 'bufwrite'
+    -- Store the time when an error message was emitted in order to not overwrite
+    -- it with 'last' virt_text in the cmdline to give the user a chance to read it.
+    if tar == 'cmd' and kind == 'emsg' then
+      M.cmd.last_emsg = os.time()
+    end
+  end
+end
+
+function M.msg_clear() end
+
+--- Place the mode text in the cmdline.
+---
+---@param content MsgContent
+function M.msg_showmode(content)
+  M.virt.last[M.virt.idx.mode] = content
+  M.virt.last[M.virt.idx.search] = {}
+  set_virttext('last')
+end
+
+--- Place text from the 'showcmd' buffer in the cmdline.
+---
+---@param content MsgContent
+function M.msg_showcmd(content)
+  local str = content[1] and content[1][2]:sub(-10) or ''
+  M.virt.last[M.virt.idx.cmd][1] = (content[1] or M.virt.last[M.virt.idx.search][1])
+    and { 0, str .. (' '):rep(11 - #str) }
+  set_virttext('last')
+end
+
+--- Place the 'ruler' text in the cmdline window.
+---
+---@param content MsgContent
+function M.msg_ruler(content)
+  M.virt.last[M.virt.idx.ruler] = content
+  set_virttext('last')
+end
+
+---@alias MsgHistory [string, MsgContent]
+--- Zoom in on the message window with the message history.
+---
+---@param entries MsgHistory[]
+function M.msg_history_show(entries)
+  if #entries == 0 then
+    return
+  end
+
+  api.nvim_buf_set_lines(ext.bufs.more, 0, -1, false, {})
+  for i, entry in ipairs(entries) do
+    M.show_msg('more', entry[2], i == 1)
+  end
+
+  M.set_pos('more')
+end
+
+function M.msg_history_clear() end
+
+--- Adjust dimensions of the message windows after certain events.
+---
+---@param type? 'box'|'cmd'|'more'|'prompt' Type of to be positioned window (nil for all).
+function M.set_pos(type)
+  local function win_set_pos(win)
+    local texth = type and api.nvim_win_text_height(win, {}) or 0
+    local height = type and math.min(texth.all, math.ceil(o.lines * 0.5))
+    api.nvim_win_set_config(win, {
+      hide = false,
+      relative = 'laststatus',
+      height = height,
+      row = win == ext.wins[ext.tab].box and 0 or 1,
+      col = 10000,
+      zindex = type == 'more' and 299 or nil,
+    })
+    if type == 'box' then
+      -- Ensure last line is visible and first line is at top of window.
+      local row = (texth.all > height and texth.end_row or 0) + 1
+      api.nvim_win_set_cursor(ext.wins[ext.tab].box, { row, 0 })
+    elseif type == 'more' and api.nvim_get_current_win() ~= win then
+      api.nvim_set_current_win(win)
+    end
+  end
+
+  for t, win in pairs(ext.wins[ext.tab] or {}) do
+    local cfg = (t == type or (type == nil and t ~= 'cmd'))
+      and api.nvim_win_is_valid(win)
+      and api.nvim_win_get_config(win)
+    if cfg and (type or not cfg.hide) then
+      win_set_pos(win)
+    end
+  end
+end
+
+return M
diff --git a/runtime/lua/vim/_extui/shared.lua b/runtime/lua/vim/_extui/shared.lua
new file mode 100644
index 00000000000000..acd5d08e8bfbf7
--- /dev/null
+++ b/runtime/lua/vim/_extui/shared.lua
@@ -0,0 +1,87 @@
+local api, o = vim.api, vim.o
+local M = {
+  msg = nil, ---@type vim._extui.messages
+  cmd = nil, ---@type vim._extui.cmdline
+  ns = api.nvim_create_namespace('nvim._ext_ui'),
+  augroup = api.nvim_create_augroup('nvim._ext_ui', {}),
+  cmdheight = -1, -- 'cmdheight' option value set by user.
+  -- Map of tabpage ID to box/cmd/more/prompt window IDs.
+  wins = {}, ---@type { ['box'|'cmd'|'more'|'prompt']: integer }[]
+  bufs = { box = -1, cmd = -1, more = -1, prompt = -1 },
+  tab = 0, -- Current tabpage.
+  cfg = {
+    enable = true,
+    msg = { -- Options related to the message module.
+      ---@type 'box'|'cmd' Type of window used to place messages, either in the
+      ---cmdline or in a separate ephemeral message box window.
+      pos = 'cmd',
+      box = { -- Options related to the message box window.
+        timeout = 4000, -- Time a message is visible.
+      },
+    },
+  },
+}
+local wincfg = { -- Default cfg for nvim_open_win().
+  relative = 'laststatus',
+  style = 'minimal',
+  col = 0,
+  row = 1,
+  width = 10000,
+  height = 1,
+  noautocmd = true,
+  zindex = 300,
+}
+
+--- Ensure the various buffers and windows have not been deleted.
+function M.tab_check_wins()
+  M.tab = api.nvim_get_current_tabpage()
+  if not M.wins[M.tab] then
+    M.wins[M.tab] = { box = -1, cmd = -1, more = -1, prompt = -1 }
+  end
+
+  for _, type in ipairs({ 'box', 'cmd', 'more', 'prompt' }) do
+    if not api.nvim_buf_is_valid(M.bufs[type]) then
+      M.bufs[type] = api.nvim_create_buf(false, true)
+      if type == 'cmd' then
+        -- Attach highlighter to the cmdline buffer.
+        local parser = assert(vim.treesitter.get_parser(M.bufs.cmd, 'vim', {}))
+        M.cmd.highlighter = vim.treesitter.highlighter.new(parser)
+      elseif type == 'more' then
+        -- Close more window with Ctrl-C.
+        vim.keymap.set('n', '<C-c>', '<C-w>c', { buffer = M.bufs.more })
+      end
+    end
+
+    local setopt = false
+    if not api.nvim_win_is_valid(M.wins[M.tab][type]) then
+      local top = { vim.opt.fcs:get().horiz or o.ambw == 'single' and '─' or '-', 'WinSeparator' }
+      local border = (type == 'more' or type == 'prompt') and { '', top, '', '', '', '', '', '' }
+      local cfg = vim.tbl_deep_extend('force', wincfg, {
+        focusable = type == 'more',
+        mouse = type ~= 'cmd' and true or nil,
+        anchor = type ~= 'cmd' and 'SE' or nil,
+        hide = type ~= 'cmd' or M.cmdheight == 0 or nil,
+        title = type == 'more' and 'Messages' or nil,
+        border = type == 'box' and not o.termguicolors and 'single' or border or 'none',
+        _cmdline_offset = type == 'cmd' and 0 or nil,
+      })
+      M.wins[M.tab][type] = api.nvim_open_win(M.bufs[type], false, cfg)
+      api.nvim_win_set_hl_ns(M.wins[M.tab][type], M.ns)
+      setopt = true
+    elseif api.nvim_win_get_buf(M.wins[M.tab][type]) ~= M.bufs[type] then
+      api.nvim_win_set_buf(M.wins[M.tab][type], M.bufs[type])
+      setopt = true
+    end
+
+    if setopt then
+      if type == 'box' and o.termguicolors then
+        vim.wo[M.wins[M.tab][type]].winblend = 30
+      end
+      vim.wo[M.wins[M.tab][type]].linebreak = false
+      vim.wo[M.wins[M.tab][type]].smoothscroll = true
+      vim.wo[M.wins[M.tab][type]].eventignorewin = 'all'
+    end
+  end
+end
+
+return M
diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua
index 804ce1e2b06695..70365522a42bbe 100755
--- a/src/gen/gen_vimdoc.lua
+++ b/src/gen/gen_vimdoc.lua
@@ -149,6 +149,7 @@ local config = {
       'loader.lua',
       'uri.lua',
       'ui.lua',
+      '_extui.lua',
       'filetype.lua',
       'keymap.lua',
       'fs.lua',
@@ -171,6 +172,7 @@ local config = {
       'runtime/lua/vim/loader.lua',
       'runtime/lua/vim/uri.lua',
       'runtime/lua/vim/ui.lua',
+      'runtime/lua/vim/_extui.lua',
       'runtime/lua/vim/filetype.lua',
       'runtime/lua/vim/keymap.lua',
       'runtime/lua/vim/fs.lua',


Reviews:
user: login: gpanders
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-02T18:23:11Z
user: login: gpanders
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-02T20:28:50Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-03T09:45:51Z
user: login: gpanders
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-03T13:51:02Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-03T14:01:53Z
user: login: gpanders
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-03T15:25:56Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-03T15:40:19Z
user: login: gpanders
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-03T15:56:25Z
user: login: towry
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-20T01:29:11Z
user: login: chemio9
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-26T00:51:40Z
user: login: przepompownia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T17:07:01Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-09T22:38:20Z
user: login: bfredl
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T09:21:02Z
user: login: przepompownia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T13:37:46Z
user: login: przepompownia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T15:36:53Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T15:52:01Z
user: login: przepompownia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:45:20Z
user: login: przepompownia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:55:18Z
user: login: bfredl
body: Should be low-risk to merge this now, and iterate and fix things on master.

We can decide when 0.12 is drawing closer whether it is ready to be advertised as a feature or remain a `WARNING: EXPERIMENTAL` thing for that release. 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T08:46:19Z
user: login: brianhuster
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T17:32:17Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T17:44:42Z
user: login: brianhuster
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T17:58:14Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T18:03:33Z
user: login: luukvbaal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T19:29:27Z

Review comments:
diff_hunk: @@ -0,0 +1,109 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      if ev.match == 'cmdheight' then
path: runtime/lua/vim/_extui.lua
user: login: gpanders
body: ```suggestion
    callback = function(ev)
      ext.tab_check_wins()
      if ev.match == 'cmdheight' then
```
created_at: 2025-01-02T18:23:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: gpanders
body: AFAICT winblend only has an effect when `Normal` has a guifg/bg color defined. I keep `Normal` cleared in my colorscheme (it just uses default fg/bg colors from the terminal) so these options make the message window have a plain black background with no border. In addition to setting these options when `'termguicolors'` is set I suggest also checking the values of the `Normal` highlight group.
created_at: 2025-01-02T20:28:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: Well the idea was to have an invisible message box (`MsgArea` linked to `Normal` by default), and no border unless necessary (terminal space being precious and all). Clearing `Normal` should not yield black background unless your terminal background is indeed black, or you define `MsgArea` with a black background.

`'winblend'` helps to discern text that runs behind the floating window, but that doesn't work with `'notermguicolors'`, in which case a border is needed. Otherwise I don't think a cleared `Normal` group is relevant; it should still give alpha to text below the float, but maybe I'm misunderstanding.
created_at: 2025-01-03T09:45:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: gpanders
body: >Clearing Normal should not yield black background unless your terminal background is indeed black, or you define MsgArea with a black background.

That's what I'm saying, this is not the case. The `'winblend'` option creates a black background when `Normal` is cleared. You can try it for yourself and see. I don't know if this is intended behavior or not (the compositor presumably needs to know the default fg/bg color to perform the blending which it doesn't know if `Normal` is cleared).
created_at: 2025-01-03T13:51:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: I'm not sure what to try, could you share reproduction steps and or a screenshot of what you're seeing?
created_at: 2025-01-03T14:01:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: gpanders
body: With this PR display any kind of message (e.g. `Ctrl-G`) with `Normal` cleared and then again with `Normal` defined with a gui fg/bg color.

With Normal cleared and `'winblend'` set to 30, I see this:

![Screenshot from 2025-01-03 09-24-24](https://github.com/user-attachments/assets/4295d2bf-5b44-4223-b10d-1961b909aec6)

With Normal defined with the same fg/bg colors as my terminal and `'winblend'` set to 30, I see this:

![Screenshot from 2025-01-03 09-24-35](https://github.com/user-attachments/assets/1bbd4a4b-e65a-4471-9dd8-bbb1b54ab3c2)

With Normal cleared and `'winblend'` set to 0, I see this:

![Screenshot from 2025-01-03 09-28-20](https://github.com/user-attachments/assets/f78a14a7-23bf-4ee1-b551-29ac795efe47)

created_at: 2025-01-03T15:25:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: Interesting, not sure what causes that; it doesn't reproduce from `build/bin/nvim --clean +"hi clear Normal" +"set tgc" +"lua require('vim._extui').enable(true)"` so more info is needed.
created_at: 2025-01-03T15:40:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+local lsauid = -1 -- Autocommand id for 'laststatus' WinOpen/Closed handler.
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  vim.api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(enable)
+  if not enable then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    api.nvim_buf_delete(ext.bufs.cmd, {})
+    api.nvim_buf_delete(ext.bufs.msg, {})
+    api.nvim_buf_delete(ext.bufs.more, {})
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  ext.cmdheight = vim.o.cmdheight
+  vim.ui_attach(ext.ns, { ext_cmdline = true, ext_messages = true }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+  vim.o.cmdheight = ext.cmdheight
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
+
+  api.nvim_create_autocmd('VimResized', {
+    group = ext.augroup,
+    callback = function()
+      ext.msg.set_pos(ext.cmdheight, 0)
+    end,
+    desc = 'Manipulate cmdline and message window dimensions after shell resize.',
+  })
+
+  api.nvim_create_autocmd('OptionSet', {
+    group = ext.augroup,
+    pattern = { 'cmdheight', 'laststatus', 'termguicolors' },
+    callback = function(ev)
+      ext.tab_check_wins()
+      if ev.match == 'cmdheight' then
+        -- 'cmdheight' set; (un)hide cmdline window and set its height.
+        ext.cmdheight = vim.v.option_new
+        local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
+        pcall(api.nvim_win_set_config, ext.wins[ext.tab].cmd, cfg)
+      elseif ev.match == 'termguicolors' then
+        -- 'termguicolors' toggled; add or remove border and set 'winblend' for message windows.
+        for _, tab in ipairs(api.nvim_list_tabpages()) do
+          local win = ext.wins[tab].msg
+          pcall(api.nvim_win_set_config, win, { border = vim.v.option_new and 'none' or 'single' })
+          pcall(api.nvim_set_option_value, 'winblend', vim.v.option_new and 30 or 0, { win = win })
path: runtime/lua/vim/_extui.lua
user: login: gpanders
body: Interesting, it doesn't reproduce with `hi clear Normal`, but does with `call nvim_set_hl(0, 'Normal', #{})`.
created_at: 2025-01-03T15:56:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,306 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+
+---@class vim._extui.messages
+local M = {
+  rucol = nil, ---@type integer? Column of ruler extmark.
+  ruler_id = nil, ---@type integer? ID of ruler extmark.
+  showcmd_id = nil, ---@type integer? ID of showcmd extmark.
+  prev_msg = '', -- Concatenated content of the previous message.
+  dupe = 0, -- Number of times message is repeated.
+}
+
+-- Message box window.
+local Box = {
+  width = 1, -- Current width of the message window.
+  count = 0, -- Number of messages currently in the message window.
+  timer = nil, ---@type uv.uv_timer_t Timer that removes the most recent message.
+}
+
+--- Start a timer whose callback will remove the message from the message window.
+---
+---@param buf integer Buffer the message was written to.
+---@param len integer Number of rows that should be removed.
+function Box:start_timer(buf, len)
+  self.timer = vim.defer_fn(function()
+    if self.count == 0 or not api.nvim_buf_is_valid(buf) then
+      return -- Messages moved to split or buffer was closed.
+    end
+    api.nvim_buf_set_lines(buf, 0, len, false, {})
+    self.count = self.count - 1
+    if self.count > 0 then
+      M.set_pos('box')
+    else
+      self.width, M.prev_msg = 1, ''
+      api.nvim_win_set_config(ext.wins[ext.tab].box, { hide = true })
+    end
+  end, 4000)
+end
+
+local showcmd_cols = 11
+---@alias MsgChunk [integer, string, integer]
+---@alias MsgContent MsgChunk[]
+---@param target 'box'|'cmd'|'more'|'prompt'
+---@param content MsgContent
+---@param replace_last boolean
+function M.show_msg(target, content, replace_last)
+  local msg = ''
+  if target == ext.cfg.messages.pos then
+    for _, chunk in ipairs(content) do
+      msg = msg .. chunk[2]
+    end
+    M.dupe = (msg == M.prev_msg and M.dupe + 1 or 0)
+    M.prev_msg = msg
+  end
+
+  local row = target == 'cmd' and 0
+    or api.nvim_buf_line_count(ext.bufs[target])
+      - ((replace_last or (target == 'box' and (Box.count == 0 or M.dupe > 0))) and 1 or 0)
+  local start_row, col = row, 0
+  local lines, marks = {}, {} ---@type string[], [integer, integer, vim.api.keyset.set_extmark][]
+
+  -- Accumulate to be inserted and highlighted message chunks. or false
+  for i, chunk in ipairs(M.dupe > 0 and {} or content) do
+    local srow, scol = row, col
+    -- Split at newline and concatenate first and last message chunks.
+    for str in (chunk[2] .. '\0'):gmatch('.-[\n%z]') do
+      local idx = i > 1 and row == srow and 0 or 1
+      lines[#lines + idx] = idx > 0 and str:sub(1, -2) or lines[#lines] .. str:sub(1, -2)
+      col = #lines[#lines]
+      if target == 'box' then
+        Box.width = math.max(Box.width, api.nvim_strwidth(lines[#lines]))
+      end
+      row = row + (str:sub(-1) == '\0' and 0 or 1)
+    end
+    if chunk[3] > 0 then
+      marks[#marks + 1] = { srow, scol }
+      marks[#marks][3] = {
+        end_col = col,
+        end_row = row,
+        hl_group = chunk[3],
+        invalidate = true,
+        undo_restore = false,
+        end_right_gravity = (i == #content), -- extend highlight for dupe/trunc indicator.
+      }
+    end
+  end
+
+  local trunc = target == 'cmd' and row + 1 - ext.cmdheight
+  -- Add highlighted message or `dupe_str` to buffer.
+  if M.dupe > 0 then
+    local str = ('%s(%d)'):format(trunc > 0 and '' or ' ', M.dupe)
path: runtime/lua/vim/_extui/messages.lua
user: login: towry
body: Got "attempt to compare number with boolean" error at this line
created_at: 2025-02-20T01:29:11Z
author_association: NONE
diff_hunk: @@ -0,0 +1,306 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+
+---@class vim._extui.messages
+local M = {
+  rucol = nil, ---@type integer? Column of ruler extmark.
+  ruler_id = nil, ---@type integer? ID of ruler extmark.
+  showcmd_id = nil, ---@type integer? ID of showcmd extmark.
+  prev_msg = '', -- Concatenated content of the previous message.
+  dupe = 0, -- Number of times message is repeated.
+}
+
+-- Message box window.
+local Box = {
+  width = 1, -- Current width of the message window.
+  count = 0, -- Number of messages currently in the message window.
+  timer = nil, ---@type uv.uv_timer_t Timer that removes the most recent message.
+}
+
+--- Start a timer whose callback will remove the message from the message window.
+---
+---@param buf integer Buffer the message was written to.
+---@param len integer Number of rows that should be removed.
+function Box:start_timer(buf, len)
+  self.timer = vim.defer_fn(function()
+    if self.count == 0 or not api.nvim_buf_is_valid(buf) then
+      return -- Messages moved to split or buffer was closed.
+    end
+    api.nvim_buf_set_lines(buf, 0, len, false, {})
+    self.count = self.count - 1
+    if self.count > 0 then
+      M.set_pos('box')
+    else
+      self.width, M.prev_msg = 1, ''
+      api.nvim_win_set_config(ext.wins[ext.tab].box, { hide = true })
+    end
+  end, 4000)
+end
+
+local showcmd_cols = 11
+---@alias MsgChunk [integer, string, integer]
+---@alias MsgContent MsgChunk[]
+---@param target 'box'|'cmd'|'more'|'prompt'
+---@param content MsgContent
+---@param replace_last boolean
+function M.show_msg(target, content, replace_last)
+  local msg = ''
+  if target == ext.cfg.messages.pos then
+    for _, chunk in ipairs(content) do
+      msg = msg .. chunk[2]
+    end
+    M.dupe = (msg == M.prev_msg and M.dupe + 1 or 0)
+    M.prev_msg = msg
+  end
+
+  local row = target == 'cmd' and 0
+    or api.nvim_buf_line_count(ext.bufs[target])
+      - ((replace_last or (target == 'box' and (Box.count == 0 or M.dupe > 0))) and 1 or 0)
+  local start_row, col = row, 0
+  local lines, marks = {}, {} ---@type string[], [integer, integer, vim.api.keyset.set_extmark][]
+
+  -- Accumulate to be inserted and highlighted message chunks. or false
+  for i, chunk in ipairs(M.dupe > 0 and {} or content) do
+    local srow, scol = row, col
+    -- Split at newline and concatenate first and last message chunks.
+    for str in (chunk[2] .. '\0'):gmatch('.-[\n%z]') do
+      local idx = i > 1 and row == srow and 0 or 1
+      lines[#lines + idx] = idx > 0 and str:sub(1, -2) or lines[#lines] .. str:sub(1, -2)
+      col = #lines[#lines]
+      if target == 'box' then
+        Box.width = math.max(Box.width, api.nvim_strwidth(lines[#lines]))
+      end
+      row = row + (str:sub(-1) == '\0' and 0 or 1)
+    end
+    if chunk[3] > 0 then
+      marks[#marks + 1] = { srow, scol }
+      marks[#marks][3] = {
+        end_col = col,
+        end_row = row,
+        hl_group = chunk[3],
+        invalidate = true,
+        undo_restore = false,
+        end_right_gravity = (i == #content), -- extend highlight for dupe/trunc indicator.
+      }
+    end
+  end
+
+  local trunc = target == 'cmd' and row + 1 - ext.cmdheight
path: runtime/lua/vim/_extui/messages.lua
user: login: chemio9
body: trunc might be `false` or a number
created_at: 2025-02-26T00:51:27Z
author_association: NONE
diff_hunk: @@ -0,0 +1,144 @@
+local ext = require('vim._extui.shared')
+local api = vim.api
+---@class vim._extui.cmdline
+local M = {
+  active = false, -- Whether the cmdline is currently active (false after cmdline_hide).
+  shown = false, -- Whether the last entered command is still visible (false after msg_showmode).
+  prompt = false, -- Whether a prompt is active; messages are placed in the 'prompt' buffer/window.
+}
+
+local row = 0 -- Current row in the cmdline buffer, > 0 for "cmdline_block".
+--- Concatenate content chunks and set the text for the current row in the cmdline buffer.
+---
+---@alias CmdChunk [integer, string]
+---@alias CmdContent CmdChunk[]
+---@param content CmdContent
+---@param prompt string
+local function set_text(content, prompt)
+  for _, chunk in ipairs(content) do
+    prompt = prompt .. chunk[2]
+  end
+  api.nvim_buf_set_lines(ext.bufs.cmd, row, -1, false, { prompt .. ' ' })
+end
+
+local promptlen = 0 -- Current length of the prompt, stored for use in "cmdline_pos"
path: runtime/lua/vim/_extui/cmdline.lua
user: login: przepompownia
body: Would it be possible (and would it make sense) to provide that length in `cmdline_pos` event to avoid storing it on the user side?
created_at: 2025-03-09T17:07:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,144 @@
+local ext = require('vim._extui.shared')
+local api = vim.api
+---@class vim._extui.cmdline
+local M = {
+  active = false, -- Whether the cmdline is currently active (false after cmdline_hide).
+  shown = false, -- Whether the last entered command is still visible (false after msg_showmode).
+  prompt = false, -- Whether a prompt is active; messages are placed in the 'prompt' buffer/window.
+}
+
+local row = 0 -- Current row in the cmdline buffer, > 0 for "cmdline_block".
+--- Concatenate content chunks and set the text for the current row in the cmdline buffer.
+---
+---@alias CmdChunk [integer, string]
+---@alias CmdContent CmdChunk[]
+---@param content CmdContent
+---@param prompt string
+local function set_text(content, prompt)
+  for _, chunk in ipairs(content) do
+    prompt = prompt .. chunk[2]
+  end
+  api.nvim_buf_set_lines(ext.bufs.cmd, row, -1, false, { prompt .. ' ' })
+end
+
+local promptlen = 0 -- Current length of the prompt, stored for use in "cmdline_pos"
path: runtime/lua/vim/_extui/cmdline.lua
user: login: luukvbaal
body: Possible yes, but I think a GUI would probably use a self-calculated cell-offset rather than this byte-offset (not sure). 

Do you think it would make sense to add the byte-offset of the `cmdline_show->prompt` to the `cmdline_pos` event as a third argument @bfredl?

Or can we do it regardless of whether it use useful for GUIs? It's what a `vim.ui_attach()` implementation needs to position the cursor. Although it would just remove the need for a state variable storing `string.len(cmdline_show->prompt)`. Not that high leverage but slightly more ergonomic.
created_at: 2025-03-09T22:38:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,144 @@
+local ext = require('vim._extui.shared')
+local api = vim.api
+---@class vim._extui.cmdline
+local M = {
+  active = false, -- Whether the cmdline is currently active (false after cmdline_hide).
+  shown = false, -- Whether the last entered command is still visible (false after msg_showmode).
+  prompt = false, -- Whether a prompt is active; messages are placed in the 'prompt' buffer/window.
+}
+
+local row = 0 -- Current row in the cmdline buffer, > 0 for "cmdline_block".
+--- Concatenate content chunks and set the text for the current row in the cmdline buffer.
+---
+---@alias CmdChunk [integer, string]
+---@alias CmdContent CmdChunk[]
+---@param content CmdContent
+---@param prompt string
+local function set_text(content, prompt)
+  for _, chunk in ipairs(content) do
+    prompt = prompt .. chunk[2]
+  end
+  api.nvim_buf_set_lines(ext.bufs.cmd, row, -1, false, { prompt .. ' ' })
+end
+
+local promptlen = 0 -- Current length of the prompt, stored for use in "cmdline_pos"
path: runtime/lua/vim/_extui/cmdline.lua
user: login: bfredl
body: hmm, I don't think it is necessary that _any_ internal implementation would implement prompts using string concatenation. 

Storing state between ui events is necessary in general, so storing `#promt` or whatever on the lua side shouldn't be a big deal. 
created_at: 2025-04-08T09:21:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(opts)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
path: runtime/lua/vim/_extui.lua
user: login: przepompownia
body: `opts` seems to need validation (try opts = nil for example). 
created_at: 2025-04-19T13:37:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,106 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: przepompownia
body: I tried to link `Normal` differently in the history and the message window (to dim only disappearing messages) but currently it seems to be not simple from the user side (at first glance it requires `=require('vim._extui.shared').wins[tabnr].box` and set `winhighlight`) or I have overlooked something.
created_at: 2025-04-19T15:36:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,106 @@
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+M.enable = function(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: Yes a new highlight group to apply to the message box is probably in order.
created_at: 2025-04-19T15:52:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,394 @@
+local api, fn, o = vim.api, vim.fn, vim.o
+local ext = require('vim._extui.shared')
+
+---@class vim._extui.messages
+local M = {
+  -- Message box window. Used for regular messages with 'cmdheight' == 0 or,
+  -- cfg.msg.pos == 'box'. Also used for verbose messages regardless of
+  -- cfg.msg.pos. Automatically resizes to the text dimensions up to a point,
+  -- at which point only the most recent messages will fit and be shown.
+  -- A timer is started for each message whose callback will remove the message
+  -- from the window again.
+  box = {
+    count = 0, -- Number of messages currently in the message window.
+    width = 1, -- Current width of the message window.
+    timer = nil, ---@type uv.uv_timer_t Timer that removes the most recent message.
+  },
+  -- Cmdline message window. Used for regular messages with 'cmdheight' > 0.
+  -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text.
+  -- Messages that don't fit the 'cmdheight' are cut off and virt_text is added
+  -- to indicate the number of spilled lines and repeated messages.
+  cmd = {
+    count = 0, -- Number of messages currently in the message window.
+    lines = 0, -- Number of lines in cmdline buffer (including wrapped lines).
+    msg_row = -1, -- Last row of message to distinguish for placing virt_text.
+    last_col = o.columns, -- Crop text to start column of 'last' virt_text.
+  },
+  dupe = 0, -- Number of times message is repeated.
+  prev_msg = '', -- Concatenated content of the previous message.
+  virt = { -- Stored virt_text state.
+    last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row.
+    msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in message window.
+    msg_hl = 0, ---@type integer? indicator hl_group, matching message tail.
+    idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 },
+    ids = {}, ---@type { ['last'|'msg']: integer? } Table of mark IDs.
+  },
+}
+
+--- Start a timer whose callback will remove the message from the message window.
+---
+---@param buf integer Buffer the message was written to.
+---@param len integer Number of rows that should be removed.
+function M.box:start_timer(buf, len)
+  self.timer = vim.defer_fn(function()
+    if self.count == 0 or not api.nvim_buf_is_valid(buf) then
+      return -- Messages moved to split or buffer was closed.
+    end
+    api.nvim_buf_set_lines(buf, 0, len, false, {})
+    self.count = self.count - 1
+    -- Resize or hide message box for removed message.
+    if self.count > 0 then
+      M.set_pos('box')
+    else
+      self.width = 1
+      M.prev_msg = ext.cfg.msg.pos == 'box' and '' or M.prev_msg
+      api.nvim_win_set_config(ext.wins[ext.tab].box, { hide = true })
path: runtime/lua/vim/_extui/messages.lua
user: login: przepompownia
body: Need to check `nvim_win_is_valid`: I have `vim.keymap.set('n', '<Esc>', vim.cmd.fclose)` and sometimes I hide the window before its timer expires.
created_at: 2025-04-21T20:45:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,394 @@
+local api, fn, o = vim.api, vim.fn, vim.o
+local ext = require('vim._extui.shared')
+
+---@class vim._extui.messages
+local M = {
+  -- Message box window. Used for regular messages with 'cmdheight' == 0 or,
+  -- cfg.msg.pos == 'box'. Also used for verbose messages regardless of
+  -- cfg.msg.pos. Automatically resizes to the text dimensions up to a point,
+  -- at which point only the most recent messages will fit and be shown.
+  -- A timer is started for each message whose callback will remove the message
+  -- from the window again.
+  box = {
+    count = 0, -- Number of messages currently in the message window.
+    width = 1, -- Current width of the message window.
+    timer = nil, ---@type uv.uv_timer_t Timer that removes the most recent message.
+  },
+  -- Cmdline message window. Used for regular messages with 'cmdheight' > 0.
+  -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text.
+  -- Messages that don't fit the 'cmdheight' are cut off and virt_text is added
+  -- to indicate the number of spilled lines and repeated messages.
+  cmd = {
+    count = 0, -- Number of messages currently in the message window.
+    lines = 0, -- Number of lines in cmdline buffer (including wrapped lines).
+    msg_row = -1, -- Last row of message to distinguish for placing virt_text.
+    last_col = o.columns, -- Crop text to start column of 'last' virt_text.
+  },
+  dupe = 0, -- Number of times message is repeated.
+  prev_msg = '', -- Concatenated content of the previous message.
+  virt = { -- Stored virt_text state.
+    last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row.
+    msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in message window.
+    msg_hl = 0, ---@type integer? indicator hl_group, matching message tail.
+    idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 },
+    ids = {}, ---@type { ['last'|'msg']: integer? } Table of mark IDs.
+  },
+}
+
+--- Start a timer whose callback will remove the message from the message window.
+---
+---@param buf integer Buffer the message was written to.
+---@param len integer Number of rows that should be removed.
+function M.box:start_timer(buf, len)
+  self.timer = vim.defer_fn(function()
+    if self.count == 0 or not api.nvim_buf_is_valid(buf) then
+      return -- Messages moved to split or buffer was closed.
+    end
+    api.nvim_buf_set_lines(buf, 0, len, false, {})
+    self.count = self.count - 1
+    -- Resize or hide message box for removed message.
+    if self.count > 0 then
+      M.set_pos('box')
+    else
+      self.width = 1
+      M.prev_msg = ext.cfg.msg.pos == 'box' and '' or M.prev_msg
+      api.nvim_win_set_config(ext.wins[ext.tab].box, { hide = true })
+      api.nvim_buf_clear_namespace(ext.bufs.box, -1, 0, -1)
+    end
+  end, 4000)
path: runtime/lua/vim/_extui/messages.lua
user: login: przepompownia
body: It should be configurable. 
created_at: 2025-04-21T20:55:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,129 @@
+--- @brief
+---
+---WARNING: This is an experimental interface intended to replace the message
+---grid in the TUI.
+---
+---To enable the experimental UI (default opts shown):
+---```lua
+---require('vim._extui').enable({
+---  enable = true, -- Whether to enable or disable the UI.
+---  msg = { -- Options related to the message module.
+---    ---@type 'box'|'cmd' Type of window used to place messages, either in the
+---    ---cmdline or in a separate message box window with ephemeral messages.
+---    pos = 'cmd',
+---    box = { -- Options related to the message box window.
+---      timeout = 4000, -- Time a message is visible.
+---    },
+---  },
+---})
+---```
+
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+---@nodoc
+function M.enable(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: brianhuster
body: May I know why these hl groups are linked to 'MsgArea'? It makes me unable to see search highlighting in messages window
created_at: 2025-05-02T17:32:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,129 @@
+--- @brief
+---
+---WARNING: This is an experimental interface intended to replace the message
+---grid in the TUI.
+---
+---To enable the experimental UI (default opts shown):
+---```lua
+---require('vim._extui').enable({
+---  enable = true, -- Whether to enable or disable the UI.
+---  msg = { -- Options related to the message module.
+---    ---@type 'box'|'cmd' Type of window used to place messages, either in the
+---    ---cmdline or in a separate message box window with ephemeral messages.
+---    pos = 'cmd',
+---    box = { -- Options related to the message box window.
+---      timeout = 4000, -- Time a message is visible.
+---    },
+---  },
+---})
+---```
+
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+---@nodoc
+function M.enable(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: Yes needs more specificity. With how the various windows in this module evolved we probably only want these links in the cmdline window.
created_at: 2025-05-02T17:44:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,129 @@
+--- @brief
+---
+---WARNING: This is an experimental interface intended to replace the message
+---grid in the TUI.
+---
+---To enable the experimental UI (default opts shown):
+---```lua
+---require('vim._extui').enable({
+---  enable = true, -- Whether to enable or disable the UI.
+---  msg = { -- Options related to the message module.
+---    ---@type 'box'|'cmd' Type of window used to place messages, either in the
+---    ---cmdline or in a separate message box window with ephemeral messages.
+---    pos = 'cmd',
+---    box = { -- Options related to the message box window.
+---      timeout = 4000, -- Time a message is visible.
+---    },
+---  },
+---})
+---```
+
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+---@nodoc
+function M.enable(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: brianhuster
body: Could I send a PR to remove them? Sorry I just don't understand why they should be linked to MsgArea. Or are you planning to add `MsgSearch`, `MsgCurSearch`, `MsgIncSearch` highlight groups, so those lines are placeholders?
created_at: 2025-05-02T17:58:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,129 @@
+--- @brief
+---
+---WARNING: This is an experimental interface intended to replace the message
+---grid in the TUI.
+---
+---To enable the experimental UI (default opts shown):
+---```lua
+---require('vim._extui').enable({
+---  enable = true, -- Whether to enable or disable the UI.
+---  msg = { -- Options related to the message module.
+---    ---@type 'box'|'cmd' Type of window used to place messages, either in the
+---    ---cmdline or in a separate message box window with ephemeral messages.
+---    pos = 'cmd',
+---    box = { -- Options related to the message box window.
+---      timeout = 4000, -- Time a message is visible.
+---    },
+---  },
+---})
+---```
+
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+---@nodoc
+function M.enable(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: They are needed in the cmdline window to avoid highlighting the entered search command itself.
created_at: 2025-05-02T18:03:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,129 @@
+--- @brief
+---
+---WARNING: This is an experimental interface intended to replace the message
+---grid in the TUI.
+---
+---To enable the experimental UI (default opts shown):
+---```lua
+---require('vim._extui').enable({
+---  enable = true, -- Whether to enable or disable the UI.
+---  msg = { -- Options related to the message module.
+---    ---@type 'box'|'cmd' Type of window used to place messages, either in the
+---    ---cmdline or in a separate message box window with ephemeral messages.
+---    pos = 'cmd',
+---    box = { -- Options related to the message box window.
+---      timeout = 4000, -- Time a message is visible.
+---    },
+---  },
+---})
+---```
+
+local api = vim.api
+local ext = require('vim._extui.shared')
+ext.msg = require('vim._extui.messages')
+ext.cmd = require('vim._extui.cmdline')
+local M = {}
+
+local function ui_callback(event, ...)
+  local handler = ext.msg[event] or ext.cmd[event]
+  if not handler then
+    return
+  end
+  ext.tab_check_wins()
+  handler(...)
+  api.nvim__redraw({
+    flush = true,
+    cursor = handler == ext.cmd[event] and true or nil,
+    win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
+  })
+end
+local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
+
+---@nodoc
+function M.enable(opts)
+  vim.validate('opts', opts, 'table', true)
+  ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
+
+  if ext.cfg.enable == false then
+    -- Detach and cleanup windows, buffers and autocommands.
+    for _, tab in ipairs(api.nvim_list_tabpages()) do
+      for _, win in pairs(ext.wins[tab] or {}) do
+        api.nvim_win_close(win, true)
+      end
+    end
+    for _, buf in pairs(ext.bufs) do
+      api.nvim_buf_delete(buf, {})
+    end
+    api.nvim_clear_autocmds({ group = ext.augroup })
+    vim.ui_detach(ext.ns)
+    return
+  end
+
+  vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
+    if vim.in_fast_event() then
+      scheduled_ui_callback(event, ...)
+    else
+      ui_callback(event, ...)
+    end
+  end)
+
+  api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
+  api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
path: runtime/lua/vim/_extui.lua
user: login: luukvbaal
body: #33792
created_at: 2025-05-02T19:29:27Z
author_association: MEMBER

PR comments:
user: login: clason
created_at: 2024-12-20T14:12:22Z
body: > Come to a consensus for how best to represent messages, alternative:
Only move messages that do not fit the 'cmdheight' area to a dedicated
floating message box.

related: #22478 

TL;DR: we need to distinguish (at least) three kinds of messages
1. single line "status messages" (-> route to `vim.notify`; print on cmdline as default)
2. error/deprecation messages/stacktraces etc. that should not be ignored (-> ???, floating window sounds good but probably needs to go through a `vim.notify` hook for plugins)
3. long-form output like `:map` (-> full buffer for search, yank, etc.)

(Of course, the line between 2 and 3 is blurry -- for example, `:Inspect` output would fit better in a floating window, so expected length of output is pobably a better distinguisher, and a separate "priority" option should be applied to must-read error messages. Prompt messages could be handled by a similar flag.)

author_association: MEMBER
user: login: luukvbaal
created_at: 2024-12-20T14:32:38Z
body: My starting point (which I mentioned in https://github.com/neovim/neovim/issues/27811#issuecomment-1997091918, and is now present in the PR) is that _all_ messages go in a (disappearing) floating window. Only when they exceed some length are they moved to a persistent floating window (mimicking a more prompt).

If @neovim/core (or people who try out this feature) feel strongly about still placing messages that fit the 'cmdheight' area where we are used for them to be, we can change that. The logic for determining based on text height is present in this PR. My hope was that people can get used to even small messages appearing in the bottom right corner.
author_association: MEMBER
user: login: clason
created_at: 2024-12-20T14:37:16Z
body: That's fine; I just wanted to link the context. The main points are that a) we need "message metadata" to allow different handling of messages (length is a good stand-in for an MVP, but maybe not enough in the long run) and b) we need to expose that to plugins like noice that want to shiny up the message UX. Again, not a blocker, just something to keep in mind.
author_association: MEMBER
user: login: justinmk
created_at: 2025-01-02T16:35:40Z
body: >   When that is finished, make this new UI the default and update aloooot of tests.

Why shouldn't the default behavior continue to show the messages in the cmdline region?  Changing the placement of messages doesn't need to be part of this phase.

If a message exceeds 'cmdheight' (i.e. would trigger "press enter"), just truncate its display (and show a hint like `+3 messages` in bottom-right corner). Users can invoke a default mapping such as `g>` to see the pager.
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-01-02T16:52:19Z
body: > Why shouldn't the default behavior continue to show the messages in the cmdline region?

Because we can (with ext_messages and floating windows) easily choose a representation that makes more sense.
The message box representation seemed natural to me, and is what I implemented.
At least with `:set cmdheight=0`, anything other than what we currently have makes more sense IMO.

But I can see how this would be contentious, which is why I asked for input in #27811.
If the old representation needs to stay with (`cmdheight > 0`) that could be done but if it were up to me I would try to avoid it in order to not have to maintain two separate representations.
author_association: MEMBER
user: login: justinmk
created_at: 2025-01-02T17:41:16Z
body: Just tried this locally, and I'm very excited for it. The highlighting and pager are very cool.

> > Why shouldn't the default behavior continue to show the messages in the cmdline region?
> 
> Because we can (with ext_messages and floating windows) easily choose a representation that makes more sense.

It's not obvious that showing the result of `:ls` in a timed floating window at bottom-right is any better than placing it where it normally goes. Seems somewhat arbitrary. 

Main point I want to make is: this PR is fantastic, and I want to ship it ASAP. Mimicking the legacy behavior (to a reasonable extent) is a two-way door: we can always debate a more "fancy" layout later, it doesn't reduce complexity or risk to decide that *now*. It can easily be a later phase, so it should be.

And depending on how much test changes are required, a "legacy layout" may be justified even just to avoid updating lots of tests.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-01-10T17:44:57Z
body: Side effects with merged-in master branch here:

- `nvim --clean -V9 -c "lua require('vim._extui').enable(true)" runtime/lua/vim/lsp/buf.lua` - crashes after [q]uitting from initial (not redirected yet) messages:
```cpp
#0  __pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
#1  0x00007fce587dedef in __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:78
#2  0x00007fce5878ad02 in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#3  0x00007fce587734f0 in __GI_abort () at ./stdlib/abort.c:79
#4  0x00007fce58773418 in __assert_fail_base (fmt=0x7fce588f7ca0 "%s%s%s:%u: %s%sAssertion `%s' failed.\n%n", assertion=assertion@entry=0x5615277b57a0 "msg_ext_kind == kind",
    file=file@entry=0x5615277d76b0 "/home/arctgx/dev/external/neovim-dev/src/nvim/message.c", line=line@entry=315, function=function@entry=0x56152778daa8 <__PRETTY_FUNCTION__.17> "msg_multihl") at ./assert/assert.c:94
#5  0x00007fce58783612 in __assert_fail (assertion=assertion@entry=0x5615277b57a0 "msg_ext_kind == kind", file=file@entry=0x5615277d76b0 "/home/arctgx/dev/external/neovim-dev/src/nvim/message.c", line=line@entry=315,
    function=function@entry=0x56152778daa8 <__PRETTY_FUNCTION__.17> "msg_multihl") at ./assert/assert.c:103
#6  0x00005615274e898c in msg_multihl (hl_msg=..., kind=0x5615277a9a20 "echomsg", history=true, err=false) at /home/arctgx/dev/external/neovim-dev/src/nvim/message.c:315
#7  0x000056152734c87c in nvim_echo (chunks=..., history=<optimized out>, opts=0x7fff936a533e, err=0x7fff936a53e0) at /home/arctgx/dev/external/neovim-dev/src/nvim/api/vim.c:795
#8  0x000056152733c99d in handle_nvim_echo (channel_id=<optimized out>, args=..., arena=<optimized out>, error=0x7fff936a53e0) at /home/arctgx/dev/external/neovim-dev/build/src/nvim/auto/api/private/dispatch_wrappers.generated.h:7936
#9  0x0000561527503421 in request_event (argv=0x24f71) at /home/arctgx/dev/external/neovim-dev/src/nvim/msgpack_rpc/channel.c:366
#10 0x00005615275d6f06 in state_handle_k_event () at /home/arctgx/dev/external/neovim-dev/src/nvim/state.c:120
#11 0x00005615275126c4 in nv_event (cap=<optimized out>) at /home/arctgx/dev/external/neovim-dev/src/nvim/normal.c:6634
#12 0x00005615275068e4 in normal_execute (state=0x7fff936a5580, key=<optimized out>) at /home/arctgx/dev/external/neovim-dev/src/nvim/normal.c:1244
#13 0x00005615275d6d9d in state_enter (s=0x7fff936a5580) at /home/arctgx/dev/external/neovim-dev/src/nvim/state.c:102
#14 0x0000561527503bd4 in normal_enter (cmdwin=<optimized out>, noexmode=<optimized out>) at /home/arctgx/dev/external/neovim-dev/src/nvim/normal.c:522
#15 0x0000561527319157 in main (argc=<optimized out>, argv=<optimized out>) at /home/arctgx/dev/external/neovim-dev/src/nvim/main.c:651
```

- `nvim --clean -c "lua require('vim._extui').enable(true)" runtime/lua/vim/lsp/buf.lua -c 'se verbose=9'`
```cpp
#0  __pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
#1  0x00007f07527a2def in __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:78
#2  0x00007f075274ed02 in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#3  0x00007f07527374f0 in __GI_abort () at ./stdlib/abort.c:79
#4  0x00007f0752737418 in __assert_fail_base (fmt=0x7f07528bbca0 "%s%s%s:%u: %s%sAssertion `%s' failed.\n%n", assertion=assertion@entry=0x55d306d0194c "pos_delta >= 0",
    file=file@entry=0x55d306d236b0 ".../src/nvim/message.c", line=line@entry=2476, function=function@entry=0x55d306cd9ef0 <__PRETTY_FUNCTION__.0> "msg_scroll_flush") at ./assert/assert.c:94
#5  0x00007f0752747612 in __assert_fail (assertion=assertion@entry=0x55d306d0194c "pos_delta >= 0", file=file@entry=0x55d306d236b0 ".../src/nvim/message.c", line=line@entry=2476,
    function=function@entry=0x55d306cd9ef0 <__PRETTY_FUNCTION__.0> "msg_scroll_flush") at ./assert/assert.c:103
#6  0x000055d306a3abae in msg_scroll_flush () at .../src/nvim/message.c:2476
#7  0x000055d306b69254 in ui_flush () at .../src/nvim/ui.c:541
#8  0x000055d3068ef9ed in ui_flush () at .../src/nvim/drawscreen.c:404
#9  screen_resize (width=179, height=<optimized out>) at .../src/nvim/drawscreen.c:401
#10 screen_resize (width=<optimized out>, height=<optimized out>) at .../src/nvim/drawscreen.c:269
#11 0x000055d306b64161 in ui_refresh () at .../src/nvim/ui.c:245
#12 0x000055d306b64f98 in ui_remove_cb (ns_id=2, checkerr=true) at .../src/nvim/ui.c:799
#13 0x000055d306b22f06 in state_handle_k_event () at .../src/nvim/state.c:120
#14 0x000055d306a5e6c4 in nv_event (cap=<optimized out>) at .../src/nvim/normal.c:6634
#15 0x000055d306a528e4 in normal_execute (state=0x7ffe3ebf3720, key=<optimized out>) at .../src/nvim/normal.c:1244
#16 0x000055d306b22d9d in state_enter (s=0x7ffe3ebf3720) at .../src/nvim/state.c:102
#17 0x000055d306a4fbd4 in normal_enter (cmdwin=<optimized out>, noexmode=<optimized out>) at .../src/nvim/normal.c:522
#18 0x000055d306865157 in main (argc=<optimized out>, argv=<optimized out>) at .../src/nvim/main.c:651
```
Not sure if worth report while testing on combined commit. On the other side here are only Lua changes so probably both cases can be minimized to something reproducible on master.

Background: I wanted to check at first if WinScrolled autocmds are (rather unwantedly) triggered on the message window. 

author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-01-10T18:18:11Z
body: Thanks for reporting, I'm not too concerned with the second backtrace, which happens after the extui was already forcibly detached due to excessive errors. 'verbose' was not considered yet, good to look into thanks.

First backtrace hits an assert that we want to avoid (recently added to make sure), but is also not too critical. Both will be easier resolve once the message grid is no more but I can check if there is an easy fix in the meantime. 

EDIT: first assert is also hit _after_ the extui was forcibly detached, we don't care about it anymore at that point. We need to reset some variables when first attached and after forced detachment to avoid the second assert. This is tracked in #21056, so marking as resolved. The main thing here will be to avoid the errors in the extui implementation.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-01-11T01:05:22Z
body: Now (after merging master again here) on `nvim --clean -c "lua require('vim._extui').enable(true)" runtime/lua/vim/lsp/buf.lua -c 'se verbose=9'` and hitting Enter enough I get 

```
Error executing vim.schedule lua callback: ...m-src-dev/share/nvim/runtime/lua/vim/_extui/messages.lua:276: Invalid window id:
1015
stack traceback:
        [C]: in function 'nvim_win_set_config'
        ...m-src-dev/share/nvim/runtime/lua/vim/_extui/messages.lua:276: in function 'win_set_pos'
        ...m-src-dev/share/nvim/runtime/lua/vim/_extui/messages.lua:299: in function 'set_pos'
        ...m-src-dev/share/nvim/runtime/lua/vim/_extui/messages.lua:104: in function 'show_msg'
```
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-01-11T01:25:22Z
body: Let's get around the above issue with `verbose` to show the case with `WinScrolled`:

- `nvim --clean -c "lua require('vim._extui').enable(true)" -c 'au WinScrolled * echo rand()'`
- `:echo 'x'`

Is it possible to ignore autocommand events on the message window? I tried to wrap displaying message window and updating its buffer by `vim._with({noautocmd = true},`, but with no result. 

`WinScrolled` with (anti?)pattern `*` appears even in clean Nvim (`call s:Highlight_Matching_Pair()`), but also in plugins like nvim-tresitter-context. 
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-01-11T11:48:30Z
body: Yes, autocommands firing for the extui buffers/windows is probably unwanted, at least publicly (namespaced autocommands/editor objects(window buffer) could still be useful for the event handler itself). Perhaps avoiding that warrants the more general API of marking a window as a UI element which I alluded to in #27984, #31269.

We can ourselves set `'eventignore' = "all"` and restore it in the UI callback, but that doesn't avoid firing `WinScrolled`; which fires after the UI callback has already returned (it's checked every event loop iteration, rather than fired when scrolling actually happens).
author_association: MEMBER
user: login: przepompownia
created_at: 2025-01-11T13:13:58Z
body: Quick check without detailed debugging shows me that
- for some reason set `vim.go.eventignore = 'all'` (and empty after) directly works unlike when set from `vim._with`,
- `OptionSet` seems to be the edge case, for which I cannot avoid displaying verbose messages about firing that event,
- verbosity (at level 9 and probably not only) produces junky `\n` messages with no kind:
![image](https://github.com/user-attachments/assets/cafd6078-9895-4391-83fd-368f94abcb9c)

I'm curious what you'll (or anyone else here) propose for display verbose messages here. At the moment for my playground I consider displaying them in some separate space (buffer and window) to avoid spamming (any) message area. I keep in mind that sometimes it's useful to display all messages together to track the order of appearing, but usually rather not. 

author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-03-10T19:23:53Z
body: After switching to use ext_cmdline I noticed (both here and in my plugin) that wildmenu does not work. Are we forced to implement `ext_popupmenu` behavior too in that case? If so, it involves implementing custom ins-completion menu what I would like to avoid. 
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-03-10T19:42:26Z
body: See #31269 @przepompownia.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-03-11T23:15:25Z
body: Is it currently possible to get the correct cmdline window height in multiline commands, e.g. `:if 1<CR>`? I see that the example is not related to `cmdline_block_*` event.

here:
![image](https://github.com/user-attachments/assets/120e1343-a691-428f-bb77-4747fcecf057)
built-in:
![image](https://github.com/user-attachments/assets/258c680f-4402-43b7-956f-b4efe74d7460)
At this point, it's more out of curiosity than necessity.
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-03-11T23:38:36Z
body: > I see that the example is not related to cmdline_block_* event.

I think that is an oversight/bug. This pattern should emit `cmdline_block` events.
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-04-18T11:03:57Z
body: Marking this as ready for review, but this still depends on #31269.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-19T14:00:05Z
body: Cannot keep `cmdheight=0`. Try `nvim --clean --cmd "lua require('vim._extui').enable({enable = true})" -c 'set cmdheight=0'`. 

This example shows also hidden statusline until switching to cmdline mode. 
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-19T15:08:52Z
body: The recent commit fixed also invisible `echo` message (before I reported it) in the case mentioned above, thanks! 
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-19T17:04:00Z
body: Now with `nvim-src-clean -c "lua require('vim._extui').enable({enable = true})"` (without `set cmdheight`) the cmdline area is invisible (regardless of redrawing).
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-04-19T17:30:48Z
body: Please make sure your runtime actually points to this branch when testing this, because it isn't invisible on my end...
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-19T18:12:42Z
body: NVIM v0.12.0-dev-180+gce26a54c4e, built again and installed after `make disclean` and manual `MAKE_INSTALL_PREFIX` subdirectory removal. 

Below: what I see after `:echom 1` and then `:mes`:

https://github.com/user-attachments/assets/5b0aa307-80a5-4361-9e9c-bea742ae185e

Edit: sorry, I pasted `nvim-src-clean` in my previous post, but it's only a script wrapper for `nvim --clean`. Just to be clear I get the same effect both without `--clean` (like on the screencast) and with it. 
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-04-19T19:47:30Z
body: IDK @przepompownia cannot reproduce. Please try `make && VIMRUNTIME=$(git rev-parse --show-toplevel)/runtime build/bin/nvim --clean +"lua require('vim._extui').enable({})"`
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-19T19:58:10Z
body: > IDK @przepompownia cannot reproduce. Please try `make && VIMRUNTIME=$(git rev-parse --show-toplevel)/runtime build/bin/nvim --clean +"lua require('vim._extui').enable({})"`

The same for me. 

On the other side, at the moment I see that the problem is related to hiding window logic: none of (commented below) conditions is satisfied (unconditional unhiding makes the cmdline window visible) 
![image](https://github.com/user-attachments/assets/73a571e4-112d-4504-8f1a-bfda808e94f6)

(i.e. `ext.cmdheight` is `1`, `api.nvim_win_get_height(win) == height`).
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-04-19T20:23:43Z
body: If cmdheight > 0 the cmdline window is created with `hide = false` and should thus never have to be unhidden in the first place. But I guess the `ext.cmdheight == 0` guard can be removed if some startup conditions cause the window to be created with `hide = true`, it's just there to avoid the win_get_config call...
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-19T22:06:45Z
body: `hide` is enabled inside `tab_check_wins` because `M.cmdheight == 0` (in `shared`).

For me, the `OptionSet` autocmd is fired for `termguicolors` (and then calls `tab_check_wins`) before `VimEnter`, so the command window isn't initialized again from `ui_callback` (and remains hidden).

Maybe `OptionSet` should be defined after executing `VimEnter`.
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-04-19T23:19:26Z
body: I think #33546 will avoid these problems.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-20T01:10:50Z
body: > I think #33546 will avoid these problems.

Good idea, regardless of how much it helps avoid the problem here (especially if we still will have to defer call ui_attach after VimEnter). 
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-21T19:09:59Z
body: The above problem seems to be fixed after #33546 
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-21T19:39:21Z
body: Additionally, I found a problem resulting from my outdated configuration. The repro can be reduced to
```sh
nvim --cmd 'au UIEnter * se ch=0' --cmd "lua require('vim._extui').enable({enable = true})"
```
In this case, the statusline will not be displayed until you enter the cmdline mode. This is irrelevant after moving `se ch`, but probably shouldn't take place.
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-23T21:59:17Z
body: ```lua
vim.schedule(function()
  vim.notify('w', vim.log.levels.WARN)
end)
vim.schedule(function()
  vim.notify('e', vim.log.levels.ERROR)
end)
```
with `set ch=0` displays only `e` for me. Both messages are visible by `:mes`.
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-04-23T23:00:56Z
body: For whatever reason `msg_show->replace_last == true` for the second message. 

The only place I know of where `replace_last` is useful for us is where Nvim sends two message events for an incomplete message to show busy state, later to be appended to (e.g. when writing a file).

But maybe we should just identify those two messages by it's content because I don't think we want to honor `replace_last` anywhere else. It's only useful to exactly mimic the current message grid implementation when it replaces a message that will not be scrolled.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-23T23:30:16Z
body: I didn't look how messages with `search_count`, `bufwrite`, `undo` and `completion` kinds are handled here but found `replace_last` flag useful for those kinds (and nothing else) when started writing my custom plugin. 
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-25T14:12:15Z
body: The recent commit makes the above case working as expected, thanks!
author_association: CONTRIBUTOR
user: login: przepompownia
created_at: 2025-04-25T14:31:08Z
body: Do you consider adding (optional?) LSP progress handler here or (if it could be out of scope of this PR) at least some user documentation how to implement it? 
author_association: CONTRIBUTOR
user: login: luukvbaal
created_at: 2025-04-25T14:55:12Z
body: Definitely seems out of scope to me, I guess you want to re-use the message box for that?
author_association: MEMBER
user: login: przepompownia
created_at: 2025-04-25T15:33:04Z
body: Yes, it would be a pity to waste this opportunity. At the first glance `M.show_msg` allows replacing the recent message, but there is no guarantee that it would be a progress message. I cannot find here a way how to access a message marked before as a special and update it (and probably display it at some fixed position, .e.g. lowest). 
author_association: CONTRIBUTOR
user: login: clason
created_at: 2025-04-25T15:37:19Z
body: Definitely out of scope. Recall the stated problem:

>  We have an unmaintained Vimscript parser and cmdline
highlighting mechanism, with which it is hard to leverage the
treesitter highlighter. Long messages result in a hit-enter-prompt.

Let's get this solved; then we can go from there. This PR has been open for more than a year; please help get it done, not delay it further.

author_association: MEMBER
user: login: justinmk
created_at: 2025-05-01T13:03:52Z
body: I'm trying this out locally. Since it's opt-in currently, there are of course no real blockers to merging, but we definitely want the next step to be to get rid of old code and make this the default.

some drive-by comments:
- [x] `ctrl-c` or something should probably exit the `g>` window? currently requires `ctrl-w c`
- [x] the cmdline border is a bit much, and doesn't seem to respect 'winborder' ?
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-05-01T13:20:35Z
body: The cmdline shouldn't have a border (or respect 'winborder' for now, I think). If it does that's a bug, one that I haven't encountered.

I'll add a mapping for `ctrl-c`.
author_association: MEMBER
user: login: justinmk
created_at: 2025-05-01T14:11:06Z
body: > cmdline shouldn't have a border

repro:

    nvim --clean -c 'set winborder=rounded' -c "lua require('vim._extui').enable({})"
author_association: MEMBER
user: login: luukvbaal
created_at: 2025-05-01T14:13:04Z
body: Thanks, fixed.
author_association: MEMBER
user: login: przepompownia
created_at: 2025-05-02T00:21:10Z
body: > This PR has been open for more than a year; please help get it done, not delay it further.

I also wouldn't mind merging this PR as soon as possible and can create a separate feature request for message updates. 
author_association: CONTRIBUTOR
user: login: garydwatson
created_at: 2025-07-20T16:19:08Z
body: this is probably the wrong place to say this seeing as how this is already merged... however... when enabling this feature by...
require('vim._extui').enable({}) enables the experimental UI.
{ msg.pos = 'box' } or :set cmdheight=0 enables the message

it completely breaks the :ls / :buffers command... the list of buffers has a paged view that can be scrolled up and down either line by line or a page at a time, and futher, you can hit ":" at any point while looking through the list to enter command mode so you could do a ":bd 1 7 8 9 42" while looking at the list...

if you replace that very fully functional view with this, then buffer navigation and management will effectively be broken.
author_association: NONE
user: login: justinmk
created_at: 2025-07-20T16:30:02Z
body: > the :ls / :buffers command

Use `g<` to enter the pager. There will also be ways to change the behavior. And we're still working out the default behavior. 
author_association: MEMBER