Saltar al contenido

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 sucesor del termopen deprecado), el canal en vim.bo.channel y chansend para hablar con el proceso, los autocomandos TermOpen y TermClose, y nvim_open_term: el emulador sin proceso.

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. En la lección 4.2 usaste :terminal como usuario; aquí lo construyes tú desde Lua, porque Neovim expone su emulador como lo que ya sabes manejar: un buffer.

  • Explicar qué distingue a un buffer de terminal de un buffer normal, y qué le pasa a su contenido.
  • Abrir y controlar una terminal desde Lua con jobstart y la opción term.
  • Hablar con el proceso mediante el canal de vim.bo.channel y chansend.
  • Reaccionar al ciclo de vida con TermOpen y TermClose.
  • Usar nvim_open_term para tener el emulador sin proceso: el colorizador de ANSI.

Qué es realmente

Un buffer de terminal no contiene texto que tú edites: contiene la pantalla de un emulador. Neovim lleva dentro un emulador VT220/xterm basado en libvterm, 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 se edita con normalidad, porque la única autoridad sobre su contenido es el proceso. Su historial está limitado por la opción scrollback —diez mil líneas por defecto, ampliable hasta un millón—, y lo que se sale por arriba desaparece. Su nombre tiene la forma term:// seguida del directorio y del comando, lo que permite reconocerlo con un patrón de autocomando. Y arrastra dos comportamientos que ya conoces de otra manera: nace con 'modified' activado —ponle nomodified si el aviso al cerrarlo te molesta— y solo hace tail de la salida nueva cuando el cursor está en la última línea, que es exactamente la heurística de autoscroll que escribiste a mano en la lección 13.2.

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 expone su canal en la opción de buffer 'channel': desde Lua, vim.bo.channel. 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 modo más del editor. Las teclas van al proceso, no a Neovim, y por eso necesita una salida explícita.

b:terminal_job_id está deprecado: el canal vive en ‘channel’

Verás b:terminal_job_id en miles de configuraciones y plugins. Desde la 0.12 está oficialmente deprecado: la referencia canónica es la opción de buffer 'channel' —vim.bo.channel en el buffer actual, vim.bo[bufnr].channel en cualquier otro—. Lo mismo le pasó a b:terminal_job_pid, sustituido por jobpid(vim.bo.channel). Reconoce las formas viejas al leer código ajeno; escribe siempre la nueva.

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 función termopen, deprecada desde la 0.11:

LUA
local function abrir_terminal(cmd)  local buf = vim.api.nvim_create_buf(false, true)  local win = vim.api.nvim_open_win(buf, true, {   -- true: entra en la ventana    split = 'below',    height = math.floor(vim.o.lines * 0.35),  })  local chan = vim.fn.jobstart(cmd, {    term = true,    cwd = vim.fs.root(0, '.git') or vim.uv.cwd(),    on_exit = function(_, code)      vim.schedule(function()        vim.notify('terminado con código ' .. code)      end)    end,  })  vim.cmd.startinsert()  return buf, win, chanend

La condición que documenta la ayuda es estricta: term = true conecta el proceso al buffer actual, que debe estar sin modificar. Por eso el orden importa: primero creas el buffer scratch, luego entras en su ventana —el segundo argumento de nvim_open_win en true lo convierte en el actual— y solo entonces lanzas el trabajo. Un buffer con cambios sin guardar hace fallar la llamada, y uno que ya fue terminal tampoco se puede reutilizar: necesitas uno nuevo.

El valor devuelto es el identificador del canal —el mismo que quedará en vim.bo.channel—, tu única manija para hablar con el proceso. Y los valores de error siguen la convención de jobstart: 0 por argumentos inválidos y -1 si el ejecutable no existe, la comprobación que la lección 13.5 convierte en rutina.

La opción term implica pty: el hijo no ve una tubería sino una pseudoterminal con las dimensiones de tu ventana y $TERM fijado a xterm-256color. Ese matiz resuelve el misterio del búfer de bloque de la lección anterior por la vía estructural: un programa que se sabe en una terminal vuelve al búfer de línea y emite su salida en cuanto la produce, con colores incluidos.

TermOpen ya no es para quitar los números

Casi todas las guías enseñan un autocomando TermOpen que desactiva number, relativenumber y signcolumn. Está obsoleto: desde la 0.11 Neovim fija solo esos valores locales en cada terminal nuevo, junto a list, wrap y foldcolumn. Reserva TermOpen para lo que sí es tuyo: subir scrollback si ejecutas builds largos, una statusline local con b:term_title, o un startinsert si quieres entrar escribiendo.

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, a, I o A desde el modo normal, o :startinsert
Salir al modo normal <C-\><C-n>
Ejecutar un solo comando normal y volver <C-\><C-o>
Enviar cualquier otra tecla tras <C-\> Ambas llegan al proceso
Mapear solo en este modo vim.keymap.set('t', …)

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:

LUA
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.

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ú.

LUA
local function enviar_linea(texto)  vim.fn.chansend(vim.bo.channel, 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 mecanismo exacto de 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, con el código de salida en vim.v.event.status —o -1 si lo que murió fue el buffer, borrado con el trabajo aún vivo—:

LUA
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 })   -- éxito: 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ú. Sin tu autocomando, el comportamiento de serie lo pone el grupo nvim.terminal de autocomandos por defecto, que pinta [Process exited] y espera una tecla; se desactiva entero con :autocmd! nvim.terminal TermClose, sabiendo que te llevas también el resto de su lógica.

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.

LUA
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. Es la misma mecánica que snacks.terminal te dio hecha en la lección 4.2; ahora sabes exactamente qué hay debajo.

El emulador sin proceso: nvim_open_term

Queda una pieza que casi nadie conoce y que separa dos conceptos que esta lección ha tratado juntos. jobstart con term hace dos cosas: arranca un proceso y le pone delante un emulador. nvim_open_term hace solo la segunda: convierte un buffer en terminal sin proceso alguno, y te devuelve un canal en el que todo lo que escribas con chansend se interpreta como lo haría una terminal.

¿Para qué quieres una terminal sin nadie dentro? Para renderizar. Cualquier texto con códigos de color ANSI —la salida capturada de un test runner, un log coloreado, lo que te llegó por vim.system— se pinta con sus colores de verdad en vez de mostrar ^[[31m literal. La propia ayuda trae el ejemplo canónico, un colorizador en cuatro líneas:

LUA
vim.api.nvim_create_user_command('TermHl', function()  vim.api.nvim_open_term(0, {})end, { desc = 'interpreta los códigos ANSI del buffer actual' })

Dos detalles de su contrato: acepta force_crlf —activado por defecto, convierte \n en el \r\n que un emulador espera— y on_input, un callback que recibe lo que se teclee en modo terminal, por si quieres construir un REPL falso donde el «proceso» es tu propio Lua. Y una advertencia de orden: abre primero la ventana y luego el terminal, para que el emulador nazca ya con las dimensiones correctas.

El terminal integrado invierte quién controla la pantalla

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 se edita con normalidad: 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.

Lo esencial

Un buffer de terminal proyecta la pantalla de un emulador VT, no texto editable. Se abre con jobstart y term = true sobre un buffer actual y sin modificar, e implica una pseudoterminal con las dimensiones de la ventana. El canal vive en vim.bo.channel —b:terminal_job_id está deprecado— y se escribe con chansend. TermOpen configura, TermClose entrega el código en v:event.status, y nvim_open_term te da el emulador sin proceso para interpretar ANSI.

El buffer pasa a ser un emulador y su contenido se reinterpreta: los ^[[31m desaparecen y aparece el color. Es el TermHl de la ayuda oficial, reducido a su esencia.

vim.bo.channel es el canal del terminal actual y chansend escribe bytes crudos: el \r final es el Enter, y lo pones tú. Este one-liner es el corazón de cualquier plugin de send to terminal.

Con un autocomando TermClose que imprima vim.v.event.status verás llegar el 3. Cambia el comando a exit 0 y decide en ese número si la ventana muere sola o se queda para leerla.

La forma deprecada era b:terminal_job_pid; la actual pasa por el canal. Con el PID puedes mirar el proceso desde fuera con el monitor del sistema, que en la lección 13.5 será una herramienta de verificación habitual.

Una terminal a medida

  1. Escribe abrir_terminal como función reutilizable que acepte un comando y abra una división inferior con la altura al treinta y cinco por ciento. Comprueba que lanzarla con cambios sin guardar en un buffer normal no los pisa jamás.
  2. Añade un autocomando TermOpen que suba scrollback a cincuenta mil y ponga una statusline local con b:term_title. Verifica que los números de línea ya venían desactivados sin que tú hicieras nada.
  3. Mapea el doble <Esc> para salir del modo terminal, y comprueba que un vim anidado dentro sigue recibiendo su <Esc> simple.
  4. Implementa un comando que envíe la línea bajo el cursor a la terminal abierta usando chansend y vim.bo.channel, y otra versión que envíe la selección visual entera.
  5. Usa TermClose para cerrar la ventana solo cuando v:event.status sea cero, y muestra el código en un mensaje cuando no lo sea.
  6. Captura con vim.system la salida de una herramienta con colores forzados —ls --color=always o tu test runner— y píntala en un buffer con nvim_open_term más chansend.