Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/adrs/004.tabs-vim.keybinding-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 004. Keybinding Cleanup — Expose Functions, Minimal OOTB Bindings

**SPEC:** tabs.vim / key-binding
**Status:** Accepted
**Last Updated:** 2026-04-04

---

## Decision

Refactor `plugin/tabs.vim` to expose public `TabsVim_*` functions for all non-trivial operations, and reduce out-of-the-box bindings to only what cannot be cleanly delegated to the user's vimrc.

**OOTB behavior retained:**
- Mouse drag-and-drop file opening (bracketed-paste infrastructure)

**All keybindings removed from the plugin.** Users wire them via their own vimrc using the exposed functions. Vim's native `gt` / `gT` / `<count>gt` cover tab navigation without any plugin binding.

---

## Context

The current plugin ships ~20 hard-coded keybindings covering terminals, window splits, buffer rename, redo, file path copy, fzf integration, and tab jumps. This creates several problems:

1. **Conflicts**: Bindings like `<C-]>` (native: jump to tag), `gF` (native: open file under cursor), `<leader>r` (commonly user-assigned), and terminal mode maps override keys that users or other plugins rely on.
2. **Scope creep**: Some bindings (`<leader>r` for redo, `<leader>fy` for clipboard) are general editor utilities with no tab-management justification.
3. **Opaque defaults**: Users cannot audit what the plugin has bound without reading source code.
4. **No opt-out granularity**: No per-binding disable flag exists; users must shadow every conflicting map.

Vim already provides `gt`, `gT`, and `<count>gt` for tab navigation — there is no need for the plugin to bind `<Tab>`/`<S-Tab>` either.

Mouse DnD is retained OOTB because it requires bracketed-paste terminal infrastructure (`t_BE`/`t_BD` sequences and `<F30>`/`<F31>` key aliases) that cannot meaningfully be expressed as a user-side vimrc binding. It also has zero keymap conflict risk.

The plugin's declared principles are *Locality*, *Discoverability*, *Speed*, and *Integration* — none of which require owning the user's keymap.

---

## Considered Options

| Option | Pros | Cons |
|--------|------|------|
| **Expose functions, only mouse DnD OOTB** *(chosen)* | Zero keymap conflicts; users own their keymap; functions are stable API | Users must add vimrc bindings |
| Keep `<Tab>`/`<S-Tab>` OOTB | Convenient defaults | Redundant with native `gt`/`gT`; `<Tab>` conflicts with completion plugins (e.g. copilot.vim) |
| Keep all bindings, add `g:tabs_vim_no_mappings` flag | Easy single opt-out | All-or-nothing; doesn't help with partial conflicts |
| Keep all bindings, add per-binding `g:tabs_vim_map_*` flags | Fine-grained opt-out | Dozens of config variables; documentation burden |

Comment thread
jesse23 marked this conversation as resolved.
Terminal mode escape mappings (`tnoremap <Esc>`, `tnoremap <C-]>`) are removed — especially collision-prone and purely a user preference. The `<C-]>` recommendation (use it to enter terminal insert mode, overriding its native tag-jump role) is documented in the key-binding SPEC as an opt-in with the trade-off explained.

---

## Consequences

- Only functions with real logic are promoted to `TabsVim_*` public API: `TabsVim_ToggleHorizTerm`, `TabsVim_ToggleVertTerm`, `TabsVim_NewTabTerm`, `TabsVim_CloseOrHide`, `TabsVim_RenameBuffer`, `TabsVim_FzfOpenInTab`. Simple one-liner native commands (`:tabnew`, `:sp`, `:tabonly`, `:tabedit <cfile>`, etc.) are documented as direct vimrc mappings — no wrapper is exposed.
- A new SPEC `key-binding.md` documents the public function API, direct native-command wiring patterns, and trade-off notes (`g<number>`, `<C-]>`).
- The existing `tabs.vim` SPEC is updated to reference the key-binding SPEC and mark the refactor feature.
- Existing users who relied on the removed bindings must add them to their vimrc — the key-binding SPEC provides a ready-made example block.
Comment thread
jesse23 marked this conversation as resolved.
- No behavior changes: the logic inside each function is unchanged; only the binding wiring moves to the user.
129 changes: 129 additions & 0 deletions docs/specs/key-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# SPEC: Key Binding

**Last Updated:** 2026-04-04

---

## Description

Defines the keybinding contract for `tabs.vim`: what the plugin installs out-of-the-box (OOTB), what public functions it exposes for users to bind themselves, and the recommended vimrc wiring pattern.

The plugin intentionally keeps OOTB bindings minimal to avoid conflicts with user keymaps and other plugins. All operations are exposed as stable `TabsVim_*` public functions. Users adopt the ones they want and bind them to their preferred keys.

**Persona:** Vim/Neovim users who want tab-management features without the plugin imposing a full keymap.

---

## OOTB Behavior

The plugin installs **no user-facing keybindings** by default. Tab navigation is already covered by Vim natively (`gt` / `gT` / `<count>gt`).

The one OOTB behavior is **mouse drag-and-drop file opening**: dropping a file from a file manager into the terminal opens it in a new tab. This is implemented via bracketed-paste terminal sequences (`t_BE`/`t_BD`) and internal synthetic key aliases (`<F30>`/`<F31>`) — these are not user-facing mappings. It activates only in terminal Vim (not GVim) on Vim 8.0.0210+.

---

## Public Function API

Only operations with real logic are exposed as `TabsVim_*` functions. Simple one-liner native commands (`:tabnew`, `:sp`, `:tabonly`, `:only`, `:tabedit <cfile>`, `let @+ = expand("%:p")`) are documented as direct vimrc mappings below — no wrapper needed.

### Terminal Operations

| Function | Description |
|----------|-------------|
| `TabsVim_ToggleHorizTerm()` | Toggle a persistent horizontal split terminal (below, 15 rows) |
| `TabsVim_ToggleVertTerm()` | Toggle a persistent vertical split terminal (right, 80 cols) |
| `TabsVim_NewTabTerm()` | Open a new terminal in its own tab |
Comment thread
jesse23 marked this conversation as resolved.

### Window / Buffer Operations

| Function | Description |
|----------|-------------|
| `TabsVim_CloseOrHide()` | Close the current window; if last window, prompt to quit Vim; if a terminal buffer, toggle-hide it instead |
| `TabsVim_RenameBuffer()` | Prompt to rename the current buffer |

Comment thread
jesse23 marked this conversation as resolved.
### FZF Integration

| Function | Description |
|----------|-------------|
| `TabsVim_FzfOpenInTab()` | Open fzf file picker with `tabedit` as the sink (requires fzf.vim) |

Comment thread
jesse23 marked this conversation as resolved.
---

## Recommended vimrc Wiring

Copy and adapt the block below. Remove any line you don't use.

```vim
" ── Tab navigation ────────────────────────────────────────────────────────────
" Native: gt / gT / <count>gt — no plugin binding needed.
nnoremap <silent> <leader>wt :tabnew<CR>
nnoremap <silent> <leader>x :call TabsVim_CloseOrHide()<CR>
nnoremap <silent> <leader>X :tabonly<CR>

" Direct tab jumps: <leader>1 … <leader>9 (native <count>gt)
for s:i in range(1, 9)
execute 'nnoremap <silent> <leader>' . s:i . ' ' . s:i . 'gt'
endfor

" ── Window / split — native commands ─────────────────────────────────────────
nnoremap <silent> <leader>ws :sp<CR>
nnoremap <silent> <leader>wv :vsp<CR>
nnoremap <silent> <leader>wm :only<CR>
nnoremap <silent> <leader>wr :call TabsVim_RenameBuffer()<CR>

" ── Terminal ──────────────────────────────────────────────────────────────────
nnoremap <silent> <leader>ts :call TabsVim_ToggleHorizTerm()<CR>
nnoremap <silent> <leader>tv :call TabsVim_ToggleVertTerm()<CR>
nnoremap <silent> <leader>tt :call TabsVim_NewTabTerm()<CR>

" ── File operations — native commands ────────────────────────────────────────
nnoremap <silent> gF :tabedit <cfile><CR>
nnoremap <silent> <leader>fy :let @+ = expand("%:p")<CR>
nnoremap <silent> <leader>ft :call TabsVim_FzfOpenInTab()<CR>
```

---

## Keybinding Notes

### `g<number>` for direct tab jumps

`<number>gt` (e.g. `3gt`) is native Vim and requires no plugin function. If you want a shorter shorthand, `g1`–`g9` are unbound in stock Vim and generally free — but `g0` is taken (go to first character of the screen line), so never bind `g0`.

```vim
for s:i in range(1, 9)
execute 'nnoremap <silent> g' . s:i . ' ' . s:i . 'gt'
endfor
```

This is a pure vimrc concern — no plugin function is needed.

### `<C-]>` for terminal navigation

The primary use case is quickly exiting terminal mode to normal mode so you can scroll, copy, and perform window operations (splits, focus changes, etc.). Vim's native way to do this is `<C-\><C-n>`, which is an awkward two-key chord. `<C-]>` is a natural single-key replacement:

```vim
tnoremap <C-]> <C-\><C-n> " exit terminal mode → normal mode (scroll, copy, splits)
```

This mapping is **safe** — `<C-]>` has no meaningful default binding in terminal mode.

**Why `<C-]>` and not `<C-[>`?** `<C-[>` is the ASCII equivalent of `<Esc>` and most terminals send an identical byte sequence for both — Vim cannot distinguish them. Using `<C-[>` as a terminal-exit binding would conflict with any `<Esc>` mapping and produce unreliable behavior across terminal emulators. `<C-]>` sends a distinct byte (`0x1D`) that terminals reliably deliver as-is.

The companion normal-mode mapping is optional and carries a trade-off:

```vim
nnoremap <C-]> i " re-enter terminal insert mode from normal mode
```

In normal mode, `<C-]>` is the native "jump to tag under cursor" (ctags / cscope / LSP). Override it only if you do not rely on tag jumping. If you do use tags, leave this one out and use `i` or `a` to re-enter terminal insert mode manually.

---

## Features

| Feature | Description | ADR | Done? |
|---------|-------------|-----|-------|
| **No OOTB keybindings** | Plugin installs zero keymaps; mouse DnD infrastructure only | ADR-004 | ✅ |
| **Public function API** | All operations promoted to `TabsVim_*` public functions | ADR-004 | ✅ |
| **Example vimrc block** | Ready-made mapping block for users to copy into vimrc | — | ✅ |
2 changes: 1 addition & 1 deletion docs/specs/tabs.vim.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ let g:tabs_vim_colors = {

## Related Specs

None yet.
- [key-binding.md](key-binding.md) — public `TabsVim_*` function API, OOTB behavior contract, and recommended vimrc wiring

---

Expand Down
93 changes: 28 additions & 65 deletions plugin/tabs.vim
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ let s:vterm_bufnr = -1
" TERMINAL: Split terminals & new tab terminal
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

" Toggle split terminal: below/horizontal (<leader>h, <leader>ts)
" or vertical (<leader>tv)
function! s:ToggleTerm(bufvar, open_cmd, resize_cmd) abort
let bufnr = eval(a:bufvar)
for w in range(1, winnr('$'))
Expand All @@ -37,22 +35,18 @@ function! s:ToggleTerm(bufvar, open_cmd, resize_cmd) abort
execute a:resize_cmd
endfunction

nnoremap <silent> <leader>h :call <SID>ToggleTerm('s:term_bufnr', 'below', 'resize 15')<CR>
nnoremap <silent> <leader>ts :call <SID>ToggleTerm('s:term_bufnr', 'below', 'resize 15')<CR>
nnoremap <silent> <leader>tv :call <SID>ToggleTerm('s:vterm_bufnr', 'vertical', 'vertical resize 80')<CR>
function! TabsVim_ToggleHorizTerm() abort
call s:ToggleTerm('s:term_bufnr', 'below', 'resize 15')
endfunction

function! TabsVim_ToggleVertTerm() abort
call s:ToggleTerm('s:vterm_bufnr', 'vertical', 'vertical resize 80')
endfunction

" New tab terminal (<leader>tt)
function! s:NewTabTerm() abort
function! TabsVim_NewTabTerm() abort
tab term
setlocal nobuflisted
endfunction
nnoremap <silent> <leader>tt :call <SID>NewTabTerm()<CR>

" Terminal mode mappings
tnoremap <Esc> <Esc>
tnoremap <C-]> <C-\><C-n>
nnoremap <C-]> i
inoremap <C-]> <Esc>

" Terminal settings
augroup TermSettings
Expand All @@ -73,30 +67,12 @@ function! s:ResizeTerminals() abort
endfunction

"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
" WINDOWS & BUFFERS: Tab navigation, creation, closing
" WINDOWS & BUFFERS
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

" Tab navigation: <Tab> next, <S-Tab> prev
nnoremap <silent> <Tab> :tabnext<CR>
nnoremap <silent> <S-Tab> :tabprev<CR>

" Window/split operations
nnoremap <silent> <leader>ws :sp<CR>
nnoremap <silent> <leader>wv :vsp<CR>
nnoremap <silent> <leader>wm :only<CR>

" Buffer operations
nnoremap <silent> <leader>wr :call <SID>RenameBuffer()<CR>
nnoremap <silent> <leader>wb :Buffers<CR>

" Tab operations
nnoremap <silent> <leader>wt :tabnew<CR>
nnoremap <silent> <leader>x :call <SID>CloseOrHideSplit()<CR>
nnoremap <silent> <leader>X :tabonly<CR>

" Close window or terminal, with prompt to quit if it's the last window
function! s:CloseOrHideSplit() abort
if winnr('$') == 1
" Close window or terminal, with prompt to quit if it's the last window in the last tab
function! TabsVim_CloseOrHide() abort
if tabpagenr('$') == 1 && winnr('$') == 1
if confirm('Quit Vim?', "&Yes\n&No", 2) == 1
qall!
Comment thread
jesse23 marked this conversation as resolved.
endif
Expand All @@ -106,31 +82,37 @@ function! s:CloseOrHideSplit() abort
if &buftype ==# 'terminal'
if bufnr('%') == s:vterm_bufnr
call s:ToggleTerm('s:vterm_bufnr', 'vertical', 'vertical resize 80')
else
elseif bufnr('%') == s:term_bufnr
call s:ToggleTerm('s:term_bufnr', 'below', 'resize 15')
else
close
endif
else
close
endif
endfunction

" Rename current buffer
function! s:RenameBuffer() abort
function! TabsVim_RenameBuffer() abort
let l:new_name = input('Rename buffer: ', expand('%:t'))
if empty(l:new_name)
return
endif
execute 'file ' . fnameescape(l:new_name)
endfunction

" Copy file path to clipboard
nnoremap <silent> <leader>fy :let @+ = expand("%:p")<CR>

" gF: open file-under-cursor in a new tab (extends built-in gf)
nnoremap <silent> gF :tabedit <cfile><CR>

" <leader>r: redo
nnoremap <silent> <leader>r <Cmd>redo<CR>
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
" FZF Integration: Open files in tabs
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
function! TabsVim_FzfOpenInTab() abort
Comment thread
jesse23 marked this conversation as resolved.
if !exists('*fzf#vim#files') || !exists('*fzf#vim#with_preview')
echohl WarningMsg
echo 'tabs.vim: TabsVim_FzfOpenInTab requires fzf.vim (fzf#vim#files not available)'
echohl None
return
endif
call fzf#vim#files('', fzf#vim#with_preview({'sink': 'tabedit'}), 0)
endfunction

"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
" DRAG-AND-DROP: Drop a file path onto the terminal → open in new tab
Expand Down Expand Up @@ -289,22 +271,3 @@ if exists('##ModeChanged')
autocmd ModeChanged * redrawtabline
augroup END
endif

"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
" Direct Tab Jump: <leader>[1-9] to jump to tab N
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
nnoremap <silent> <leader>1 :1tabnext<CR>
nnoremap <silent> <leader>2 :2tabnext<CR>
nnoremap <silent> <leader>3 :3tabnext<CR>
nnoremap <silent> <leader>4 :4tabnext<CR>
nnoremap <silent> <leader>5 :5tabnext<CR>
nnoremap <silent> <leader>6 :6tabnext<CR>
nnoremap <silent> <leader>7 :7tabnext<CR>
nnoremap <silent> <leader>8 :8tabnext<CR>
nnoremap <silent> <leader>9 :9tabnext<CR>

"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
" FZF Integration: Open files in tabs
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
" <leader>ft: Open file in tab via fzf
nnoremap <silent> <leader>ft :call fzf#vim#files('', fzf#vim#with_preview({'sink': 'tabedit'}), 0)<CR>
Loading