From be628194ecf3537a8c6f821d4aafc76106faed89 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 12 Apr 2026 09:31:06 -0600 Subject: [PATCH] WIP: aesthetics! --- README.md | 63 +++- lua/turbo-debug/breakpoints.lua | 66 +++- lua/turbo-debug/config.lua | 65 ++-- lua/turbo-debug/help.lua | 220 ++++++++++---- lua/turbo-debug/mode.lua | 515 +++++++++++++++++++++++--------- tests/test_help.lua | 4 +- 6 files changed, 690 insertions(+), 243 deletions(-) diff --git a/README.md b/README.md index cb4b0ef..805a5c6 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,36 @@ That's it. No other config needed. See `:help turbo-debug` for customization. ## How It Works turbo-debug is **modal**. Press `dd` and you enter debug mode โ€” the -UI opens, keybindings activate, and you're debugging. Press `q` or -`dd` again and everything disappears. Your editor is exactly as it -was before. +dap-ui layout opens, keybindings activate, virtual text lights up, and +you're debugging. Press `dd` again (or `q`) and everything +disappears: the session terminates, the UI closes, and every keybinding is +restored to exactly what it was. This modal approach is inspired by [debugmaster.nvim](https://github.com/MironPascalCaseFan/debugmaster.nvim). -During a debug session you need to **navigate** code and **inspect** state. -You do not need to **edit**. So debug mode shadows editing keys (`c`, `d`, -`s`, `r`, `x`) with debug actions and leaves all navigation keys untouched -(`h`, `j`, `k`, `l`, `w`, `b`, `e`, `f`, `/`, `n`, etc.). +During a debug session you **navigate** code and **inspect** state; you +don't **edit**. So debug mode shadows editing keys (`c`, `d`, `s`, `r`, +`x`) with debug actions and leaves all navigation keys untouched (`h`, +`j`, `k`, `l`, `w`, `b`, `e`, `f`, `/`, `n`, etc.). -When debug mode ends, every keybinding is restored to exactly what it was. +### A nice text *and* GUI debugger + +The modal keymap is deliberately **left-hand optimized** so the right hand +stays on the mouse for interacting with the dap-ui panes: + +- **Left hand on keyboard**: `c s d r C x X D W E q R` fire the twelve + rapid-use debug actions without leaving home row. +- **Right hand on mouse**: click into Watches/Scopes/Stacks/Breakpoints + panes to expand trees, jump to frames, add watch expressions, or toggle + breakpoints from the GUI. + +Only two modal keys live on the right hand, both by universal convention: +`K` for hover (matches the LSP hover key) and `?` for help. + +Inside dap-ui panes, the native dap-ui mappings still work โ€” `d` removes a +watch, `r` sends to REPL, `e` edits. The modal `d` (step-into) and `r` +(step-out) yield to dap-ui in those buffers so you can manage the GUI +without exiting debug mode. ## Keybindings @@ -57,7 +75,34 @@ During debug mode, single-key controls: | `q` | Terminate session | `E` | Eval in REPL | | `R` | Restart session | `?` | Help popup | -Press `?` during debug mode for a quick reference. +Press `?` during debug mode for a quick reference that also lists dap-ui's +native in-pane bindings (``, `e`, `o`, `d`, `r`, `t`). + +### Extending keymaps + +Every key is user-overridable. Remap, disable, or add bindings via +`setup()`: + +```lua +require("turbo-debug").setup({ + keys = { + step_over = "n", -- remap a modal key + watch = false, -- disable a modal key + }, + global_keys = { + toggle = "D", -- change the toggle chord + }, + sidebar = "right", -- "left" (default) or "right" + help_on_enter = false, -- disable the help splash + quit_exits_mode = false, -- q terminates but stays in debug mode +}) +``` + +### Statusline integration + +If you want a live debug indicator in your statusline, add +`require("dap").status()` as a lualine/heirline component โ€” it returns a +progress message while debugging and an empty string when idle. ## Supported Languages diff --git a/lua/turbo-debug/breakpoints.lua b/lua/turbo-debug/breakpoints.lua index faede20..f14ba50 100644 --- a/lua/turbo-debug/breakpoints.lua +++ b/lua/turbo-debug/breakpoints.lua @@ -12,19 +12,61 @@ function M.setup() load_breakpoints_event = { "BufReadPost" }, }) - -- sign highlights (default = true so colorschemes can override) - vim.api.nvim_set_hl(0, "DapBreakpoint", { default = true, fg = "#e06c75" }) - vim.api.nvim_set_hl(0, "DapBreakpointCondition", { default = true, fg = "#e5c07b" }) - vim.api.nvim_set_hl(0, "DapBreakpointRejected", { default = true, fg = "#5c6370" }) - vim.api.nvim_set_hl(0, "DapStopped", { default = true, fg = "#98c379" }) - vim.api.nvim_set_hl(0, "DapStoppedLine", { default = true, bg = "#2e3b2e" }) - vim.api.nvim_set_hl(0, "DapLogPoint", { default = true, fg = "#61afef" }) + -- link sign highlights to standard diagnostic groups so they pick up the + -- user's colorscheme instead of hardcoding hex colors. + vim.api.nvim_set_hl(0, "DapBreakpoint", { default = true, link = "DiagnosticError" }) + vim.api.nvim_set_hl(0, "DapBreakpointCondition", { default = true, link = "DiagnosticWarn" }) + vim.api.nvim_set_hl(0, "DapBreakpointRejected", { default = true, link = "DiagnosticHint" }) + vim.api.nvim_set_hl(0, "DapStopped", { default = true, link = "DiagnosticOk" }) + vim.api.nvim_set_hl(0, "DapStoppedLine", { default = true, link = "Search" }) + vim.api.nvim_set_hl(0, "DapLogPoint", { default = true, link = "DiagnosticInfo" }) - vim.fn.sign_define("DapBreakpoint", { text = "๐Ÿ”ด", texthl = "DapBreakpoint" }) - vim.fn.sign_define("DapBreakpointCondition", { text = "๐ŸŸก", texthl = "DapBreakpointCondition" }) - vim.fn.sign_define("DapBreakpointRejected", { text = "โญ•", texthl = "DapBreakpointRejected" }) - vim.fn.sign_define("DapStopped", { text = "โžค", texthl = "DapStopped", linehl = "DapStoppedLine" }) - vim.fn.sign_define("DapLogPoint", { text = "๐Ÿ“", texthl = "DapLogPoint" }) + -- gutter signs: Unicode geometric symbols via byte escapes so they survive + -- any tool-pipeline that strips PUA/Nerd-Font codepoints. They render in + -- any monospace font, Nerd Font required. + -- โ— U+25CF (solid circle) = breakpoint + -- โ—† U+25C6 (solid diamond) = conditional breakpoint + -- โ—‹ U+25CB (hollow circle) = rejected breakpoint + -- โ–ถ U+25B6 (right triangle) = current execution line (IP) + -- โœŽ U+270E (pencil) = log breakpoint + vim.fn.sign_define("DapBreakpoint", { text = "\xe2\x97\x8f", texthl = "DapBreakpoint", numhl = "DapBreakpoint" }) + vim.fn.sign_define("DapBreakpointCondition", { text = "\xe2\x97\x86", texthl = "DapBreakpointCondition", numhl = "DapBreakpointCondition" }) + vim.fn.sign_define("DapBreakpointRejected", { text = "\xe2\x97\x8b", texthl = "DapBreakpointRejected", numhl = "DapBreakpointRejected" }) + vim.fn.sign_define("DapStopped", { text = "\xe2\x96\xb6", texthl = "DapStopped", linehl = "DapStoppedLine", numhl = "DapStopped" }) + vim.fn.sign_define("DapLogPoint", { text = "\xe2\x9c\x8e", texthl = "DapLogPoint", numhl = "DapLogPoint" }) + + -- unobtrusive inline virtual text: italic, comment-colored + vim.api.nvim_set_hl(0, "NvimDapVirtualText", { default = true, link = "Comment", italic = true }) + vim.api.nvim_set_hl(0, "NvimDapVirtualTextChanged", { default = true, link = "DiagnosticWarn", italic = true }) + vim.api.nvim_set_hl(0, "NvimDapVirtualTextError", { default = true, link = "DiagnosticError", italic = true }) + vim.api.nvim_set_hl(0, "NvimDapVirtualTextInfo", { default = true, link = "DiagnosticInfo", italic = true }) + + -- turbo-debug's own UI groups (help float, active-pane borders, winbar badge) + vim.api.nvim_set_hl(0, "TurboDebugHelpBrand", { default = true, link = "Title" }) + vim.api.nvim_set_hl(0, "TurboDebugHelpTagline", { default = true, link = "Comment", italic = true }) + vim.api.nvim_set_hl(0, "TurboDebugHelpSection", { default = true, link = "Function" }) + vim.api.nvim_set_hl(0, "TurboDebugHelpSectionIcon", { default = true, link = "Constant" }) + vim.api.nvim_set_hl(0, "TurboDebugHelpKey", { default = true, link = "Special" }) + vim.api.nvim_set_hl(0, "TurboDebugHelpParen", { default = true, link = "Comment" }) + vim.api.nvim_set_hl(0, "TurboDebugCtrlButton", { default = true, link = "Function" }) + vim.api.nvim_set_hl(0, "TurboDebugCtrlMuted", { default = true, link = "Comment" }) + + -- derived highlight: the IP "reason" vtext (STEP / BREAKPOINT / EXCEPTION / + -- PAUSE / etc.) โ€” bold + the WarningMsg color so it jumps off the screen. + -- nvim_set_hl can't combine link + bold, so copy attrs explicitly and + -- re-apply on ColorScheme so theme switches don't blank it out. + local function apply_ip_reason_hl() + local ok, src = pcall(vim.api.nvim_get_hl, 0, { name = "WarningMsg", link = false }) + if not ok or not src or not src.fg then return end + vim.api.nvim_set_hl(0, "TurboDebugIPReason", { + fg = src.fg, bg = src.bg, bold = true, default = true, + }) + end + apply_ip_reason_hl() + vim.api.nvim_create_autocmd("ColorScheme", { + group = vim.api.nvim_create_augroup("TurboDebugColors", { clear = true }), + callback = apply_ip_reason_hl, + }) -- global mappings (work outside debug mode) local gk = config.opts.global_keys diff --git a/lua/turbo-debug/config.lua b/lua/turbo-debug/config.lua index 18eecd7..7b42753 100644 --- a/lua/turbo-debug/config.lua +++ b/lua/turbo-debug/config.lua @@ -4,6 +4,11 @@ M.defaults = { help_on_enter = true, builtin_adapters = true, quit_exits_mode = true, -- q terminates AND exits debug mode (false = terminate only) + -- When `c` is pressed with no breakpoints set, launch the first adapter + -- configuration for the filetype with stopOnEntry so the debugger pauses + -- at the program's entry point. Matches the Turbo Pascal "press key to + -- start debugging" model. Set false to always run until a breakpoint. + stop_on_entry_when_no_breakpoints = true, -- modal keys (active only during debug mode, single-key) keys = { @@ -31,40 +36,58 @@ M.defaults = { clear_breakpoints = "dD", }, + -- sidebar position: "left" (default) or "right" + sidebar = "left", + + -- highlight the active dapui pane's border/separator vs. dim inactive panes + active_window_highlight = true, + dapui = { - icons = { expanded = "โ–ผ", collapsed = "โ–ถ", current_frame = "โžค" }, + -- Unicode icons via byte escapes so they survive tool round-trips. + -- โ–พ U+25BE down-triangle (expanded), โ–ธ U+25B8 right-triangle (collapsed), + -- โ–ถ U+25B6 right-pointing triangle (current frame / play-head) + icons = { + expanded = "\xe2\x96\xbe", + collapsed = "\xe2\x96\xb8", + current_frame = "\xe2\x96\xb6", + }, controls = { enabled = true, element = "repl", + -- โ–ถ play, โธ pause, โค“ step-into, โ‡’ step-over, โค’ step-out, โ†ถ step-back, + -- โ†ป run-last, โ–  terminate, โŠ— disconnect + icons = { + play = "\xe2\x96\xb6", + pause = "\xe2\x8f\xb8", + step_into = "\xe2\xa4\x93", + step_over = "\xe2\x87\x92", + step_out = "\xe2\xa4\x92", + step_back = "\xe2\x86\xb6", + run_last = "\xe2\x86\xbb", + terminate = "\xe2\x96\xa0", + disconnect = "\xe2\x8a\x97", + }, }, - floating = { border = "rounded" }, + floating = { border = "rounded", mappings = { close = { "q", "" } } }, render = { indent = 2, max_type_length = 20 }, - layouts = { - { - position = "right", - size = 40, - elements = { - { id = "scopes", size = 0.50 }, - { id = "watches", size = 0.25 }, - { id = "stacks", size = 0.25 }, - }, - }, - { - position = "bottom", - size = 12, - elements = { - { id = "repl", size = 0.50 }, - { id = "console", size = 0.50 }, - }, - }, - }, + -- layouts are built dynamically from `sidebar` in mode.lua; override + -- `layouts` here to fully customize. + layouts = nil, }, virtual_text = { + enabled = true, commented = true, + virt_text_pos = "eol", + show_stop_reason = true, highlight_changed_variables = true, + highlight_new_as_changed = false, only_first_definition = false, all_frames = false, + display_callback = function(variable) + if #variable.value > 80 then return " = " .. variable.value:sub(1, 77) .. "โ€ฆ" end + return " = " .. variable.value + end, }, adapters = {}, diff --git a/lua/turbo-debug/help.lua b/lua/turbo-debug/help.lua index 118fbe0..e80ad60 100644 --- a/lua/turbo-debug/help.lua +++ b/lua/turbo-debug/help.lua @@ -4,69 +4,168 @@ local M = {} local win_id = nil local buf_id = nil -local timer = nil -local modal_info = { - { "continue", "Continue" }, - { "step_over", "Step over" }, - { "step_into", "Step into" }, - { "step_out", "Step out" }, - { "run_to_cursor", "Run to cursor" }, - { "breakpoint", "Toggle breakpoint" }, - { "cond_breakpoint", "Conditional breakpoint" }, - { "clear_breakpoints", "Clear all breakpoints" }, - { "watch", "Watch expression" }, - { "hover", "Hover / inspect" }, - { "eval", "Eval in REPL" }, - { "terminate", "Quit debugging" }, - { "restart", "Restart session" }, +-- Each entry: +-- { action, key_letter, pre, post } +-- The formatted line is: "
()"  with  taken from the
+-- config.opts.keys[action] lookup. Highlight spans are computed against
+-- the actual key letter in the final line.
+local flow = {
+  { "continue",      "c", "(",                 ")ontinue" },
+  { "step_over",     "s", "(",                 ")tep over" },
+  { "step_into",     "d", "(",                 ")escend into" },
+  { "step_out",      "r", "(",                 ")eturn out" },
+  { "run_to_cursor", "C", "run to (",          ") cursor" },
+  { "restart",       "R", "(",                 ")estart session" },
+  { "terminate",     "q", "(",                 ")uit debugging" },
 }
 
-local global_info = {
-  { "toggle", "Toggle debug mode" },
-  { "breakpoint", "Toggle breakpoint" },
-  { "cond_breakpoint", "Conditional breakpoint" },
-  { "clear_breakpoints", "Clear all breakpoints" },
+local breakpoints = {
+  { "breakpoint",        "x", "(", ") toggle breakpoint" },
+  { "cond_breakpoint",   "X", "(", ") conditional breakpoint" },
+  { "clear_breakpoints", "D", "(", ")elete all breakpoints" },
 }
 
-local function build_content()
-  local lines = { " turbo-debug", "" }
-  local width = #lines[1]
+local inspect = {
+  { "watch", "W", "(", ")atch expression" },
+  { "hover", "K", "(", ") hover / inspect" },
+  { "eval",  "E", "(", ")val in REPL" },
+}
 
-  local function add(key, label)
-    local line = string.format("  %-12s %s", key, label)
+local meta = {
+  { "help", "?", "(", ") this popup" },
+}
+
+local globals = {
+  { "breakpoint",        "toggle breakpoint" },
+  { "cond_breakpoint",   "conditional breakpoint" },
+  { "clear_breakpoints", "delete all breakpoints" },
+}
+
+local dapui_panes = {
+  { "", "expand / jump to target" },
+  { "o",    "open target (jump to frame / breakpoint)" },
+  { "e",    "edit (watches)" },
+  { "d",    "remove (watches)" },
+  { "r",    "send to REPL" },
+  { "t",    "toggle subtle frames (call stack)" },
+}
+
+-- โ–ถ U+25B6, โ— U+25CF, โ—‰ U+25C9, โ—† U+25C6, โฏ U+276F, โ‰ก U+2261
+local ICON_FLOW    = "\xe2\x96\xb6"
+local ICON_BRK     = "\xe2\x97\x8f"
+local ICON_INSPECT = "\xe2\x97\x89"
+local ICON_META    = "\xe2\x97\x86"
+local ICON_CHEVRON = "\xe2\x9d\xaf"
+local ICON_STACK   = "\xe2\x89\xa1"
+
+-- a brand header and tagline
+local BRAND_TITLE = " " .. ICON_FLOW .. " TURBO DEBUG"
+local BRAND_TAGLINE = "   modal text + GUI debugger ยท left hand keys, right hand mouse"
+
+local function build()
+  local lines = { "", BRAND_TITLE, BRAND_TAGLINE, "" }
+  local hl = {} -- list of { line_idx, col_start, col_end, hl_group }
+
+  -- title + tagline highlights
+  hl[#hl + 1] = { 1, 0, #BRAND_TITLE,   "TurboDebugHelpBrand" }
+  hl[#hl + 1] = { 2, 0, #BRAND_TAGLINE, "TurboDebugHelpTagline" }
+
+  local function section(icon, title)
+    lines[#lines + 1] = "  " .. icon .. "  " .. title
+    local idx = #lines - 1
+    hl[#hl + 1] = { idx, 2, 2 + #icon, "TurboDebugHelpSectionIcon" }
+    hl[#hl + 1] = { idx, 2 + #icon + 2, 2 + #icon + 2 + #title, "TurboDebugHelpSection" }
+  end
+
+  local function entry(pre, keyletter, post)
+    -- format: "      
()"
+    -- e.g., "      (s)tep over"  or  "      run to (C) cursor"
+    -- the key letter is inside the parens pair
+    local indent = "      "
+    -- pre already ends at "("; post already starts with ")"
+    local line = indent .. pre .. keyletter .. post
+    local idx = #lines
     lines[#lines + 1] = line
-    if #line > width then width = #line end
+    -- highlight: the key letter itself
+    local col = #indent + #pre
+    hl[#hl + 1] = { idx, col, col + #keyletter, "TurboDebugHelpKey" }
+    -- highlight the surrounding parens dimmer
+    hl[#hl + 1] = { idx, col - 1, col, "TurboDebugHelpParen" }
+    hl[#hl + 1] = { idx, col + #keyletter, col + #keyletter + 1, "TurboDebugHelpParen" }
   end
 
-  lines[#lines + 1] = " Debug mode:"
-  for _, pair in ipairs(modal_info) do
-    local key = config.opts.keys[pair[1]]
-    if key then add(key, pair[2]) end
+  local function add_modal(list)
+    for _, row in ipairs(list) do
+      local action, letter, pre, post = row[1], row[2], row[3], row[4]
+      local configured = config.opts.keys[action]
+      if configured then
+        -- if the user remapped, show their actual key (preserves surprise-free behavior)
+        local lk = configured
+        entry(pre, lk, post)
+      end
+    end
   end
 
+  local function add_global(list)
+    for _, row in ipairs(list) do
+      local action, label = row[1], row[2]
+      local key = config.opts.global_keys[action]
+      if key then
+        local indent = "      "
+        local line = indent .. string.format("%-13s ", key) .. label
+        local idx = #lines
+        lines[#lines + 1] = line
+        hl[#hl + 1] = { idx, #indent, #indent + #key, "TurboDebugHelpKey" }
+      end
+    end
+  end
+
+  local function add_pane(list)
+    for _, row in ipairs(list) do
+      local key, label = row[1], row[2]
+      local indent = "      "
+      local line = indent .. string.format("%-6s ", key) .. label
+      local idx = #lines
+      lines[#lines + 1] = line
+      hl[#hl + 1] = { idx, #indent, #indent + #key, "TurboDebugHelpKey" }
+    end
+  end
+
+  section(ICON_FLOW, "Flow")
+  add_modal(flow)
+  lines[#lines + 1] = ""
+
+  section(ICON_BRK, "Breakpoints")
+  add_modal(breakpoints)
+  lines[#lines + 1] = ""
+
+  section(ICON_INSPECT, "Inspect")
+  add_modal(inspect)
+  lines[#lines + 1] = ""
+
+  section(ICON_META, "Help")
+  add_modal(meta)
+  lines[#lines + 1] = ""
+
+  section(ICON_CHEVRON, "Always available")
+  add_global(globals)
+  lines[#lines + 1] = ""
+
+  section(ICON_STACK, "In dap-ui panes")
+  add_pane(dapui_panes)
   lines[#lines + 1] = ""
-  lines[#lines + 1] = " Always available:"
-  for _, pair in ipairs(global_info) do
-    local key = config.opts.global_keys[pair[1]]
-    if key then add(key, pair[2]) end
-  end
 
-  -- highlight line at the bottom
   local help_key = config.opts.keys.help or "?"
-  lines[#lines + 1] = ""
-  lines[#lines + 1] = " Press " .. help_key .. " for help"
+  lines[#lines + 1] = "   press " .. help_key .. " anytime to reopen"
   lines[#lines + 1] = ""
 
-  return lines, width + 2
+  local width = 0
+  for _, l in ipairs(lines) do if vim.fn.strdisplaywidth(l) > width then width = vim.fn.strdisplaywidth(l) end end
+  return lines, width + 2, hl
 end
 
 function M.close()
-  if timer then
-    timer:stop()
-    timer:close()
-    timer = nil
-  end
   if win_id and vim.api.nvim_win_is_valid(win_id) then
     vim.api.nvim_win_close(win_id, true)
   end
@@ -77,17 +176,21 @@ function M.close()
   buf_id = nil
 end
 
-local function show(auto_dismiss)
+function M.open()
   M.close()
 
-  local lines, width = build_content()
+  local lines, width, hl = build()
   local height = #lines
 
   buf_id = vim.api.nvim_create_buf(false, true)
   vim.api.nvim_buf_set_lines(buf_id, 0, -1, false, lines)
 
-  local hl_line = height - 2
-  vim.api.nvim_buf_add_highlight(buf_id, -1, "Bold", hl_line, 0, -1)
+  local ns = vim.api.nvim_create_namespace("turbo-debug.help")
+  for _, span in ipairs(hl) do
+    pcall(vim.api.nvim_buf_set_extmark, buf_id, ns, span[1], span[2], {
+      end_row = span[1], end_col = span[3], hl_group = span[4],
+    })
+  end
 
   vim.bo[buf_id].modifiable = false
   vim.bo[buf_id].bufhidden = "wipe"
@@ -107,23 +210,10 @@ local function show(auto_dismiss)
     noautocmd = true,
   })
 
-  if auto_dismiss then
-    timer = vim.uv.new_timer()
-    timer:start(3000, 0, vim.schedule_wrap(function() M.close() end))
-  else
-    vim.api.nvim_create_autocmd({ "CursorMoved", "InsertEnter", "CmdlineEnter" }, {
-      once = true,
-      callback = function() M.close() end,
-    })
-  end
-end
-
-function M.open()
-  show(false)
-end
-
-function M.splash()
-  show(true)
+  vim.api.nvim_create_autocmd({ "CursorMoved", "InsertEnter", "CmdlineEnter" }, {
+    once = true,
+    callback = function() M.close() end,
+  })
 end
 
 function M.is_open()
diff --git a/lua/turbo-debug/mode.lua b/lua/turbo-debug/mode.lua
index 7160534..20a58bf 100644
--- a/lua/turbo-debug/mode.lua
+++ b/lua/turbo-debug/mode.lua
@@ -4,49 +4,234 @@ local help = require("turbo-debug.help")
 local M = {}
 
 local active = false
-local active_buf = nil
 local active_win = nil
-local stashed_maps = {}
 local saved_winbar = nil
-local mapped_keys = {} -- track exactly which keys we set, for reliable cleanup
+local saved_statusline = nil
+local saved_laststatus = nil
+
+-- installed[buf][name] = { key = lhs, prev_n = , prev_v =  }
+local installed = {}
 
--- one-time dap-ui and virtual-text initialization
 local dapui_initialized = false
 local vt_initialized = false
 
+-- โ”€โ”€โ”€ layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+local function build_default_layouts()
+  local pos = config.opts.sidebar == "right" and "right" or "left"
+  return {
+    {
+      position = pos,
+      size = 40,
+      elements = {
+        { id = "scopes",      size = 0.40 },
+        { id = "watches",     size = 0.20 },
+        { id = "stacks",      size = 0.25 },
+        { id = "breakpoints", size = 0.15 },
+      },
+    },
+    {
+      position = "bottom",
+      size = 12,
+      elements = {
+        { id = "repl",    size = 0.50 },
+        { id = "console", size = 0.50 },
+      },
+    },
+  }
+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" })
+  hl(0, "TurboDebugSLState",        { default = true, link = "DiagnosticError" })
+  hl(0, "TurboDebugSLCtrl",         { default = true, link = "Function" })
+  hl(0, "TurboDebugSLStatus",       { default = true, link = "Comment" })
+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
+
+-- โ”€โ”€โ”€ global control buttons (used by the statusline) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+_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_toggle     = function() M.toggle() end
+
+function _G.turbo_debug_statusline()
+  local dap_ok, dap = pcall(require, "dap")
+  local state, state_hl
+  if not dap_ok then
+    state, state_hl = "READY", "TurboDebugSLStatus"
+  else
+    local s = dap.session()
+    if not s then
+      state, state_hl = "READY", "TurboDebugSLStatus"
+    elseif s.stopped_thread_id then
+      state, state_hl = "PAUSED", "TurboDebugSLState"
+    else
+      state, state_hl = "RUNNING", "TurboDebugSLCtrl"
+    end
+  end
+
+  -- โ— state, then 6 clickable controls, then dap progress text (right-aligned)
+  local parts = {
+    "%#" .. state_hl .. "# \xe2\x97\x8f " .. 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
+    "%*",
+    " %=",
+    "%#TurboDebugSLStatus#%{v:lua.require'dap'.status()}%*",
+    "  %l:%c ",
+  }
+  return table.concat(parts, "")
+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.
+  if vim.api.nvim_win_is_valid(win) then
+    vim.wo[win].wrap = false
+    vim.wo[win].sidescrolloff = 0
+    vim.wo[win].linebreak = false
+    vim.wo[win].cursorline = true
+  end
+end
+
 local function ensure_dapui()
   if dapui_initialized then return end
   dapui_initialized = true
-  require("dapui").setup(config.opts.dapui)
+  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)
 
   local dap = require("dap")
-  dap.listeners.after.event_terminated["turbo-debug"] = function()
-    if active then M.exit() end
-  end
-  dap.listeners.after.event_exited["turbo-debug"] = function()
-    if active then M.exit() end
+  dap.listeners.after.event_initialized["turbo-debug"] = function()
+    require("dapui").open()
   end
 
-  -- set winbar titles on dapui panels (REPL gets controls toolbar from dapui)
+  -- K = hover during a live dap session (works globally, not just modally)
+  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 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)
+
+  -- 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.
+  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)
+      end
+    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()
+      pcall(vim.api.nvim_buf_set_extmark, buf, ip_ns, line, 0, {
+        virt_text = {
+          { "  \xe2\x97\x80  ", "DapStopped" },   -- โ—€
+          { reason,             "TurboDebugIPReason" },
+        },
+        virt_text_pos = "eol",
+        hl_mode = "combine",
+        priority = 200,
+      })
+    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
+
+  -- dapui winbar titles + window options (wrap=false to stop the register
+  -- overflow bug). Apply on FileType so we catch every pane dapui creates.
   local titles = {
-    dapui_scopes = " Scopes",
-    dapui_watches = " Watches",
-    dapui_stacks = " Call Stack",
-    dapui_console = " Console",
-    dapui_breakpoints = " Breakpoints",
+    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",
   }
   vim.api.nvim_create_autocmd("FileType", {
-    pattern = "dapui_*",
+    pattern = { "dapui_*", "dap-repl" },
     callback = function(args)
-      local title = titles[vim.bo[args.buf].filetype]
-      if title then
-        vim.schedule(function()
-          local win = vim.fn.bufwinid(args.buf)
-          if win ~= -1 then vim.wo[win].winbar = title end
-        end)
-      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
+        -- 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,
   })
+
+  if config.opts.active_window_highlight then setup_active_win_highlights() end
 end
 
 local function ensure_vt()
@@ -55,123 +240,165 @@ local function ensure_vt()
   require("nvim-dap-virtual-text").setup(config.opts.virtual_text)
 end
 
--- keymap stash/restore
+-- โ”€โ”€โ”€ actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
-local function stash_mapping(buf, lhs)
-  for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do
-    if map.lhs == lhs then
-      stashed_maps[lhs] = map
-      return
-    end
-  end
-  stashed_maps[lhs] = nil
-end
-
-local function restore_mapping(buf, lhs)
-  local map = stashed_maps[lhs]
-  if map then
-    local rhs = map.rhs or map.callback
-    if rhs then
-      pcall(vim.keymap.set, "n", lhs, rhs, {
-        buffer = buf,
-        silent = map.silent == 1,
-        noremap = map.noremap == 1,
-        expr = map.expr == 1,
-        desc = map.desc,
-      })
-    end
-  else
-    pcall(vim.keymap.del, "n", lhs, { buffer = buf })
-  end
-end
-
--- actions (set up once)
-
-local 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
+
+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
+    -- no config โ†’ fall back to normal continue (which will prompt / error)
+    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
+  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
+
+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
+    smart_continue()
+  end
+end
+
 local function setup_actions()
-  if next(actions) then return end
+  if actions_cache then return actions_cache end
   local dap = require("dap")
   local pb = require("persistent-breakpoints.api")
 
-  actions.continue = function() dap.continue() end
-  actions.step_over = function() dap.step_over() end
-  actions.step_into = function() dap.step_into() end
-  actions.step_out = function() dap.step_out() end
-  actions.run_to_cursor = function() dap.run_to_cursor() end
-  actions.restart = function() dap.restart() end
-  actions.help = function()
-    if help.is_open() then help.close() else help.open() end
-  end
-  actions.terminate = function()
-    dap.terminate()
-    if config.opts.quit_exits_mode then M.exit() end
-  end
-
-  -- breakpoint actions (modal shortcuts for the d* globals)
-  actions.breakpoint = function() pb.toggle_breakpoint() end
-  actions.cond_breakpoint = function() pb.set_conditional_breakpoint() end
-  actions.clear_breakpoints = function() pb.clear_all_breakpoints() end
-
-  actions.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
-  actions.hover = function() require("dapui").eval() end
-  actions.eval = function() require("dapui").float_element("repl") end
-end
-
--- keymap lifecycle
--- track every key we set so cleanup is exact โ€” never miss one, never clear the wrong one
-
-local function set_keymaps(buf)
-  mapped_keys = {}
-  local keys = config.opts.keys
-  for name, key in pairs(keys) do
-    if key and actions[name] then
-      stash_mapping(buf, key)
-      vim.keymap.set("n", key, actions[name], { buffer = buf, silent = true, desc = "turbo-debug: " .. name })
-      if visual_actions[name] then
-        vim.keymap.set("v", key, actions[name], { buffer = buf, silent = true, desc = "turbo-debug: " .. name })
+  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     = function()
+      dap.terminate()
+      if config.opts.quit_exits_mode then M.exit() end
+    end,
+    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
-      mapped_keys[#mapped_keys + 1] = { name = name, key = key }
+      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
+  local acts = setup_actions()
+  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)
+      end
+      installed[buf][name] = { key = key, prev_n = prev_n, prev_v = prev_v }
     end
   end
 end
 
-local function clear_keymaps(buf)
-  if not vim.api.nvim_buf_is_valid(buf) then
-    mapped_keys = {}
-    stashed_maps = {}
-    return
-  end
-  for _, mk in ipairs(mapped_keys) do
-    pcall(vim.keymap.del, "n", mk.key, { buffer = buf })
-    if visual_actions[mk.name] then
-      pcall(vim.keymap.del, "v", mk.key, { buffer = buf })
+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
-    restore_mapping(buf, mk.key)
   end
-  mapped_keys = {}
-  stashed_maps = {}
+  installed = {}
 end
 
--- debug mode chrome
+-- โ”€โ”€โ”€ chrome: per-source-window winbar badge + global statusline controls โ”€โ”€
 
 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 = "%#DiagnosticError# ๐Ÿ› DEBUG %* %f"
-  vim.api.nvim_set_hl(0, "Cursor", { bg = "#ff6600", fg = "#000000" })
+  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()"
 end
 
 local function clear_debug_chrome()
@@ -180,37 +407,59 @@ local function clear_debug_chrome()
   end
   active_win = nil
   saved_winbar = nil
-  vim.api.nvim_set_hl(0, "Cursor", {})
+
+  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
+-- โ”€โ”€โ”€ public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 function M.enter()
   if active then return end
   active = true
+  define_highlights()
   setup_actions()
 
-  active_buf = vim.api.nvim_get_current_buf()
-  set_keymaps(active_buf)
-  set_debug_chrome()
+  vim.api.nvim_create_augroup("TurboDebugModalKeys", { clear = true })
+  vim.api.nvim_create_autocmd("BufEnter", {
+    group = "TurboDebugModalKeys",
+    callback = function(args)
+      if active then M._install_for_buf(args.buf) end
+    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()
   require("dapui").open()
 
+  -- second install pass after dapui has created its buffers, since BufEnter
+  -- doesn't always fire for windows that were opened without focus-change.
+  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
+  end)
+
   ensure_vt()
   require("nvim-dap-virtual-text").enable()
 
-  if config.opts.help_on_enter then
-    help.splash()
-  end
+  if config.opts.help_on_enter then help.open() end
 end
 
 function M.exit()
   if not active then return end
   active = false
 
-  clear_keymaps(active_buf or vim.api.nvim_get_current_buf())
-  active_buf = nil
+  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()
 
@@ -222,8 +471,6 @@ function M.toggle()
   if active then M.exit() else M.enter() end
 end
 
-function M.is_active()
-  return active
-end
+function M.is_active() return active end
 
 return M
diff --git a/tests/test_help.lua b/tests/test_help.lua
index c22d803..940671b 100644
--- a/tests/test_help.lua
+++ b/tests/test_help.lua
@@ -10,13 +10,13 @@ assert_eq(help.is_open(), true, "help window is open")
 help.close()
 assert_eq(help.is_open(), false, "help window is closed")
 
--- remapped key shows in help content
+-- remapped key shows in help content (new format wraps the key letter in parens)
 config.merge({ keys = { continue = "g" } })
 help.open()
 local found_g = false
 for _, w in ipairs(vim.api.nvim_list_wins()) do
   for _, line in ipairs(vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(w), 0, -1, false)) do
-    if line:find("g%s+Continue") then found_g = true end
+    if line:find("%(g%)") then found_g = true end
   end
 end
 assert_eq(found_g, true, "remapped key 'g' appears in help")