feat: floating control bar, throttled console, wrapped values, Codicon icons

Major v3 fixes driven by live-debugging feedback:

- Control bar: replace vim.o.statusline override (lualine fights it) with
  a 1-row floating window pinned above the statusline. Shows state chip
  (READY/RUNNING/PAUSED) + seven clickable Codicon controls + dap.status().
  Survives resize via VimResized autocmd. Fully independent of lualine.
- Console refresh: trailing-edge throttle at config.console_refresh_ms
  (default 50ms = 20 Hz ceiling). Targets console windows via nvim_win_call
  + redraw! to avoid screen-cache miss. Chatty programs can't DOS the editor.
- Modal keys in dapui panes: install synchronously on FileType (drop inner
  vim.schedule); all modal keys get nowait=true (was continue-only) so
  single-key presses aren't swallowed by <leader>d* prefix matching.
- Wrap in dapui panes: wrap=true + breakindent + showbreak="  ↳ " so long
  register values stay visible inside the pane without bleeding into the
  source window. Safety valve at config.max_value_width (default 2000,
  set 0 to disable) truncates only pathological multi-KB dumps with "…".
- Smart q: context-aware terminate. If a session is running, q calls
  dap.terminate() and stays in debug mode (so user can read the console
  postmortem). If no session, q exits debug mode. Removes quit_exits_mode.
- Icons: full Nerd Font Codicon set for signs, control bar buttons, pane
  titles, and debug badge. 👈 emoji in the paused-line vtext pairs with
  👉 in the gutter — fingers literally point at the paused line from both
  sides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-12 12:22:00 -06:00
1 parent be628194ec
commit 8d8dd23b3a
5 files changed
+306 -115

No files matched your search

+218 -86
View File
@@ -6,8 +6,9 @@ local M = {}
local active = false
local active_win = nil
local saved_winbar = nil
local saved_statusline = nil
local saved_laststatus = nil
-- floating control-bar window (independent of lualine)
local bar_win, bar_buf = nil, nil
-- installed[buf][name] = { key = lhs, prev_n = <prev n-mode map>, prev_v = <prev v-mode map> }
local installed = {}
@@ -15,6 +16,47 @@ local installed = {}
local dapui_initialized = false
local vt_initialized = false
-- ─── icon constants (Nerd Font Codicons, byte-escaped) ───────────────────────
-- All in the PUA range U+EA60..U+EC1E. Encoded as UTF-8 bytes so the source
-- file stays ASCII through any tool pipeline that strips PUA codepoints.
local ICON = {
-- controls (match dapui's own controls-panel defaults)
play = "\xee\xab\x98", -- U+EAD8 debug-start
pause = "\xee\xab\x99", -- U+EAD9 debug-pause
step_over = "\xee\xab\x96", -- U+EAD6 debug-step-over
step_into = "\xee\xab\x95", -- U+EAD5 debug-step-into
step_out = "\xee\xab\x94", -- U+EAD4 debug-step-out
step_back = "\xee\xac\xb4", -- U+EB34 debug-step-back
restart = "\xee\xad\x84", -- U+EB44 debug-restart
stop = "\xee\xab\x97", -- U+EAD7 debug-stop
disconnect = "\xee\xab\xb7", -- U+EAF7 debug-disconnect
close = "\xee\xa9\xb6", -- U+EA76 close
-- signs
breakpoint = "\xee\xa9\xb1", -- U+EA71 debug-breakpoint
bp_cond = "\xee\xaa\x9f", -- U+EA9F debug-breakpoint-conditional
bp_rej = "\xee\xaa\xbe", -- U+EABE circle-slash
stackframe = "\xee\xae\x8b", -- U+EB8B debug-stackframe
logpoint = "\xee\xaf\xa4", -- U+EBE4 debug-breakpoint-log
-- pane titles
scopes = "\xee\xaa\x8f", -- U+EA8F variable-group
watches = "\xee\xa9\xb0", -- U+EA70 eye
stacks = "\xee\xae\x83", -- U+EB83 list-tree
terminal = "\xee\xaa\x85", -- U+EA85 terminal
chevron = "\xee\xaa\xb6", -- U+EAB6 chevron-right
-- debug badge (bug-in-circle)
debug_alt = "\xee\xae\x91", -- U+EB91 debug-alt
-- misc
ip_left = "\xf0\x9f\x91\x88", -- 👈 backhand index pointing left (U+1F448)
divider = "\xc2\xb7", -- · middle dot
ellipsis = "\xe2\x80\xa6", -- … horizontal ellipsis
wrap_mark = "\xe2\x86\xb3", -- ↳ downward-arrow-with-tip-rightwards
}
-- ─── layout ──────────────────────────────────────────────────────────────────
local function build_default_layouts()
@@ -48,9 +90,12 @@ local function define_highlights()
hl(0, "TurboDebugBorderActive", { default = true, link = "Function" })
hl(0, "TurboDebugBorderInactive", { default = true, link = "Comment" })
hl(0, "TurboDebugWinbar", { default = true, link = "DiagnosticError" })
hl(0, "TurboDebugSLState", { default = true, link = "DiagnosticError" })
hl(0, "TurboDebugSLCtrl", { default = true, link = "Function" })
hl(0, "TurboDebugSLStatus", { default = true, link = "Comment" })
hl(0, "TurboDebugBar", { default = true, link = "StatusLine" })
hl(0, "TurboDebugBarCtrl", { default = true, link = "Function" })
hl(0, "TurboDebugBarStatus", { default = true, link = "Comment" })
hl(0, "TurboDebugBarReady", { default = true, link = "DiagnosticHint" })
hl(0, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" })
hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" })
end
local function setup_active_win_highlights()
@@ -76,79 +121,168 @@ local function setup_active_win_highlights()
})
end
-- ─── global control buttons (used by the statusline) ────────────────────────
-- ─── global control buttons (referenced by bar winbar expression) ───────────
_G.turbo_debug_continue = function() M.actions().continue() end
_G.turbo_debug_step_over = function() require("dap").step_over() end
_G.turbo_debug_step_into = function() require("dap").step_into() end
_G.turbo_debug_step_out = function() require("dap").step_out() end
_G.turbo_debug_restart = function() require("dap").restart() end
_G.turbo_debug_terminate = function() require("dap").terminate() end
_G.turbo_debug_terminate = function() M.actions().terminate() end
_G.turbo_debug_toggle = function() M.toggle() end
function _G.turbo_debug_statusline()
function _G.turbo_debug_bar()
local dap_ok, dap = pcall(require, "dap")
local state, state_hl
if not dap_ok then
state, state_hl = "READY", "TurboDebugSLStatus"
state, state_hl = "READY", "TurboDebugBarReady"
else
local s = dap.session()
if not s then
state, state_hl = "READY", "TurboDebugSLStatus"
state, state_hl = "READY", "TurboDebugBarReady"
elseif s.stopped_thread_id then
state, state_hl = "PAUSED", "TurboDebugSLState"
state, state_hl = "PAUSED", "TurboDebugBarPaused"
else
state, state_hl = "RUNNING", "TurboDebugSLCtrl"
state, state_hl = "RUNNING", "TurboDebugBarRunning"
end
end
-- ● state, then 6 clickable controls, then dap progress text (right-aligned)
local parts = {
"%#" .. state_hl .. "# \xe2\x97\x8f " .. state .. " %*",
"%#" .. state_hl .. "# " .. ICON.debug_alt .. " " .. state .. " %*",
" ",
"%#TurboDebugSLCtrl#",
"%@v:lua.turbo_debug_continue@ \xe2\x96\xb6 %X", -- ▶ continue
"%@v:lua.turbo_debug_step_over@ \xe2\x87\x92 %X", -- ⇒ step over
"%@v:lua.turbo_debug_step_into@ \xe2\xa4\x93 %X", -- ⤓ step into
"%@v:lua.turbo_debug_step_out@ \xe2\xa4\x92 %X", -- ⤒ step out
"%@v:lua.turbo_debug_restart@ \xe2\x86\xbb %X", -- ↻ restart
"%@v:lua.turbo_debug_terminate@ \xe2\x96\xa0 %X", -- ■ terminate
"%@v:lua.turbo_debug_toggle@ \xe2\x8a\x97 %X", -- ⊗ exit debug mode
"%#TurboDebugBarCtrl#",
"%@v:lua.turbo_debug_continue@ " .. ICON.play .. " %X",
"%@v:lua.turbo_debug_step_over@ " .. ICON.step_over .. " %X",
"%@v:lua.turbo_debug_step_into@ " .. ICON.step_into .. " %X",
"%@v:lua.turbo_debug_step_out@ " .. ICON.step_out .. " %X",
"%@v:lua.turbo_debug_restart@ " .. ICON.restart .. " %X",
"%@v:lua.turbo_debug_terminate@ " .. ICON.stop .. " %X",
"%@v:lua.turbo_debug_toggle@ " .. ICON.close .. " %X",
"%*",
" %=",
"%#TurboDebugSLStatus#%{v:lua.require'dap'.status()}%*",
"%#TurboDebugBarStatus#%{v:lua.require'dap'.status()}%*",
" %l:%c ",
}
return table.concat(parts, "")
end
-- ─── floating control bar ───────────────────────────────────────────────────
local function bar_position()
-- sit one row above the statusline; statusline is at (lines - cmdheight - 1).
-- Our bar goes at (lines - cmdheight - 2). If laststatus == 0 there's no
-- statusline, so sit at the bottom (lines - cmdheight - 1).
local row
if vim.o.laststatus > 0 then
row = vim.o.lines - vim.o.cmdheight - 2
else
row = vim.o.lines - vim.o.cmdheight - 1
end
if row < 0 then row = 0 end
return {
relative = "editor",
row = row,
col = 0,
width = vim.o.columns,
height = 1,
style = "minimal",
border = "none",
focusable = false,
noautocmd = true,
zindex = 50,
}
end
local function open_bar()
if bar_win and vim.api.nvim_win_is_valid(bar_win) then return end
bar_buf = vim.api.nvim_create_buf(false, true)
vim.bo[bar_buf].bufhidden = "wipe"
bar_win = vim.api.nvim_open_win(bar_buf, false, bar_position())
vim.wo[bar_win].winbar = "%!v:lua.turbo_debug_bar()"
vim.wo[bar_win].winhighlight = "Normal:TurboDebugBar,WinBar:TurboDebugBar,WinBarNC:TurboDebugBar"
vim.wo[bar_win].winfixheight = true
vim.wo[bar_win].list = false
vim.wo[bar_win].number = false
vim.wo[bar_win].relativenumber = false
vim.wo[bar_win].signcolumn = "no"
end
local function close_bar()
if bar_win and vim.api.nvim_win_is_valid(bar_win) then
pcall(vim.api.nvim_win_close, bar_win, true)
end
bar_win, bar_buf = nil, nil
end
local function reposition_bar()
if bar_win and vim.api.nvim_win_is_valid(bar_win) then
pcall(vim.api.nvim_win_set_config, bar_win, bar_position())
end
end
-- ─── dap-ui setup ────────────────────────────────────────────────────────────
local function set_dapui_window_opts(win)
-- dapui elements render long single-line values (e.g. register dumps) that
-- bleed into adjacent windows when wrap is on. Force-disable wrap and
-- horizontal-scroll padding so values stay inside the pane.
-- Wrap long values inside the pane so register dumps etc. don't bleed into
-- adjacent windows. breakindent + showbreak make continuation lines visually
-- attached to their parent. The `format_value` wrap below truncates only
-- pathologically-long values.
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].wrap = false
vim.wo[win].wrap = true
vim.wo[win].breakindent = true
vim.wo[win].linebreak = true
vim.wo[win].showbreak = " " .. ICON.wrap_mark .. " "
vim.wo[win].sidescrolloff = 0
vim.wo[win].linebreak = false
vim.wo[win].cursorline = true
vim.wo[win].cursorline = true
end
end
local function patch_format_value()
local ok, util = pcall(require, "dapui.util")
if not ok or not util or type(util.format_value) ~= "function" then return end
if util._turbo_debug_wrapped then return end
local orig = util.format_value
util.format_value = function(value_start, value)
if type(value) ~= "string" then return orig(value_start, value) end
local cap = config.opts.max_value_width or 2000
if cap <= 0 or #value <= cap then return orig(value_start, value) end
return orig(value_start, value:sub(1, cap - 1) .. ICON.ellipsis)
end
util._turbo_debug_wrapped = true
end
local redraw_pending = false
local function schedule_console_redraw()
if redraw_pending then return end
redraw_pending = true
vim.defer_fn(function()
redraw_pending = false
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].filetype == "dapui_console" then
for _, win in ipairs(vim.fn.win_findbuf(buf)) do
if vim.api.nvim_win_is_valid(win) then
pcall(vim.api.nvim_win_call, win, function() vim.cmd("redraw!") end)
end
end
end
end
end, config.opts.console_refresh_ms or 50)
end
local function ensure_dapui()
if dapui_initialized then return end
dapui_initialized = true
local dapui_opts = vim.deepcopy(config.opts.dapui)
if not dapui_opts.layouts then dapui_opts.layouts = build_default_layouts() end
require("dapui").setup(dapui_opts)
patch_format_value()
local dap = require("dap")
dap.listeners.after.event_initialized["turbo-debug"] = function()
require("dapui").open()
end
-- K = hover during a live dap session (works globally, not just modally)
-- K = hover during a live dap session (global, not per-buffer)
dap.listeners.after.event_initialized["turbo-debug-hover"] = function()
vim.keymap.set("n", "K", function()
require("dap.ui.widgets").hover(nil, { border = "rounded" })
@@ -158,18 +292,11 @@ local function ensure_dapui()
pcall(vim.keymap.del, "n", "K")
end
-- Console redraws: dapui's console buffer updates on event_output but
-- nvim doesn't always repaint the window until focus changes. Force a
-- schedule-wrapped redraw so output appears live.
dap.listeners.after.event_output["turbo-debug-redraw"] = vim.schedule_wrap(function()
pcall(vim.cmd.redraw)
end)
-- console refresh: trailing-edge throttled so chatty programs don't DOS us
dap.listeners.after.event_output["turbo-debug-redraw"] = schedule_console_redraw
-- IP marker: paired-arrow design.
-- gutter: ▶ (points RIGHT at the line from the sign column)
-- eol: ◀ (points LEFT at the line from the end of content)
-- The uppercase reason (STEP / BREAKPOINT / EXCEPTION / PAUSE / ENTRY)
-- renders in bold TurboDebugIPReason so it jumps off the screen.
-- IP marker: paired arrows. Gutter sign ▶ + EOL vtext ◀ REASON flanking the
-- paused line.
local ip_ns = vim.api.nvim_create_namespace("turbo-debug.ip")
local function clear_ip()
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
@@ -189,8 +316,8 @@ local function ensure_dapui()
local reason = ((body and body.reason) or "stopped"):upper()
pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, {
virt_text = {
{ " \xe2\x97\x80 ", "DapStopped" }, -- ◀
{ reason, "TurboDebugIPReason" },
{ " " .. ICON.ip_left .. " ", "DapStopped" },
{ reason, "TurboDebugIPReason" },
},
virt_text_pos = "eol",
hl_mode = "combine",
@@ -202,19 +329,22 @@ local function ensure_dapui()
dap.listeners.before.event_terminated["turbo-debug-ip"] = function() clear_ip() end
dap.listeners.before.event_exited["turbo-debug-ip"] = function() clear_ip() end
-- dapui winbar titles + window options (wrap=false to stop the register
-- overflow bug). Apply on FileType so we catch every pane dapui creates.
-- winbar titles on dapui panes (Codicons, byte-escaped)
local titles = {
dapui_scopes = " \xe2\x97\x86 Scopes",
dapui_watches = " \xe2\x97\x89 Watches",
dapui_stacks = " \xe2\x89\xa1 Call Stack",
dapui_breakpoints = " \xe2\x97\x8f Breakpoints",
dapui_console = " \xe2\x9d\xaf Console",
["dap-repl"] = " \xe2\x9d\xaf_ REPL",
dapui_scopes = " " .. ICON.scopes .. " Scopes",
dapui_watches = " " .. ICON.watches .. " Watches",
dapui_stacks = " " .. ICON.stacks .. " Call Stack",
dapui_breakpoints = " " .. ICON.breakpoint .. " Breakpoints",
dapui_console = " " .. ICON.terminal .. " Console",
["dap-repl"] = " " .. ICON.chevron .. " REPL",
}
vim.api.nvim_create_autocmd("FileType", {
pattern = { "dapui_*", "dap-repl" },
callback = function(args)
-- install keys synchronously (don't defer) so they're ready the moment
-- the buffer is usable. dapui's own `apply_mapping` pass only touches
-- <CR>/o/d/e/r/t; we don't conflict except on d and r which we skip.
if active then M._install_for_buf(args.buf) end
local ft = vim.bo[args.buf].filetype
local title = titles[ft]
vim.schedule(function()
@@ -223,10 +353,6 @@ local function ensure_dapui()
if title then vim.wo[win].winbar = title end
set_dapui_window_opts(win)
end
-- also (re-)install modal keys on this dapui buffer — BufEnter may
-- not fire at create time and we want the keys ready the moment
-- the user clicks in.
if active then M._install_for_buf(args.buf) end
end)
end,
})
@@ -260,7 +386,6 @@ local function launch_with_stop_on_entry()
local ft = vim.bo.filetype or ""
local cfgs = dap.configurations[ft]
if not (cfgs and cfgs[1]) then
-- no config → fall back to normal continue (which will prompt / error)
dap.continue()
return
end
@@ -281,8 +406,6 @@ local function smart_continue()
end
local function step_or_launch(step_fn)
-- if no session yet, a step key starts the session with stop-on-entry —
-- matches the Turbo Pascal "just press a key to start stepping" vibe.
if require("dap").session() then
step_fn()
else
@@ -290,6 +413,18 @@ local function step_or_launch(step_fn)
end
end
local function smart_terminate()
-- q is context-aware: if a session is running, kill it and stay in debug
-- mode so the user can read the console / post-mortem. If no session is
-- running, q exits debug mode entirely.
local dap = require("dap")
if dap.session() then
pcall(dap.terminate)
else
M.exit()
end
end
local function setup_actions()
if actions_cache then return actions_cache end
local dap = require("dap")
@@ -302,10 +437,7 @@ local function setup_actions()
step_out = function() step_or_launch(dap.step_out) end,
run_to_cursor = function() dap.run_to_cursor() end,
restart = function() dap.restart() end,
terminate = function()
dap.terminate()
if config.opts.quit_exits_mode then M.exit() end
end,
terminate = smart_terminate,
help = function()
if help.is_open() then help.close() else help.open() end
end,
@@ -356,18 +488,22 @@ end
function M._install_for_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) then return end
local acts = setup_actions()
local ft = vim.bo[buf].filetype or ""
local is_dapui = ft:match("^dapui_") or ft == "dap-repl"
installed[buf] = installed[buf] or {}
for name, key in pairs(config.opts.keys) do
if key and acts[name] and not installed[buf][name] then
local prev_n = find_buf_mapping(buf, key, "n")
local prev_v = visual_actions[name] and find_buf_mapping(buf, key, "v") or nil
local opts = { buffer = buf, silent = true, desc = "turbo-debug: " .. name }
if name == "continue" then opts.nowait = true end
pcall(vim.keymap.set, "n", key, acts[name], opts)
if visual_actions[name] then
pcall(vim.keymap.set, "v", key, acts[name], opts)
-- yield d/r to dapui's native remove/repl inside its own panes
if not (is_dapui and (name == "step_into" or name == "step_out")) then
local prev_n = find_buf_mapping(buf, key, "n")
local prev_v = visual_actions[name] and find_buf_mapping(buf, key, "v") or nil
local opts = { buffer = buf, silent = true, nowait = true, desc = "turbo-debug: " .. name }
pcall(vim.keymap.set, "n", key, acts[name], opts)
if visual_actions[name] then
pcall(vim.keymap.set, "v", key, acts[name], opts)
end
installed[buf][name] = { key = key, prev_n = prev_n, prev_v = prev_v }
end
installed[buf][name] = { key = key, prev_n = prev_n, prev_v = prev_v }
end
end
end
@@ -388,17 +524,12 @@ local function clear_all_modal_keys()
installed = {}
end
-- ─── chrome: per-source-window winbar badge + global statusline controls ──
-- ─── source-window debug chrome ─────────────────────────────────────────────
local function set_debug_chrome()
active_win = vim.api.nvim_get_current_win()
saved_winbar = vim.wo[active_win].winbar
vim.wo[active_win].winbar = "%#TurboDebugWinbar# \xe2\x97\x8f DEBUG %* %f"
saved_statusline = vim.o.statusline
saved_laststatus = vim.o.laststatus
vim.o.laststatus = 3 -- one global statusline
vim.o.statusline = "%!v:lua.turbo_debug_statusline()"
vim.wo[active_win].winbar = "%#TurboDebugWinbar# " .. ICON.debug_alt .. " DEBUG %* " .. ICON.divider .. " %f"
end
local function clear_debug_chrome()
@@ -407,11 +538,6 @@ local function clear_debug_chrome()
end
active_win = nil
saved_winbar = nil
if saved_statusline ~= nil then vim.o.statusline = saved_statusline end
if saved_laststatus ~= nil then vim.o.laststatus = saved_laststatus end
saved_statusline = nil
saved_laststatus = nil
end
-- ─── public API ─────────────────────────────────────────────────────────────
@@ -422,13 +548,17 @@ function M.enter()
define_highlights()
setup_actions()
vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true })
local group = vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true })
vim.api.nvim_create_autocmd("BufEnter", {
group = "TurboDebugModalKeys",
group = group,
callback = function(args)
if active then M._install_for_buf(args.buf) end
end,
})
vim.api.nvim_create_autocmd("VimResized", {
group = group,
callback = reposition_bar,
})
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end
end
@@ -436,14 +566,15 @@ function M.enter()
set_debug_chrome()
ensure_dapui()
require("dapui").open()
open_bar()
-- second install pass after dapui has created its buffers, since BufEnter
-- doesn't always fire for windows that were opened without focus-change.
-- second install pass after dapui has created its buffers
vim.schedule(function()
if not active then return end
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then M._install_for_buf(buf) end
end
reposition_bar()
end)
ensure_vt()
@@ -461,6 +592,7 @@ function M.exit()
pcall(vim.api.nvim_del_augroup_by_name, "TurboDebugModalKeys")
clear_all_modal_keys()
clear_debug_chrome()
close_bar()
help.close()
if dapui_initialized then require("dapui").close() end