UI de Terminal

Construya una aplicación de terminal que posee la pantalla, dibuja marcos estilizados y aloja otro proceso dentro de una región con borde de su propio diseño.

Qué Estamos Construyendo

Un proceso shell se ejecuta sobre un terminal host y toma el arrendamiento de presentación del terminal físico. Pinta una cabecera, una barra de estado y un borde. Dentro de ese borde aloja un segundo proceso, que ejecuta un Bash interactivo a través de un pseudo-terminal.

physical terminal -> shell surface -> viewport -> child process -> PTY proxy

El shell decide dónde aparece el hijo y traduce la entrada a las coordenadas del hijo. El hijo ve un puerto de terminal corriente y nunca se entera de que está enmarcado.

Estructura del Proyecto

tty-app/
├── wippy.lock
└── src/
    ├── _index.yaml
    ├── shell.lua
    └── child.lua
mkdir tty-app && cd tty-app
mkdir src

Paso 1: Definiciones de Entradas

Cree src/_index.yaml:

version: "1.0"
namespace: app

entries:
  - name: policy
    kind: security.policy
    policy:
      actions:
        - process.context
        - process.spawn
        - process.spawn.monitored
        - process.host
        - process.terminate
        - exec.get
        - exec.run
      resources: "*"
      effect: allow

  # Ejecuta el proceso hijo
  - name: workers
    kind: process.host
    host:
      workers: 2
    lifecycle:
      auto_start: true

  # Posee el terminal físico
  - name: terminal
    kind: terminal.host
    hide_logs: true
    lifecycle:
      auto_start: true

  - name: exec
    kind: exec.native

  - name: child
    kind: process.lua
    source: file://child.lua
    method: main
    modules: [channel, exec, tty]
    security:
      policies: [app:policy]

  - name: shell
    kind: process.lua
    source: file://shell.lua
    method: main
    modules: [channel, process, time, tty]
    meta:
      command:
        name: shell
        short: Run the terminal shell
        security:
          actor: {id: app:shell}
          policies: [app:policy]
hide_logs: true redirige la salida de logs al bus de eventos en lugar del terminal. Un proceso que posee una superficie publica marcos completos, así que cualquier otra cosa que escriba en el mismo terminal los corrompe.

Paso 2: El Bucle de Entrada

Cree src/shell.lua. Suscríbase primero a los eventos y luego inicie la entrega de entrada, de modo que ningún evento llegue antes de que haya un consumidor:

local tty = require("tty")

local function main()
    local events = assert(tty.events())
    assert(tty.start())
    assert(tty.mouse(true))

    local width, height = tty.screen_size()
    width = math.max(20, math.floor(width or 80))
    height = math.max(8, math.floor(height or 24))

    while true do
        local ev = events:receive()
        if not ev then break end

        if ev.type == "resize" then
            width, height = ev.width, ev.height
        elseif ev.type == "key" and ev.ctrl and ev.key == "q" then
            break
        end
    end

    assert(tty.stop())
end

return {main = main}

Los eventos son registros discriminados por type. Para una tecla imprimible, key_type es "runes" y key contiene el texto; para una tecla con nombre, tanto key_type como key contienen el nombre ("enter", "backspace", "up"). Las coordenadas en los eventos de ratón empiezan en uno.

Paso 3: Dibujar Marcos

Una Surface es el arrendamiento de presentación del terminal: toma arrays de filas completos y los compara con el último marco. Un Canvas compone esas filas a partir de texto estilizado sin emitir secuencias de control de terminal propias.

Añada estilos y un ayudante de relleno al principio de shell.lua:

local tty = require("tty")

local header_style = tty.style():bold():foreground("#eceff4"):background("#5e81ac")
local status_style = tty.style():foreground("#a3be8c")
local prompt_style = tty.style():foreground("#88c0d0")

local function fit(text, width)
    local clipped = tty.text.truncate(text, width)
    return clipped .. string.rep(" ", math.max(0, width - tty.text.width(clipped)))
end

tty.text.truncate y tty.text.width reconocen ANSI, de modo que el texto estilizado se mide y recorta por celdas imprimibles en lugar de por bytes.

Ahora abra una superficie y publique un marco con una cabecera, un cuerpo desplazable, una barra de estado y una línea de entrada fijada a la última fila:

local function main()
    local events = assert(tty.events())
    assert(tty.start())
    assert(tty.mouse(true))

    local surface = assert(tty.surface({
        alternate_screen = true,
        hide_cursor = true,
        synchronized_output = true,
    }))

    local width, height = tty.screen_size()
    width = math.max(20, math.floor(width or 80))
    height = math.max(8, math.floor(height or 24))
    local canvas = tty.canvas(width, height)

    local lines, scroll, input = {}, 0, ""

    local function draw()
        local body_height = height - 3
        canvas:clear()
        canvas:put(1, 1, header_style:render(fit(" wippy tui — Ctrl+Q to quit ", width)))

        local first = math.max(1, #lines - body_height + 1 - scroll)
        for row = 1, body_height do
            local line = lines[first + row - 1]
            if line then
                canvas:put(2, row + 1, line, width - 2)
            end
        end

        canvas:put(1, height - 1, status_style:render(fit(
            string.format(" %d lines   scroll %d   %dx%d", #lines, scroll, width, height), width)))
        canvas:put(1, height, prompt_style:render("> ") .. input)

        assert(surface:present(canvas:rows(), {
            cursor = {x = math.min(width, 3 + tty.text.width(input)), y = height, visible = true},
        }))
    end

    draw()
    while true do
        local ev = events:receive()
        if not ev then break end

        if ev.type == "resize" then
            width = math.max(20, ev.width)
            height = math.max(8, ev.height)
            canvas = tty.canvas(width, height)
            surface:invalidate()
        elseif ev.type == "key" and ev.ctrl and ev.key == "q" then
            break
        elseif ev.type == "key" and ev.action == "press" then
            if ev.key == "enter" then
                lines[#lines + 1] = "> " .. input
                input, scroll = "", 0
            elseif ev.key == "backspace" then
                input = input:sub(1, -2)
            elseif ev.key_type == "runes" and not ev.ctrl and not ev.alt then
                input = input .. ev.key
            end
        elseif ev.type == "mouse" and ev.action == "wheel" then
            if ev.button == "wheel_up" then
                scroll = scroll + 1
            elseif ev.button == "wheel_down" then
                scroll = math.max(0, scroll - 1)
            end
        end
        draw()
    end

    assert(surface:close())
    assert(tty.stop())
end

Cada present publica el marco completo; el backend escribe solo las filas que cambiaron e informa de rows, changed_rows y bytes_written. invalidate() olvida ese estado de comparación, que es lo que usted quiere después de que el terminal exterior cambie de tamaño bajo sus pies.

Paso 4: Alojar un Hijo

Un Viewport es un puerto de terminal virtual. El shell crea uno, entrega al hijo su concesión, y lee de vuelta los marcos que el hijo presenta.

Reemplace el cuerpo del diseño con una región con borde y ponga las filas del viewport dentro de ella:

local channel = require("channel")
local process = require("process")
local tty = require("tty")

local BODY_X, BODY_Y = 2, 3            -- celda superior izquierda dentro del borde
local CHROME_ROWS = 5                  -- cabecera, dos filas de borde, estado, pista

local border_style = tty.style():foreground("#4c566a")
local hint_style = tty.style():faint()

Cree el viewport, lance el hijo con su concesión y suscríbase a las marcas de agua de actualización:

    local inner_width = math.max(1, width - 2)
    local inner_height = math.max(1, height - CHROME_ROWS)

    local viewport = assert(tty.viewport({width = inner_width, height = inner_height}))
    local updates = assert(viewport:updates())
    local child = assert(process.with_options({terminal = assert(viewport:grant())})
        :spawn_monitored("app:child", "app:workers", "/bin/bash --noprofile --norc"))

La concesión es de un solo uso. La admisión la consume: un arranque rechazado la deja sin resolver, y un host que no puede adjuntar terminales rechaza el lanzamiento en lugar de descartar la opción en silencio.

Dibuje el borde usted mismo y coloque las filas del hijo dentro de él con put_rows, que valida cada fila antes de dibujar nada:

    local function draw()
        canvas:clear()
        canvas:put(1, 1, header_style:render(fit(" wippy shell — Ctrl+Q to quit ", width)))
        canvas:put(1, 2, border_style:render("┌" .. string.rep("─", inner_width) .. "┐"))
        for row = 1, inner_height do
            canvas:put(1, BODY_Y + row - 1, border_style:render("│"))
            canvas:put(width, BODY_Y + row - 1, border_style:render("│"))
        end
        canvas:put_rows(BODY_X, BODY_Y, frame.rows, inner_width)
        canvas:put(1, BODY_Y + inner_height,
            border_style:render("└" .. string.rep("─", inner_width) .. "┘"))
        canvas:put(1, height - 1, status_style:render(fit(" " .. status, width)))
        canvas:put(1, height, hint_style:render(fit(
            string.format(" child viewport %dx%d", inner_width, inner_height), width)))

        local cursor = {x = 1, y = height, visible = false}
        if frame.cursor then
            cursor = {
                x = math.min(width, BODY_X + frame.cursor.x - 1),
                y = math.min(height, BODY_Y + frame.cursor.y - 1),
                visible = frame.cursor.visible,
            }
        end
        assert(surface:present(canvas:rows(), {cursor = cursor}))
    end

El hijo publica en el viewport; el shell se entera a través de updates y luego lee el estado con snapshot:

        if selected.channel == updates then
            local next_frame = viewport:snapshot(revision)
            if next_frame then
                frame, revision = next_frame, next_frame.revision
                if #frame.rows > 0 then ready = true end
                draw()
            end
        end

Las actualizaciones son marcas de agua fusionadas, no un registro de eventos: un shell lento recibe solo la más reciente y debe llamar a snapshot() para obtener las filas reales. Pasar la última revisión hace que snapshot retorne nil cuando nada cambió. Una revisión nueva no significa que el hijo haya dibujado: viewport:resize también la incrementa, y hasta el primer frame la instantánea no lleva filas. Por eso ready depende de rows y no de la revisión.

La entrada va en sentido contrario mediante viewport:send. Los eventos de teclado pasan sin cambios; las coordenadas del ratón tienen que trasladarse al espacio del hijo, que empieza en uno, y los eventos fuera de la región se descartan:

    local function translate(event)
        if event.type ~= "mouse" then
            return event
        end
        local x, y = event.x - BODY_X + 1, event.y - BODY_Y + 1
        if x < 1 or y < 1 or x > inner_width or y > inner_height then
            return nil
        end
        return {
            type = "mouse", action = event.action, button = event.button,
            x = x, y = y, alt = event.alt, ctrl = event.ctrl, shift = event.shift,
        }
    end

send requiere que el productor haya llamado a tty.start(), así que el shell espera al primer marco antes de reenviar nada. Eso es lo que rastrea el indicador ready.

Paso 5: El Hijo

Cree src/child.lua. El hijo recibe un puerto de terminal corriente, así que usa el mismo módulo tty — pero en lugar de dibujarse a sí mismo, entrega su puerto a un proceso respaldado por un PTY.

local channel = require("channel")
local exec = require("exec")
local tty = require("tty")

local function main(command)
    local events = assert(tty.events())
    assert(tty.start())

    local executor = assert(exec.get("app:exec"))
    local proc = assert(executor:exec(command or "/bin/bash --noprofile --norc", {
        pty = {term = "xterm-256color"},
    }))
    local session = assert(proc:attach_terminal())
    local done = session:done()

    while true do
        local selected = channel.select({
            events:case_receive(),
            done:case_receive(),
        })
        if not selected.ok or selected.channel == done then break end

        local event = selected.value
        if event.type == "close" then break end
        assert(session:send(event))
    end

    assert(session:close())
    assert(executor:release())
    assert(tty.stop())
end

return {main = main}

attach_terminal() consume el proceso PTY sin iniciar y retorna un TerminalSession que lo posee: emulación de PTY, codificación de entrada, redimensionado, terminación y recolección. La sesión abre la superficie en el puerto que el hijo tenga, así que el mismo código funciona tanto si el hijo corre sobre un terminal host como dentro de un viewport.

Todo lo que el hijo reenvía — teclas, ratón, pegado, foco y los eventos resize que genera el shell — se convierte en entrada de terminal para Bash. Un evento close es el shell pidiendo una salida ordenada.

Paso 6: Redimensionado, Apagado y Limpieza

Un redimensionado del terminal exterior cambia tres cosas: la geometría propia del shell, la geometría del viewport y la idea que tiene el backend de lo que ya hay en pantalla.

            if event.type == "resize" then
                width = math.max(20, math.floor(event.width))
                height = math.max(8, math.floor(event.height))
                inner_width = math.max(1, width - 2)
                inner_height = math.max(1, height - CHROME_ROWS)
                canvas = tty.canvas(width, height)
                assert(viewport:resize(inner_width, inner_height))
                surface:invalidate()
                draw()
            end

viewport:resize incrementa la revisión para los espectadores y entrega un evento resize al hijo, que lo reenvía a su sesión de terminal, que redimensiona el PTY. Una sola llamada del lado del shell llega hasta el fondo.

Ctrl+Q pide al hijo que se detenga y arma un plazo, de modo que un hijo que no responde no pueda colgar el shell:

            elseif event.type == "key" and event.ctrl and event.key == "q" then
                if not closing then
                    closing = true
                    status = "closing child"
                    if ready then
                        assert(viewport:send({type = "close"}))
                    else
                        assert(process.terminate(child))
                    end
                    deadline = time.after("3s")
                    draw()
                end

El bucle vigila los eventos de ciclo de vida para detectar la salida del hijo, y el canal del plazo para el caso en que nunca llegue:

        elseif selected.channel == lifecycle then
            local event = selected.value
            if event.kind == process.event.EXIT and event.from == child then break end
        elseif deadline and selected.channel == deadline then
            assert(process.terminate(child))
            deadline = nil

Desmonte de dentro hacia fuera: desconecte el espectador, libere el arrendamiento de presentación y luego detenga la entrada.

    assert(viewport:close())
    assert(surface:close())
    assert(tty.stop())

Cerrar un viewport desconecta solo a ese espectador; nunca mata al productor. Cerrar la superficie restaura los modos de terminal que adquirió — la pantalla alternativa y el cursor.

Shell Completo

src/shell.lua:

local channel = require("channel")
local process = require("process")
local time = require("time")
local tty = require("tty")

local BODY_X, BODY_Y = 2, 3
local CHROME_ROWS = 5

local header_style = tty.style():bold():foreground("#eceff4"):background("#5e81ac")
local border_style = tty.style():foreground("#4c566a")
local status_style = tty.style():foreground("#a3be8c")
local hint_style = tty.style():faint()

local function fit(text, width)
    local clipped = tty.text.truncate(text, width)
    return clipped .. string.rep(" ", math.max(0, width - tty.text.width(clipped)))
end

local function main()
    local events = assert(tty.events())
    local lifecycle = assert(process.events())
    assert(tty.start())
    assert(tty.mouse(true))

    local surface = assert(tty.surface({
        alternate_screen = true,
        hide_cursor = true,
        synchronized_output = true,
    }))

    local width, height = tty.screen_size()
    width = math.max(20, math.floor(width or 80))
    height = math.max(8, math.floor(height or 24))
    local inner_width = math.max(1, width - 2)
    local inner_height = math.max(1, height - CHROME_ROWS)
    local canvas = tty.canvas(width, height)

    local viewport = assert(tty.viewport({width = inner_width, height = inner_height}))
    local updates = assert(viewport:updates())
    local child = assert(process.with_options({terminal = assert(viewport:grant())})
        :spawn_monitored("app:child", "app:workers", "/bin/bash --noprofile --norc"))

    local frame = {rows = {}}
    local revision = -1
    local ready, closing = false, false
    local status = "starting child"
    local deadline

    local function draw()
        canvas:clear()
        canvas:put(1, 1, header_style:render(fit(" wippy shell — Ctrl+Q to quit ", width)))
        canvas:put(1, 2, border_style:render("┌" .. string.rep("─", inner_width) .. "┐"))
        for row = 1, inner_height do
            canvas:put(1, BODY_Y + row - 1, border_style:render("│"))
            canvas:put(width, BODY_Y + row - 1, border_style:render("│"))
        end
        canvas:put_rows(BODY_X, BODY_Y, frame.rows, inner_width)
        canvas:put(1, BODY_Y + inner_height,
            border_style:render("└" .. string.rep("─", inner_width) .. "┘"))
        canvas:put(1, height - 1, status_style:render(fit(" " .. status, width)))
        canvas:put(1, height, hint_style:render(fit(
            string.format(" child viewport %dx%d", inner_width, inner_height), width)))

        local cursor = {x = 1, y = height, visible = false}
        if frame.cursor then
            cursor = {
                x = math.min(width, BODY_X + frame.cursor.x - 1),
                y = math.min(height, BODY_Y + frame.cursor.y - 1),
                visible = frame.cursor.visible,
            }
        end
        assert(surface:present(canvas:rows(), {cursor = cursor}))
    end

    local function translate(event)
        if event.type ~= "mouse" then
            return event
        end
        local x, y = event.x - BODY_X + 1, event.y - BODY_Y + 1
        if x < 1 or y < 1 or x > inner_width or y > inner_height then
            return nil
        end
        return {
            type = "mouse", action = event.action, button = event.button,
            x = x, y = y, alt = event.alt, ctrl = event.ctrl, shift = event.shift,
        }
    end

    draw()
    while true do
        local cases = {
            events:case_receive(),
            lifecycle:case_receive(),
            updates:case_receive(),
        }
        if deadline then
            cases[#cases + 1] = deadline:case_receive()
        end

        local selected = channel.select(cases)
        if not selected.ok then break end

        if selected.channel == updates then
            local next_frame = viewport:snapshot(revision)
            if next_frame then
                frame, revision = next_frame, next_frame.revision
                if #frame.rows > 0 then ready = true end
                if not closing then
                    status = "child running"
                end
                draw()
            end
        elseif selected.channel == lifecycle then
            local event = selected.value
            if event.kind == process.event.EXIT and event.from == child then break end
        elseif deadline and selected.channel == deadline then
            assert(process.terminate(child))
            deadline = nil
        else
            local event = selected.value
            if event.type == "resize" then
                width = math.max(20, math.floor(event.width))
                height = math.max(8, math.floor(event.height))
                inner_width = math.max(1, width - 2)
                inner_height = math.max(1, height - CHROME_ROWS)
                canvas = tty.canvas(width, height)
                assert(viewport:resize(inner_width, inner_height))
                surface:invalidate()
                draw()
            elseif event.type == "key" and event.ctrl and event.key == "q" then
                if not closing then
                    closing = true
                    status = "closing child"
                    if ready then
                        assert(viewport:send({type = "close"}))
                    else
                        assert(process.terminate(child))
                    end
                    deadline = time.after("3s")
                    draw()
                end
            elseif not closing and ready and event.type ~= "start" then
                local forwarded = translate(event)
                if forwarded then
                    assert(viewport:send(forwarded))
                end
            end
        end
    end

    assert(viewport:close())
    assert(surface:close())
    assert(tty.stop())
end

return {main = main}

Ejecutarlo

wippy init
wippy run shell

Escriba en el Bash enmarcado con normalidad — las teclas de flecha, el autocompletado con tabulador y los programas de pantalla completa como htop o vim funcionan todos, porque el hijo está hablando con un PTY real. Redimensione la ventana del terminal y el borde, la barra de estado y la geometría del hijo lo siguen. Pulse Ctrl+Q para cerrar el hijo y restaurar el terminal.

Hacia Dónde Ir Después

  • Cree un segundo viewport y divida el cuerpo entre dos hijos, reenviando la entrada solo al que tiene el foco.
  • Llame a viewport:handle() y pase el handle a otro proceso, que se adjunta con tty.attach(handle) y renderiza el mismo hijo en su propio diseño.
  • Reemplace el hijo Bash por un proceso Lua que dibuje su propia superficie: el shell no cambia, porque el viewport es el único contrato entre ellos.

Vea También

  • TTY — eventos, superficies, canvas, viewports, estilos y utilidades de texto
  • Ejecución de Comandos — opciones de PTY, attach_terminal y sesiones de terminal
  • Terminal — configuración del terminal host y el modelo de terminal componible
  • Procesos — opciones de lanzamiento, monitorización y eventos de ciclo de vida
  • Aplicaciones CLI — programas de terminal orientados a líneas