wandres.dev
PROCESOS EXTERNOS · jobs y terminal

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.

⏱ 20 min

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.

🎯 Al terminar esta lección sabrás
  • Explicar qué distingue a un buffer de terminal de un buffer normal.
  • Abrir y controlar una terminal desde Lua con jobstart y la opción term.
  • Moverte con soltura entre el modo terminal y el modo normal.
  • Reaccionar al ciclo de vida con TermOpen y TermClose, y escribir con chansend.

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.

💡
Ajusta las opciones locales al abrir

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.

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

📝
Lo esencial

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.

⚔️ 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.
  2. Añade un autocomando TermOpen que desactive los números y la columna de signos, y suba scrollback a cincuenta mil.
  3. Mapea el doble Esc para salir del modo terminal, y comprueba que un vim anidado dentro sigue funcionando.
  4. 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.
  5. Usa TermClose para cerrar la ventana solo cuando el código de salida sea cero, y muestra el código en un mensaje cuando no lo sea.