El buffer de terminal: un emulador dentro del editor
Qué es realmente un buffer de terminal, cómo abrirlo desde Lua con jobstart y term, el modo terminal y su ergonomía, y cómo hablar con el proceso interactivo mediante chansend y los autocomandos TermOpen y TermClose.
Hasta ahora el proceso hijo era mudo: le dabas argumentos y te devolvía bytes. Pero hay herramientas que no funcionan así. Un git rebase interactivo, un htop, un depurador, un instalador que pregunta: todos esperan una terminal al otro lado, con su cursor, sus colores y su capacidad de responder. Neovim lleva un emulador de terminal completo dentro y lo expone como lo que ya sabes manejar: un buffer.
- Explicar qué distingue a un buffer de terminal de un buffer normal.
- Abrir y controlar una terminal desde Lua con
jobstarty la opciónterm. - Moverte con soltura entre el modo terminal y el modo normal.
- Reaccionar al ciclo de vida con
TermOpenyTermClose, y escribir conchansend.
Qué es realmente
Un buffer de terminal no contiene texto que tú edites: contiene la pantalla de un emulador. Neovim implementa un terminal VT compatible, interpreta las secuencias de escape que emite el programa —mover cursor, borrar línea, cambiar color— y proyecta el resultado sobre las líneas del buffer. Lo que ves es un reflejo, no una fuente.
De ahí se derivan todas sus rarezas. El buffer no es modificable con los comandos de edición normales, porque la única autoridad sobre su contenido es el proceso. Su historial está limitado por la opción scrollback, y lo que se sale por arriba desaparece. Y su nombre tiene la forma term:// seguida del directorio y del comando, lo que permite reconocerlo con un patrón de autocomando.
Emulador VT
Neovim interpreta secuencias de escape ANSI. Los colores, el cursor y el borrado de pantalla funcionan de verdad, no son texto plano.
Canal bidireccional
Cada terminal tiene un identificador de canal en b:terminal_job_id. Por ahí entra lo que escribes y sale lo que el programa pinta.
Scrollback finito
La opción scrollback fija cuántas líneas se conservan por encima de la pantalla visible. Al superarlas, las más antiguas se pierden sin aviso.
Modo propio
El modo terminal es un quinto modo. Las teclas van al proceso, no a Neovim, y por eso necesita una salida explícita.
Abrirla desde Lua
El comando :terminal es la vía rápida, pero para construir algo reutilizable necesitas control sobre la ventana y el buffer. La forma moderna es jobstart con la opción term activada, que sustituye a la antigua función termopen:
local function abrir_terminal(cmd)
local buf = vim.api.nvim_create_buf(false, true)
local win = vim.api.nvim_open_win(buf, true, {
split = 'below',
height = math.floor(vim.o.lines * 0.35),
})
local chan = vim.api.nvim_buf_call(buf, function()
return vim.fn.jobstart(cmd, {
term = true,
cwd = vim.fs.root(0, '.git'),
on_exit = function(_, code)
vim.schedule(function()
vim.notify('terminado con codigo ' .. code)
end)
end,
})
end)
vim.cmd.startinsert()
return buf, win, chan
end
Hay una condición estricta y poco documentada: la terminal solo puede abrirse sobre un buffer vacío y que sea el actual. Por eso el nvim_buf_call, que ejecuta la llamada con ese buffer como contexto. Si intentas abrirla sobre un buffer con contenido, la llamada falla. Si el buffer ya fue terminal una vez, tampoco sirve: necesitas uno nuevo.
El valor devuelto es el identificador de canal, la única manija que tendrás para hablar con el proceso. Guárdalo.
Un buffer de terminal con números de línea y columna de signos se ve mal y desperdicia ancho. Engancha un autocomando a TermOpen que ponga number, relativenumber y signcolumn en su forma discreta, y sube scrollback si vas a ejecutar builds largos. Al ser locales al buffer, no afectan al resto del editor.
El modo terminal
Cuando el cursor está en un buffer de terminal y entras en modo terminal, cada tecla que pulsas viaja al proceso. Eso incluye combinaciones que en cualquier otro contexto serían de Neovim, y es lo correcto: un Ctrl-C debe interrumpir al programa, no a tu editor.
| Acción | Cómo |
|---|---|
| Entrar en modo terminal | i o a desde el modo normal |
| Salir al modo normal | Ctrl-barra Ctrl-n |
Enviar un Ctrl-barra literal |
Ctrl-barra Ctrl-barra |
| Ver el historial | Modo normal y navegación habitual |
| Pegar un registro | Ctrl-r seguido del registro, en modo terminal |
La secuencia de salida es incómoda a propósito, porque debía ser una combinación que ningún programa reclamara. Casi todo el mundo la reasigna:
vim.keymap.set('t', '<Esc><Esc>', [[<C-\><C-n>]], { desc = 'salir del modo terminal' })
vim.keymap.set('t', '<C-h>', [[<C-\><C-n><C-w>h]], { desc = 'ventana izquierda' })
El doble Esc es un buen compromiso: un Esc simple lo necesitan vim anidado, los depuradores y cualquier programa con modos, mientras que dos seguidos casi nunca significan nada dentro del proceso.
flowchart TB n[Modo normal en el buffer de terminal] -->|i o a| t[Modo terminal] t -->|las teclas van al proceso| p[Proceso hijo] p -->|secuencias de escape| e[Emulador VT de Neovim] e -->|pantalla proyectada| b[Lineas del buffer] t -->|Ctrl barra y Ctrl n| n b -->|TermClose| c[El proceso ha muerto]
Hablar y escuchar
Escribir en la terminal desde Lua se hace con chansend, que envía bytes crudos al canal. No hay envoltorio: si quieres que el programa reciba un retorno, mándalo tú.
local function enviar(chan, texto)
vim.fn.chansend(chan, texto .. '\r')
end
Con eso ya puedes construir integraciones reales: enviar la línea bajo el cursor a un intérprete, reejecutar el último comando, alimentar un REPL con la selección visual. Es el mismo mecanismo que usan los plugins de send to terminal, y no tiene más misterio que decidir qué texto mandas.
Del lado de la escucha, dos autocomandos cubren el ciclo de vida completo. TermOpen dispara justo al crearse y es donde configuras el buffer. TermClose dispara cuando el proceso muere, y es donde decides qué pasa después:
vim.api.nvim_create_autocmd('TermClose', {
callback = function(ev)
if vim.v.event.status == 0 then
vim.api.nvim_buf_delete(ev.buf, { force = true }) -- exito: fuera
end -- fallo: se queda para leerlo
end,
})
Ese condicional es la ergonomía que separa una integración usable de una molesta. Si el comando funcionó no quieres ver una ventana con un cadáver; si falló, quieres el error en pantalla hasta que lo cierres tú.
Un último patrón cierra el círculo: la terminal persistente. En vez de crear una nueva cada vez, guardas el buffer y el canal, y el atajo alterna la ventana sin matar el proceso.
local estado = { buf = nil, win = nil, chan = nil }
local function alternar()
if estado.win and vim.api.nvim_win_is_valid(estado.win) then
return vim.api.nvim_win_hide(estado.win) -- oculta, no destruye
end
if estado.buf and vim.api.nvim_buf_is_valid(estado.buf) then
estado.win = vim.api.nvim_open_win(estado.buf, true, { split = 'below' })
return vim.cmd.startinsert()
end
estado.buf, estado.win, estado.chan = abrir_terminal(vim.o.shell)
end
nvim_win_hide es la pieza clave: cierra la ventana dejando el buffer y su proceso intactos, así que tu shell conserva el historial, el directorio y cualquier programa que tuvieras corriendo. Destruir el buffer al ocultar es el error que convierte una terminal integrada en algo peor que cambiar de aplicación.
Todo lo que has hecho hasta este nivel comparte una premisa: Neovim es la autoridad sobre el buffer. Tú editas, los plugins escriben, el LSP anota, pero siempre a través de una API que Neovim media y cuyo modelo de datos es una lista de líneas de texto. El buffer de terminal rompe esa premisa de raíz, y entenderlo mal es la fuente de casi todos los plugins de terminal defectuosos. Aquí el modelo de datos autoritativo no son las líneas: es una matriz de celdas con atributos —carácter, color de frente, color de fondo, negrita, inversión— que vive en el emulador y que las líneas del buffer solo aproximan para que puedas mirarlas. Cuando un programa emite la secuencia que borra la línea y reescribe una barra de progreso sesenta veces por segundo, en el buffer no aparecen sesenta líneas: aparece una celda que cambia. Esa asimetría explica cosas que de otro modo parecen caprichos. Explica por qué el buffer no es modificable: escribir en él sería mentirle a la matriz, que se repintará y borrará tu edición en cuanto el programa toque esa fila. Explica por qué scrollback es una opción y no una promesa: lo que sale por arriba abandona la matriz y se archiva como texto plano perdiendo sus atributos. Y explica por qué el modo terminal existe como modo propio en vez de resolverse con mapeos: el modelo de entrada también se invierte, porque el proceso hijo es quien decide qué significa cada tecla, y Neovim renuncia deliberadamente a interpretarlas. Un buffer de terminal, en suma, no es un buffer con un proceso pegado: es una ventana a otro modelo de computación que Neovim aloja pero no gobierna. Diseña tus integraciones desde ahí —el canal es la interfaz, las líneas son solo la vista— y dejarás de pelearte con él.
Un buffer de terminal proyecta la pantalla de un emulador VT, no texto editable. Se abre con jobstart y la opción term sobre un buffer vacío y actual, y devuelve un canal que guardas para escribir con chansend. El modo terminal envía las teclas al proceso y se sale con la secuencia de escape, casi siempre reasignada. TermOpen configura y TermClose decide el destino de la ventana.
- Escribe
abrir_terminalcomo función reutilizable que acepte un comando y abra una división inferior con la altura al treinta y cinco por ciento. - Añade un autocomando
TermOpenque desactive los números y la columna de signos, y subascrollbacka cincuenta mil. - Mapea el doble
Escpara salir del modo terminal, y comprueba que unvimanidado dentro sigue funcionando. - Implementa un comando que envíe la línea bajo el cursor a la terminal abierta usando
chansend, y otra versión que envíe la selección visual entera. - Usa
TermClosepara cerrar la ventana solo cuando el código de salida sea cero, y muestra el código en un mensaje cuando no lo sea.