local config = require("turbo-debug.config") local help = require("turbo-debug.help") local M = {} local active = false local active_win = nil local saved_winbar = nil -- Forward declaration so listeners registered in ensure_dapui (which is -- defined earlier in the file than source_window) can still reference it -- as an upvalue rather than a nil global. local source_window -- two floating chrome bars (both independent of lualine): -- sbar = status bar pinned at the TOP of the editor. -- Row 0: turbo-debug · {dap status} · DEBUG · STATE -- Row 1: ─── separator -- cbar = control bar pinned just above the statusline. -- Row 0: ─── separator -- Row 1: {controls} ❓ help local sbar_win, sbar_buf = nil, nil local cbar_win, cbar_buf = nil, nil -- installed[buf][name] = { key = lhs, prev_n = , prev_v = } 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 = { -- Control-bar emoji — strictly supplementary-plane (U+1F000+) or -- standalone-emoji-default (like 🛑) codepoints. No VS16-dependent -- glyphs: those render as text-mode in half of modern terminals, which -- is exactly the "some icons are blocks, some are chars" inconsistency -- the last iteration had. Every icon here is guaranteed to render as a -- colored emoji in Ghostty + any modern terminal with emoji support. go = "\xf0\x9f\x9f\xa2", -- 🟢 green circle (U+1F7E2) — go / continue / running start_rkt = "\xf0\x9f\x9a\x80", -- 🚀 rocket (U+1F680) — launch a new session step_over = "\xf0\x9f\x91\xa3", -- 👣 footprints (U+1F463) — step step_into = "\xf0\x9f\x94\xbd", -- 🔽 down-pointing red triangle (U+1F53D) — into step_out = "\xf0\x9f\x94\xbc", -- 🔼 up-pointing red triangle (U+1F53C) — out restart = "\xf0\x9f\x94\x84", -- 🔄 counterclockwise arrows (U+1F504). ♻️ -- (U+267B + VS16) doesn't render in the -- user's font — VS16 forces emoji only -- when the font has an emoji glyph, and -- many don't. Supplementary plane is -- guaranteed color. stop = "\xf0\x9f\x9b\x91", -- 🛑 octagonal stop sign (U+1F6D1) -- pane-title emoji (supplementary plane — reliably colorful) scopes = "\xf0\x9f\x94\x8e", -- 🔎 magnifier (U+1F50E) watches = "\xf0\x9f\x91\x81\xef\xb8\x8f", -- 👁️ eye (U+1F441 + VS16) stacks = "\xf0\x9f\x93\x9a", -- 📚 books (U+1F4DA) breakpoints = "\xf0\x9f\x94\xb4", -- 🔴 red circle (U+1F534) terminal = "\xf0\x9f\x92\xbb", -- 💻 laptop (U+1F4BB) repl = "\xf0\x9f\x92\xac", -- 💬 speech balloon (U+1F4AC) -- brand / state emoji bug = "\xf0\x9f\x90\x9b", -- 🐛 bug (U+1F41B) help = "\xe2\x9d\x93", -- ❓ black question mark ornament -- (U+2753). It's BMP but is -- Emoji_Presentation by default, -- so modern terminals render it -- as color. The previous attempt -- appeared broken only because -- of a misaligned extmark; with -- that gone, ❓ renders fine. -- IP arrows ip_left = "\xf0\x9f\x91\x88", -- 👈 backhand pointing left (U+1F448) -- misc divider = "\xc2\xb7", -- · middle dot ellipsis = "\xe2\x80\xa6", -- … horizontal ellipsis wrap_mark = "\xe2\x86\xb3", -- ↳ downward arrow w/ tip right hline = "\xe2\x94\x80", -- ─ light horizontal } -- ─── layout ────────────────────────────────────────────────────────────────── local function build_default_layouts() local pos = config.opts.sidebar == "right" and "right" or "left" -- Console height: max(8 rows, 20% of total editor lines). More -- generous than the previous 15% — user reported the Console kept -- feeling "laughably small" so we're allocating real estate. local console_size = config.opts.console_height or math.max(8, math.floor(vim.o.lines * 0.2)) -- Sidebar width: ideal 40 cols, never less than 25, never more than 1/3 -- of tty width. Scales down gracefully on narrow terminals, stops at 40 -- on wide ones so the source area stays the star. local sidebar_size = math.max(25, math.min(40, math.floor(vim.o.columns / 3))) return { { position = pos, size = sidebar_size, -- Order: Call Stack → Scopes → Watches → Breakpoints. -- Equal 25% share per user preference. elements = { { id = "stacks", size = 0.25 }, { id = "scopes", size = 0.25 }, { id = "watches", size = 0.25 }, { id = "breakpoints", size = 0.25 }, }, }, { -- Console only. REPL removed — hit E in debug mode to open it -- as a floating widget when needed. position = "bottom", size = console_size, elements = { { id = "console", size = 1.0 }, }, }, } end -- ─── highlights ────────────────────────────────────────────────────────────── local function define_highlights() local hl = vim.api.nvim_set_hl hl(0, "TurboDebugBorderActive", { default = true, link = "Function" }) hl(0, "TurboDebugBorderInactive", { default = true, link = "Comment" }) hl(0, "TurboDebugWinbar", { default = true, link = "DiagnosticError" }) -- Bar background: link to Normal so the bar blends with dapui's own -- pane borders (which use Normal or WinBar — both share the editor's -- default background). StatusLine was too dark and created a visible -- seam between the bar and the surrounding panes. hl(0, "TurboDebugBar", { default = true, link = "Normal" }) -- WinSeparator (not FloatBorder) so the bar edges match the dividers -- dapui draws between its own panes. FloatBorder tends to read darker -- than WinSeparator in most colorschemes, which created a visible -- mismatch between our bar's bottom and the adjacent dapui pane top. hl(0, "TurboDebugBarSeparator", { default = true, link = "WinSeparator" }) -- Control labels: link to Normal so the text blends with the bar's -- Normal-bg surface. StatusLine bg was creating a visible color patch -- behind each label. The key letter still pops via TurboDebugBarKey -- (Special fg, bold), and icons carry their own emoji color. hl(0, "TurboDebugBarCtrl", { default = true, link = "Normal" }) hl(0, "TurboDebugBarStatus", { default = true, link = "Comment" }) hl(0, "TurboDebugBarBrand", { default = true, link = "Title" }) hl(0, "TurboDebugBarReady", { default = true, link = "DiagnosticHint" }) hl(0, "TurboDebugBarRunning", { default = true, link = "DiagnosticInfo" }) hl(0, "TurboDebugBarPaused", { default = true, link = "DiagnosticError" }) -- The key letter inside (c)ontinue etc. gets bold + Special's fg. -- No underline — the descender on lowercase q/g/p/y overlaps the -- underline and makes the glyph unreadable (q reads as g, etc.). -- nvim_set_hl can't combine link + bold, so copy Special's fg explicitly. -- Re-applied on ColorScheme so theme swaps don't blank it out. local function apply_key_hl() local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "Special", link = false }) if not ok or not src or not src.fg then vim.api.nvim_set_hl(0, "TurboDebugBarKey", { default = true, link = "Special" }) return end vim.api.nvim_set_hl(0, "TurboDebugBarKey", { fg = src.fg, bg = src.bg, bold = true, default = true, }) end apply_key_hl() vim.api.nvim_create_autocmd("ColorScheme", { group = vim.api.nvim_create_augroup("TurboDebugBarKeyHL", { clear = true }), callback = apply_key_hl, }) end local function setup_active_win_highlights() local active_hl = "WinSeparator:TurboDebugBorderActive,FloatBorder:TurboDebugBorderActive" local inactive_hl = "WinSeparator:TurboDebugBorderInactive,FloatBorder:TurboDebugBorderInactive" local group = vim.api.nvim_create_augroup("TurboDebugActiveWin", { clear = true }) local function apply(win, hl) if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_config(win).relative == "" then local ft = vim.bo[vim.api.nvim_win_get_buf(win)].filetype or "" if ft:match("^dapui_") or ft == "dap-repl" then vim.wo[win].winhighlight = hl end end end vim.api.nvim_create_autocmd("WinEnter", { group = group, callback = function() apply(vim.api.nvim_get_current_win(), active_hl) end, }) vim.api.nvim_create_autocmd("WinLeave", { group = group, callback = function() apply(vim.api.nvim_get_current_win(), inactive_hl) end, }) end -- ─── chrome bars (top = status, bottom = controls) ───────────────────────── -- -- Both bars are floating windows with buffer-rendered content (winbar -- doesn't render reliably in minimal-style floats). Highlights via -- extmarks; mouse click dispatch via buffer-local keymap + -- per-zone display-column lookup. local bar_ns = vim.api.nvim_create_namespace("turbo-debug.bar") -- click zones, one table per bar. Each entry: { line_1based, dcol_start, dcol_end, fn } local sbar_zones = {} local cbar_zones = {} local function dap_state() local ok, dap = pcall(require, "dap") if not ok then return "READY", "TurboDebugBarReady" end local s = dap.session() if not s then return "READY", "TurboDebugBarReady" end if s.stopped_thread_id then return "PAUSED", "TurboDebugBarPaused" end return "RUNNING", "TurboDebugBarRunning" end local function action(name) return function() local a = M.actions(); if a[name] then a[name]() end end end local function keyof(name, fallback) local k = config.opts.keys[name] if type(k) == "string" then return k end return fallback end -- Build a label's byte-level representation: returns { text, hl_spans, key_col, key_end_col } -- Format: " ()" e.g. ` (c)ontinue` with byte offsets for highlights. local function build_control_text(icon, key, tail) local parts = {} local function add(s) parts[#parts + 1] = s end local function col() return #table.concat(parts) end add(icon); add(" ") add("(") local key_col = col() add(key) local key_end_col = col() add(")") add(tail or "") return table.concat(parts), key_col, key_end_col end local function render_sbar() if not (sbar_buf and vim.api.nvim_buf_is_valid(sbar_buf)) then return end if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then return end local width = vim.api.nvim_win_get_width(sbar_win) if width < 1 then width = vim.o.columns end local sep = string.rep(ICON.hline, width) local state, state_hl = dap_state() -- layout on content row: [brand] [status msg] [state chip] local brand_text = " turbo-debug " local status_ok, status_msg = pcall(function() return require("dap").status() end) if not status_ok then status_msg = "" end local state_chip = " DEBUG " .. ICON.divider .. " " .. state .. " " local brand_dw = vim.fn.strdisplaywidth(brand_text) local chip_dw = vim.fn.strdisplaywidth(state_chip) local status_dw = vim.fn.strdisplaywidth(status_msg) local available = width - brand_dw - chip_dw - 4 if status_dw > available and available > 5 then status_msg = status_msg:sub(1, available - 1) .. ICON.ellipsis status_dw = vim.fn.strdisplaywidth(status_msg) elseif available <= 5 then status_msg = "" status_dw = 0 end local remain = width - brand_dw - status_dw - chip_dw if remain < 2 then remain = 2 end local pad_left = math.floor(remain / 2) local pad_right = remain - pad_left local content_line = brand_text .. string.rep(" ", pad_left) .. status_msg .. string.rep(" ", pad_right) .. state_chip -- 2 rows: sep / content. No bottom separator — dapui renders its own -- pane border above its top elements, which serves as our bottom -- edge. Drawing ours too was producing two stacked separator rows. vim.bo[sbar_buf].modifiable = true vim.api.nvim_buf_set_lines(sbar_buf, 0, -1, false, { sep, content_line }) vim.bo[sbar_buf].modifiable = false vim.api.nvim_buf_clear_namespace(sbar_buf, bar_ns, 0, -1) pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 0, 0, { end_row = 0, end_col = #sep, hl_group = "TurboDebugBarSeparator", }) -- content pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, 0, { end_row = 1, end_col = #brand_text, hl_group = "TurboDebugBarBrand", }) if status_dw > 0 then local status_byte_start = #brand_text + pad_left pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, status_byte_start, { end_row = 1, end_col = status_byte_start + #status_msg, hl_group = "TurboDebugBarStatus", }) end local chip_byte_start = #content_line - #state_chip pcall(vim.api.nvim_buf_set_extmark, sbar_buf, bar_ns, 1, chip_byte_start, { end_row = 1, end_col = #content_line, hl_group = state_hl, }) sbar_zones = {} end local function render_cbar() if not (cbar_buf and vim.api.nvim_buf_is_valid(cbar_buf)) then return end if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then return end local width = vim.api.nvim_win_get_width(cbar_win) if width < 1 then width = vim.o.columns end local sep = string.rep(ICON.hline, width) local state, _ = dap_state() local help_text = ICON.help .. " help " local help_dw = vim.fn.strdisplaywidth(help_text) -- Modal control labels. State-dependent text so the label shows what -- the key will ACTUALLY do right now: -- no session: (c) start (q)uit (Restart hidden) -- active: (c)ontinue (q) terminate (R)estart shown local has_session = state ~= "READY" local continue_icon = has_session and ICON.go or ICON.start_rkt local continue_tail = has_session and "ontinue" or " start" local quit_tail = has_session and " terminate" or "uit" local controls = { { icon = continue_icon, key = keyof("continue", "c"), tail = continue_tail, fn = action("continue") }, { icon = ICON.step_over, key = keyof("step_over", "s"), tail = "tep", fn = action("step_over") }, { icon = ICON.step_into, key = keyof("step_into", "d"), tail = "escend", fn = action("step_into") }, { icon = ICON.step_out, key = keyof("step_out", "r"), tail = "eturn", fn = action("step_out") }, } -- (R)estart is only meaningful during an active session if has_session then controls[#controls + 1] = { icon = ICON.restart, key = keyof("restart", "R"), tail = "estart", fn = action("restart") } end controls[#controls + 1] = { icon = ICON.stop, key = keyof("terminate", "q"), tail = quit_tail, fn = action("terminate") } local ctrl_parts = {} local ctrl_spans = {} local ctrl_key_spans = {} local ctrl_zones_bytes = {} local gap = " " for i, c in ipairs(controls) do local prefix_len = #table.concat(ctrl_parts) if i > 1 then ctrl_parts[#ctrl_parts + 1] = gap prefix_len = prefix_len + #gap end local txt, kcol, kend = build_control_text(c.icon, c.key, c.tail) ctrl_parts[#ctrl_parts + 1] = txt ctrl_spans[#ctrl_spans + 1] = { prefix_len, prefix_len + #txt, "TurboDebugBarCtrl" } ctrl_key_spans[#ctrl_key_spans + 1] = { prefix_len + kcol, prefix_len + kend } ctrl_zones_bytes[#ctrl_zones_bytes + 1] = { prefix_len, prefix_len + #txt, c.fn } end local controls_text = table.concat(ctrl_parts) local controls_dw = vim.fn.strdisplaywidth(controls_text) -- controls centered between left edge and help (right anchor) local ctrl_zone_width = width - help_dw local ctrl_pad_left = math.floor((ctrl_zone_width - controls_dw) / 2) if ctrl_pad_left < 1 then ctrl_pad_left = 1 end local ctrl_pad_right = ctrl_zone_width - controls_dw - ctrl_pad_left if ctrl_pad_right < 1 then ctrl_pad_right = 1 end local content_line = string.rep(" ", ctrl_pad_left) .. controls_text .. string.rep(" ", ctrl_pad_right) .. help_text -- 2 rows: content / sep (no top separator — the source/dapui area -- above has its own pane borders as the visual break) vim.bo[cbar_buf].modifiable = true vim.api.nvim_buf_set_lines(cbar_buf, 0, -1, false, { content_line, sep }) vim.bo[cbar_buf].modifiable = false vim.api.nvim_buf_clear_namespace(cbar_buf, bar_ns, 0, -1) -- bottom separator (on row 1 now since content is on row 0) pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 1, 0, { end_row = 1, end_col = #sep, hl_group = "TurboDebugBarSeparator", }) -- control labels on row 0 (content row) local ctrl_byte_offset = ctrl_pad_left for _, span in ipairs(ctrl_spans) do pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], { end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = span[3], }) end for _, span in ipairs(ctrl_key_spans) do pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, ctrl_byte_offset + span[1], { end_row = 0, end_col = ctrl_byte_offset + span[2], hl_group = "TurboDebugBarKey", }) end -- help (right-anchored) on row 0 local help_byte_start = #content_line - #help_text pcall(vim.api.nvim_buf_set_extmark, cbar_buf, bar_ns, 0, help_byte_start, { end_row = 0, end_col = #content_line, hl_group = "TurboDebugBarCtrl", }) cbar_zones = {} for _, z in ipairs(ctrl_zones_bytes) do local byte_start = ctrl_byte_offset + z[1] local byte_end = ctrl_byte_offset + z[2] local dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, byte_start)) local dcol_end = vim.fn.strdisplaywidth(content_line:sub(1, byte_end)) -- content is on buffer row 0 = window line 1 cbar_zones[#cbar_zones + 1] = { 1, dcol_start, dcol_end, z[3] } end local help_dcol_start = vim.fn.strdisplaywidth(content_line:sub(1, help_byte_start)) local help_dcol_end = vim.fn.strdisplaywidth(content_line) cbar_zones[#cbar_zones + 1] = { 1, help_dcol_start, help_dcol_end, function() require("turbo-debug.help").open() end } end local function render_bars() render_sbar() render_cbar() end -- The bars are SPLITS, not floats. Splits consume real layout rows so -- dapui and source windows tuck underneath them naturally — no overlap. local function handle_sbar_click() local pos = vim.fn.getmousepos() if not pos or pos.winid ~= sbar_win then return end for _, zone in ipairs(sbar_zones) do if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then zone[4]() return end end end local function handle_cbar_click() local pos = vim.fn.getmousepos() if not pos or pos.winid ~= cbar_win then return end for _, zone in ipairs(cbar_zones) do if zone[1] == pos.line and pos.wincol - 1 >= zone[2] and pos.wincol - 1 < zone[3] then zone[4]() return end end end local function setup_bar_buf(buf) vim.bo[buf].bufhidden = "wipe" vim.bo[buf].buftype = "nofile" vim.bo[buf].swapfile = false vim.bo[buf].buflisted = false vim.bo[buf].filetype = "TurboDebugBar" end local function setup_bar_win(win) vim.wo[win].winhighlight = "Normal:TurboDebugBar,EndOfBuffer:TurboDebugBar" vim.wo[win].winfixheight = true vim.wo[win].list = false vim.wo[win].cursorline = false vim.wo[win].number = false vim.wo[win].relativenumber = false vim.wo[win].signcolumn = "no" vim.wo[win].statuscolumn = "" vim.wo[win].wrap = false end local function bounce_out(buf) -- if focus lands in a bar buffer (e.g. mouse drag), kick it back out -- so the user can't accidentally type into a non-modifiable buffer. vim.api.nvim_create_autocmd("BufEnter", { buffer = buf, callback = function() vim.schedule(function() if vim.api.nvim_get_current_buf() == buf then pcall(vim.cmd, "wincmd p") end end) end, }) end local function open_bars() -- Both bars are SPLITS, not floats. Splits consume real layout rows -- so dapui windows tuck between them. `wincmd K` / `wincmd J` is -- applied after creation to ensure the bars span the FULL editor -- width (across the sidebar column too) — otherwise dapui's later -- vertical splits would constrain the bars to just the main column. local caller_win = vim.api.nvim_get_current_win() -- status bar (top) if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then vim.cmd("noautocmd keepalt topleft split") sbar_win = vim.api.nvim_get_current_win() sbar_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_win_set_buf(sbar_win, sbar_buf) vim.api.nvim_win_set_height(sbar_win, 2) setup_bar_buf(sbar_buf) setup_bar_win(sbar_win) vim.wo[sbar_win].winfixheight = true vim.keymap.set("n", "", handle_sbar_click, { buffer = sbar_buf, silent = true, nowait = true }) vim.keymap.set("n", "", "", { buffer = sbar_buf, silent = true, nowait = true }) bounce_out(sbar_buf) end if vim.api.nvim_win_is_valid(caller_win) then pcall(vim.api.nvim_set_current_win, caller_win) end -- control bar (bottom) if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then vim.cmd("noautocmd keepalt botright split") cbar_win = vim.api.nvim_get_current_win() cbar_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_win_set_buf(cbar_win, cbar_buf) vim.api.nvim_win_set_height(cbar_win, 2) setup_bar_buf(cbar_buf) setup_bar_win(cbar_win) vim.wo[cbar_win].winfixheight = true vim.keymap.set("n", "", handle_cbar_click, { buffer = cbar_buf, silent = true, nowait = true }) vim.keymap.set("n", "", "", { buffer = cbar_buf, silent = true, nowait = true }) bounce_out(cbar_buf) end if vim.api.nvim_win_is_valid(caller_win) then pcall(vim.api.nvim_set_current_win, caller_win) end render_bars() end -- Set dapui pane heights to their initial proportions. Called ONCE -- per M.enter, after dapui.open(). No winfixheight — the user is free -- to manually resize panes (bigger Breakpoints list, taller Console to -- tail long output, etc.) and their resize sticks because we don't -- fight subsequent WinResized events. Initial sizing only. local function pin_dapui_sizes() local pane_wins = {} for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_valid(buf) then local ft = vim.bo[buf].filetype if ft == "dapui_stacks" or ft == "dapui_scopes" or ft == "dapui_watches" or ft == "dapui_breakpoints" or ft == "dapui_console" or ft == "dap-repl" then local wins = vim.fn.win_findbuf(buf) if wins and wins[1] and vim.api.nvim_win_is_valid(wins[1]) then pane_wins[ft] = wins[1] end end end end -- Console to configured height if pane_wins.dapui_console then local target = config.opts.console_height or math.max(8, math.floor(vim.o.lines * 0.2)) pcall(vim.api.nvim_win_set_height, pane_wins.dapui_console, target) end -- Sidebar panes distributed equally (residual to Breakpoints) local order = { "dapui_stacks", "dapui_scopes", "dapui_watches", "dapui_breakpoints" } local present = {} local total = 0 for _, ft in ipairs(order) do local w = pane_wins[ft] if w then present[#present + 1] = w total = total + vim.api.nvim_win_get_height(w) end end if #present > 1 and total > 0 then local each = math.floor(total / #present) local residual = total - (each * #present) for i, w in ipairs(present) do local h = each + (i == #present and residual or 0) pcall(vim.api.nvim_win_set_height, w, h) end end end local function close_bars() if sbar_win and vim.api.nvim_win_is_valid(sbar_win) then pcall(vim.api.nvim_win_close, sbar_win, true) end if cbar_win and vim.api.nvim_win_is_valid(cbar_win) then pcall(vim.api.nvim_win_close, cbar_win, true) end sbar_win, sbar_buf, cbar_win, cbar_buf = nil, nil, nil, nil sbar_zones, cbar_zones = {}, {} end local function reposition_bars() -- Just re-render bar content. We intentionally DON'T re-pin dapui -- sizes here — the user may have manually resized panes, and -- fighting their resize on every event is a worse UX than occasional -- layout drift. render_bars() end -- ─── dap-ui setup ──────────────────────────────────────────────────────────── local function set_dapui_window_opts(win) -- 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. -- -- Also kill line numbers, the statuscolumn (which snacks overrides with a -- rendered gutter), and the `~` end-of-buffer markers — these are pure -- noise in debug-info panes where there's no file to navigate. if vim.api.nvim_win_is_valid(win) then 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].cursorline = true vim.wo[win].number = false vim.wo[win].relativenumber = false vim.wo[win].signcolumn = "no" vim.wo[win].statuscolumn = "" vim.wo[win].foldcolumn = "0" -- make end-of-buffer `~` invisible by setting EOB fillchar to space vim.wo[win].fillchars = "eob: " 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() -- One-time setup. Calling dapui.setup on every enter was tearing down -- internal state (windows, event listeners, buffer refs) while the -- adapter handshake was in flight — "Debug adapter didn't respond" -- timeouts and overall slowness. dapui's layouts use the absolute -- sizes computed once here; if the terminal resizes significantly -- between toggles, proportions can drift slightly, but that's -- acceptable compared to breaking the adapter. 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 (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" }) end, { silent = true, desc = "DAP hover" }) end dap.listeners.after.event_terminated["turbo-debug-hover"] = function() pcall(vim.keymap.del, "n", "K") 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 arrows. Gutter sign 👉 + EOL vtext 👈 REASON flanking -- the paused line. clear_ip() wipes both our own extmarks AND nvim-dap's -- DapStopped gutter signs, so a restart (or a natural session end) doesn't -- leave a stale 👉 behind on the last-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 if vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_clear_namespace(buf, ip_ns, 0, -1) -- unplace any lingering DapStopped signs (nvim-dap doesn't always -- clear these on restart — it's the exact bug the user hit). local ok, placed = pcall(vim.fn.sign_getplaced, buf, { group = "*" }) if ok and placed and placed[1] then for _, s in ipairs(placed[1].signs or {}) do if s.name == "DapStopped" then pcall(vim.fn.sign_unplace, s.group or "", { buffer = buf, id = s.id }) end end end end end end -- re-render the control bar on any dap state change so the READY/RUNNING/ -- PAUSED chip and dap.status() text stay live. local function refresh_bars() vim.schedule(render_bars) end dap.listeners.after.event_initialized["turbo-debug-bar"] = refresh_bars dap.listeners.after.event_stopped["turbo-debug-bar"] = refresh_bars dap.listeners.after.event_continued["turbo-debug-bar"] = refresh_bars dap.listeners.after.event_terminated["turbo-debug-bar"] = refresh_bars dap.listeners.after.event_exited["turbo-debug-bar"] = refresh_bars -- When a stopped event fires, dap will jump to the source frame using -- its switchbuf logic. `uselast` (the common default) targets the -- CURRENT window — but if focus happens to be on a dapui pane or one -- of our bars, dap falls back to `winnr('#')` which can land anywhere -- (including bufferline's tabline area or a wrong split). By shifting -- focus to a real source window BEFORE dap processes the jump, we -- guarantee new source files (stepped-into library/stdlib files) open -- in the intended source window and the chrome stays intact. dap.listeners.before.event_stopped["turbo-debug-focus-source"] = function() local w = source_window() if w and w ~= vim.api.nvim_get_current_win() then pcall(vim.api.nvim_set_current_win, w) end end dap.listeners.after.event_stopped["turbo-debug-ip"] = function(session, body) vim.defer_fn(function() clear_ip() local frame = session and session.current_frame if not (frame and frame.source and frame.source.path) then return end local buf = vim.fn.bufnr(frame.source.path) if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then return end local line = math.max(0, (frame.line or 1) - 1) local reason = ((body and body.reason) or "stopped"):upper() -- Extmark carries BOTH the virt_text (👈 REASON at EOL) AND a -- sign_text (👉 in the gutter) with high priority. nvim-dap also -- places its own DapStopped sign, but at the same priority as the -- breakpoint sign — which meant whichever was placed later won. -- Our extmark sign at priority 500 always wins, guaranteeing the -- paused line has the finger-pointing icon in the gutter. pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, { sign_text = "\xf0\x9f\x91\x89", -- 👉 U+1F449 sign_hl_group = "DapStopped", virt_text = { { " " .. ICON.ip_left .. " ", "DapStopped" }, { reason, "TurboDebugIPReason" }, }, virt_text_pos = "eol", hl_mode = "combine", priority = 500, }) end, 50) end dap.listeners.after.event_continued["turbo-debug-ip"] = function() clear_ip() end dap.listeners.before.event_terminated["turbo-debug-ip"] = function() clear_ip() end dap.listeners.before.event_exited["turbo-debug-ip"] = function() clear_ip() end -- Clear the Console pane on session start/restart. -- -- Strategy: keep the buffer (crucially, keep the attached terminal -- channel intact so dap's output stream isn't broken), briefly flip -- `modifiable` on and call nvim_buf_set_lines to wipe the scrollback. -- The terminal's PTY state and cursor position are preserved — only -- the displayed lines are removed. Next output from the program -- appends from the top of the empty buffer. -- -- The previous "delete the whole buffer" approach only worked on -- fresh launches (new terminal opens anyway) and broke on Restart -- because the adapter reuses the existing terminal channel — deleting -- the buffer orphaned the channel and left the Console unresponsive. local function clear_console() if not config.opts.clear_console_on_start then return end local ok, dapui = pcall(require, "dapui") if not (ok and dapui.elements and dapui.elements.console) then return end local bok, buf = pcall(dapui.elements.console.buffer) if not bok or not buf or not vim.api.nvim_buf_is_valid(buf) then return end pcall(function() local was_mod = vim.bo[buf].modifiable vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) vim.bo[buf].modifiable = was_mod end) end -- Fire before every session-starting verb so old scrollback is gone -- before the new session's output begins. dap.listeners.before.launch["turbo-debug-clear-console"] = clear_console dap.listeners.before.attach["turbo-debug-clear-console"] = clear_console dap.listeners.before.restart["turbo-debug-clear-console"] = clear_console -- Default-collapse scopes the user considers noisy (Registers on -- codelldb, for example). We intercept the scopes response and set -- `expensive = true` on any matching scope — dapui's own component -- auto-collapses anything marked expensive (see dapui/components/ -- scopes.lua line 22-24), so this reuses their existing mechanism. dap.listeners.before.scopes["turbo-debug-collapse"] = function(_, _, response, _) local collapse = config.opts.collapsed_scopes if not (collapse and #collapse > 0) then return end if not (response and response.scopes) then return end for _, scope in ipairs(response.scopes) do for _, name in ipairs(collapse) do if scope.name == name then scope.expensive = true break end end end end -- winbar titles on dapui panes — colorful emoji for instant pane -- recognition. REPL is iconless per user preference (its prompt is -- already visually distinct enough). local titles = { dapui_scopes = " " .. ICON.scopes .. " Scopes", dapui_watches = " " .. ICON.watches .. " Watches", dapui_stacks = " " .. ICON.stacks .. " Call Stack", dapui_breakpoints = " " .. ICON.breakpoints .. " Breakpoints", dapui_console = " " .. ICON.terminal .. " Console", ["dap-repl"] = " 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 -- /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() local win = vim.fn.bufwinid(args.buf) if win ~= -1 then if title then vim.wo[win].winbar = title end set_dapui_window_opts(win) end end) end, }) if config.opts.active_window_highlight then setup_active_win_highlights() end end local function ensure_vt() if vt_initialized then return end vt_initialized = true require("nvim-dap-virtual-text").setup(config.opts.virtual_text) end -- ─── actions ──────────────────────────────────────────────────────────────── local actions_cache local visual_actions = { watch = true, hover = true } local function has_any_breakpoint() local ok, bps = pcall(require, "dap.breakpoints") if not ok then return false end local all = bps.get() for _, lines in pairs(all or {}) do if #lines > 0 then return true end end return false end -- Find a source window (non-dapui, non-repl, non-bar). nvim-dap reads the -- current buffer's filetype to pick a launch configuration, so when the user -- presses `c` while focused in a dapui pane (filetype=dapui_scopes, etc.), -- we need to temporarily borrow a source window's context. -- NOTE: assigned (not declared) — forward-declared at top of file. source_window = function() -- prefer the current window if it's a source local cur = vim.api.nvim_get_current_win() local function is_source(w) if not vim.api.nvim_win_is_valid(w) then return false end if vim.api.nvim_win_get_config(w).relative ~= "" then return false end local ft = vim.bo[vim.api.nvim_win_get_buf(w)].filetype or "" return ft ~= "" and not ft:match("^dapui_") and ft ~= "dap-repl" and ft ~= "TurboDebugBar" end if is_source(cur) then return cur end for _, w in ipairs(vim.api.nvim_list_wins()) do if is_source(w) then return w end end return nil end local function run_in_source(fn) -- We can't use nvim_win_call here: dap.continue() may open a vim.ui.select -- picker asynchronously, and by the time the picker opens, nvim_win_call -- has already returned focus to the original window — the picker opens -- but nothing has focus on it, forcing the user to click in to interact. -- Actually shift focus to the source window so the picker takes focus -- properly. local w = source_window() if w and w ~= vim.api.nvim_get_current_win() then pcall(vim.api.nvim_set_current_win, w) end return fn() end local function launch_with_stop_on_entry() local dap = require("dap") local ft = vim.bo.filetype or "" local cfgs = dap.configurations[ft] if not (cfgs and cfgs[1]) then dap.continue() return end local cfg = vim.deepcopy(cfgs[1]) cfg.stopOnEntry = true cfg.name = (cfg.name or "debug") .. " (stop on entry)" dap.run(cfg) end local function smart_continue() local dap = require("dap") if dap.session() then dap.continue(); return end run_in_source(function() if config.opts.stop_on_entry_when_no_breakpoints ~= false and not has_any_breakpoint() then launch_with_stop_on_entry() else dap.continue() end end) end local function step_or_launch(step_fn) if require("dap").session() then step_fn() else smart_continue() 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") local pb = require("persistent-breakpoints.api") actions_cache = { continue = smart_continue, step_over = function() step_or_launch(dap.step_over) end, step_into = function() step_or_launch(dap.step_into) end, 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 = smart_terminate, help = function() if help.is_open() then help.close() else help.open() end end, breakpoint = function() pb.toggle_breakpoint() end, cond_breakpoint = function() pb.set_conditional_breakpoint() end, clear_breakpoints = function() pb.clear_all_breakpoints() end, watch = function() local expr if vim.fn.mode() == "v" or vim.fn.mode() == "V" then vim.cmd('noautocmd normal! "vy') expr = vim.fn.getreg("v") else expr = vim.fn.expand("") end if expr and expr ~= "" then require("dapui").elements.watches.add(expr) end end, hover = function() require("dapui").eval() end, eval = function() require("dapui").float_element("repl") end, } return actions_cache end function M.actions() return actions_cache or setup_actions() end -- ─── keymap install / clear ───────────────────────────────────────────────── local function find_buf_mapping(buf, lhs, mode) for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, mode)) do if m.lhs == lhs then return m end end return nil end local function restore_mapping(buf, mode, map) local rhs = map.rhs or map.callback if not rhs then return end pcall(vim.keymap.set, mode, map.lhs, rhs, { buffer = buf, silent = map.silent == 1, noremap = map.noremap == 1, expr = map.expr == 1, desc = map.desc, }) end function M._install_for_buf(buf) if not vim.api.nvim_buf_is_valid(buf) then return end -- Skip the control-bar buffer entirely — its own keymap handles -- button clicks, and debug keys like `c` would fire accidentally on click. if vim.bo[buf].filetype == "TurboDebugBar" 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] then -- 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 -- Only record prev-mapping on first install; subsequent installs -- (force-reinstall on focus change) keep the original stash so -- exit-time restoration still works. local entry = installed[buf][name] if not entry 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 entry = { key = key, prev_n = prev_n, prev_v = prev_v } installed[buf][name] = entry end 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 end end end end local function clear_all_modal_keys() for buf, entries in pairs(installed) do if vim.api.nvim_buf_is_valid(buf) then for name, entry in pairs(entries) do pcall(vim.keymap.del, "n", entry.key, { buffer = buf }) if visual_actions[name] then pcall(vim.keymap.del, "v", entry.key, { buffer = buf }) end if entry.prev_n then restore_mapping(buf, "n", entry.prev_n) end if entry.prev_v then restore_mapping(buf, "v", entry.prev_v) end end end end installed = {} end -- ─── source-window debug chrome ───────────────────────────────────────────── local function set_debug_chrome() active_win = vim.api.nvim_get_current_win() saved_winbar = vim.wo[active_win].winbar -- Source file's winbar is just the filename now — the "DEBUG · STATE" -- chip lives in the top status bar (sbar). Keeps the source context -- minimal and leaves more room for filename paths. vim.wo[active_win].winbar = " %f" end local function clear_debug_chrome() if active_win and vim.api.nvim_win_is_valid(active_win) then vim.wo[active_win].winbar = saved_winbar or "" end active_win = nil saved_winbar = nil end -- ─── public API ───────────────────────────────────────────────────────────── function M.enter() if active then return end active = true define_highlights() setup_actions() local group = vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true }) vim.api.nvim_create_autocmd("BufEnter", { group = group, callback = function(args) if active then M._install_for_buf(args.buf) end end, }) -- Recovery handler shared by several layout-change events. -- 1) Editor resize (VimResized) -- 2) Tabline appearing/disappearing — bufferline.nvim dynamically -- toggles showtabline based on buffer count. When it flips, every -- window shifts by one row and our bars can get killed or -- squeezed. -- 3) Arbitrary WinResized (mouse drag on pane borders, plugin-driven -- wincmd=, etc.) -- We don't re-pin dapui sizes here — user's manual resizes should -- stick. We just recreate any bar that got nuked and re-render -- content. local function recover_chrome() vim.schedule(function() if not active then return end -- Close any orphaned TurboDebugBar windows we're not tracking. -- When the tabline toggles, nvim can sometimes leave a stale bar -- window while creating a new one — which produces the "duplicate -- turbo-debug rows stacked on top of each other" artifact. for _, w in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_is_valid(w) and w ~= sbar_win and w ~= cbar_win then local b = vim.api.nvim_win_get_buf(w) if vim.api.nvim_buf_is_valid(b) and vim.bo[b].filetype == "TurboDebugBar" then pcall(vim.api.nvim_win_close, w, true) end end end if not (sbar_win and vim.api.nvim_win_is_valid(sbar_win)) then sbar_win, sbar_buf = nil, nil end if not (cbar_win and vim.api.nvim_win_is_valid(cbar_win)) then cbar_win, cbar_buf = nil, nil end open_bars() -- idempotent render_bars() -- Force a full redraw so any leftover pixels from the previous -- layout (before tabline disappeared) don't linger on screen. pcall(vim.cmd, "redraw!") end) end vim.api.nvim_create_autocmd("VimResized", { group = group, callback = recover_chrome }) -- WinResized fires when any window's geometry changes — including -- the shift that happens when bufferline's tabline disappears (the -- tabline row gets reclaimed, all windows shift up). At that point -- nvim re-renders at the new positions but doesn't always clear -- the pixels where the old render was, producing the "duplicate -- status bar rows" artifact. Forcing a full redraw here wipes the -- stale pixels. We don't re-pin dapui sizes so user manual resizes -- still stick. vim.api.nvim_create_autocmd("WinResized", { group = group, callback = function() if not active then return end vim.schedule(function() pcall(vim.cmd, "redraw!") end) end, }) vim.api.nvim_create_autocmd("OptionSet", { group = group, pattern = "showtabline", callback = recover_chrome, }) -- Fallback: BufEnter/BufWinEnter catches bufferline showing the -- tabline when a second buffer appears (bufferline doesn't always -- call `:set showtabline=N`; sometimes it just computes the tabline -- string which causes nvim to reserve the row on its own). vim.api.nvim_create_autocmd({ "BufEnter", "BufWinEnter", "TabEnter" }, { group = group, callback = function() -- cheap guard: only recover if one of our bars is actually gone if not active then return end if sbar_win and vim.api.nvim_win_is_valid(sbar_win) and cbar_win and vim.api.nvim_win_is_valid(cbar_win) then return end recover_chrome() 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 set_debug_chrome() ensure_dapui() -- { reset = true } is critical: without it, dapui's WindowLayout:resize -- uses whatever `win_state.size` drifted to last time (see -- dapui/windows/layout.lua:82-116 — `update_sizes` writes current -- ratios back to state, so sizes accumulate drift across close/open -- cycles). `reset` tells it to ignore cached state and use init_size -- (our original 0.25 per pane). require("dapui").open({ reset = true }) open_bars() pin_dapui_sizes() -- 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 pin_dapui_sizes() render_bars() end) ensure_vt() require("nvim-dap-virtual-text").enable() if config.opts.help_on_enter then help.open() end end function M.exit() if not active then return end active = false if require("dap").session() then pcall(require("dap").terminate) end pcall(vim.api.nvim_del_augroup_by_name, "TurboDebugModalKeys") clear_all_modal_keys() clear_debug_chrome() help.close() -- Close dapui FIRST so dapui's windows tear down into a state we -- control. Closing our bars first would let dapui's remaining panes -- expand into the freed rows, which dapui then "remembers" and -- breaks the next open's layout. if dapui_initialized then require("dapui").close() end close_bars() if vt_initialized then require("nvim-dap-virtual-text").disable() end end function M.toggle() if active then M.exit() else M.enter() end end function M.is_active() return active end return M