{"id":51774815,"url":"https://github.com/lispnik/revision-term","last_synced_at":"2026-07-20T05:02:18.804Z","repository":{"id":369325740,"uuid":"1289033713","full_name":"lispnik/revision-term","owner":"lispnik","description":"A reusable libvterm-backed terminal-emulator widget for the revision text-mode UI framework — embed a live shell as a first-class window in any Common Lisp TUI app.","archived":false,"fork":false,"pushed_at":"2026-07-04T18:27:25.000Z","size":313,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-04T19:05:07.513Z","etag":null,"topics":["cffi","common-lisp","ffi","libvterm","pty","sbcl","terminal","terminal-emulator","text-user-interface","tui","widget"],"latest_commit_sha":null,"homepage":null,"language":"Common Lisp","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lispnik.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-04T09:00:11.000Z","updated_at":"2026-07-04T18:27:28.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/lispnik/revision-term","commit_stats":null,"previous_names":["lispnik/revision-term"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/lispnik/revision-term","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lispnik%2Frevision-term","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lispnik%2Frevision-term/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lispnik%2Frevision-term/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lispnik%2Frevision-term/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lispnik","download_url":"https://codeload.github.com/lispnik/revision-term/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lispnik%2Frevision-term/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35674031,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"ssl_error","status_checked_at":"2026-07-20T02:08:09.736Z","response_time":111,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cffi","common-lisp","ffi","libvterm","pty","sbcl","terminal","terminal-emulator","text-user-interface","tui","widget"],"created_at":"2026-07-20T05:02:13.683Z","updated_at":"2026-07-20T05:02:18.795Z","avatar_url":"https://github.com/lispnik.png","language":"Common Lisp","funding_links":[],"categories":[],"sub_categories":[],"readme":"# revision-term — a reusable terminal widget for the `revision` framework\n\n[![CI](https://github.com/lispnik/revision-term/actions/workflows/ci.yml/badge.svg)](https://github.com/lispnik/revision-term/actions/workflows/ci.yml)\n\nA **terminal-emulator window** for the [`revision`](../revision) CLOS-native\ntext-mode UI framework. It runs a real child process (your shell, `vi`, `top`,\nanything) on a pseudo-terminal, emulates the terminal with **libvterm**, and\nrenders the emulated screen into a `revision` view — so a live terminal can\nlive inside a `revision` window and be **embedded in any `revision`\napplication**, exactly the way the framework's REPL/editor windows are.\n\n![Terminal windows inside a revision desktop: true-colour + text styles, a native Lisp REPL alongside, tiled windows, and a second live terminal](media/demo.gif)\n\n*A `revision` desktop hosting terminal windows: 24-bit colour and text styles, a\nnative Lisp REPL tiled beside a shell, scrollback, and a second terminal running\na live clock — all managed windows. Reproduce it with `make demo` (interactive)\nor `make record-demo` (regenerates the GIF).*\n\n```lisp\n(revision-term:run-terminal)                       ; your $SHELL, full-screen\n(revision-term:run-terminal :command '(\"/usr/bin/vi\"))\n```\n\nA `terminal-view` is just another focusable `revision` view — drop it in a\n`stack`/`row`, give it bounds, and it hosts a shell.\n\n## What it demonstrates\n\nThree libraries meeting at one widget:\n\n- **`revision`** provides the view/window/event/theming machinery and the\n  worker→UI bridge. `terminal-view` is a `view` subclass: it implements `draw`\n  and `handle-event` and nothing about the framework had to change.\n- **libvterm** (a C library) does the hard part — parsing the ANSI/VT stream\n  the child emits into a grid of cells. We bind the slice we need via **CFFI**.\n- **[`cffi-callback-closures`](../cffi-callback-closures)** supplies libvterm's\n  per-instance callbacks. libvterm wants a *scrollback-push* callback, a\n  *cursor-visibility* callback, a *resize* callback and an *output* callback,\n  each of which must close over **this** terminal's state. That is precisely the\n  \"N distinct C function pointers, each carrying its own data\" case that\n  `cffi:defcallback` cannot express and libffi closures can — every\n  `terminal-view` mints its own set with `make-foreign-callback`.\n\n## Requirements\n\n- **SBCL** (uses `sb-thread`, `sb-ext`).\n- **libvterm** — `brew install libvterm` (macOS) or your distro's `libvterm`.\n- The sibling checkouts next to this one:\n  `../revision` and `../cffi-callback-closures` (which bundles its own `cffi` /\n  `cffi-libffi` under `ocicl/`). `setup.lisp` puts all of them on the ASDF\n  registry, so there is nothing else to install.\n- macOS or Linux (POSIX `forkpty`).\n\n\u003e **`revision` version.** This widget needs the text-style support in the\n\u003e framework's true-colour attributes (`attr-rgb-style` and the style argument to\n\u003e `rgb-attr` / `make-rgb`), added to [`lispnik/revision`](https://github.com/lispnik/revision)\n\u003e in commit [`4328e2c`](https://github.com/lispnik/revision/commit/4328e2c)\n\u003e (on `master`). A `revision` checkout older than that will fail to build; just\n\u003e `git pull` it.\n\n## Run it\n\n```sh\nmake run          # your $SHELL full-screen in a revision window\n# or:\nsbcl --script examples/run-shell.lisp\n```\n\nKeys, once inside:\n\n| Key | Action |\n|-----|--------|\n| *(anything)* | forwarded to the child process |\n| `Ctrl-\\` | close the terminal window (full-screen host) |\n| `Shift-Insert` | paste (bracketed) from the clipboard |\n| `Shift-PageUp` / `Shift-PageDown` | scroll the scrollback |\n| `Shift-Home` / `Shift-End` | jump to the top / back to live |\n| mouse wheel | scroll the scrollback (or forwarded when the app grabs the mouse) |\n| mouse drag | select text; on release it's copied (to the macOS clipboard too) |\n\nThe child process drives more than text: it can **grab the mouse** (vim, htop,\ntmux get real mouse events), set the **window title** (OSC — reflected on the\nwindow frame), pick the **cursor shape** (block/underline/bar), copy to the\n**system clipboard** (OSC 52), and toggle the alternate screen. When the child\nexits, the widget shows a **`[process exited: N]`** banner instead of freezing,\nand window **resize** is propagated to the child.\n\nRendering is faithful: exact **24-bit colour**, text **styles** (bold, italic,\nsingle/double/curly **underline**, blink, reverse, strike), **double-width**\nCJK/emoji, and **combining marks / grapheme clusters** (an accent or ZWJ emoji\nrenders as one glyph). Reverse video is applied by swapping fg/bg so it looks the\nsame on any host terminal, **concealed** text (SGR 8) renders blank, clearing the\nscrollback (CSI 3J) empties the ring, and a live text selection keeps tracking\nits content as the scrollback ring trims. The\nclipboard uses `pbcopy`/`pbpaste` on macOS and `wl-copy`/`xclip`/`xsel` on\nLinux.\n\n## Embed it in your own app\n\n`make-terminal` mirrors the framework's `make-repl` builder — it returns\n`(values WINDOW FOCUS OPEN)`, where `OPEN` (run after layout) starts the child\nand returns a cleanup thunk:\n\n```lisp\n(multiple-value-bind (win focus open)\n    (revision-term:make-terminal :command '(\"/bin/sh\")\n                                 :title \" shell \"\n                                 :on-exit (lambda (tv) (declare (ignore tv))\n                                            (format *debug-io* \"child exited~%\")))\n  ;; host it full-screen ...\n  (revision:run-view win :focus focus :open open))\n  ;; ... or hand (win focus open) to a desktop to open it as a managed window.\n```\n\nOr use the raw `terminal-view` directly inside a layout you build with the\nframework's `stack`/`row`, then call `revision-term::terminal-start` on it once\nit has bounds (and `terminal-shutdown` when done).\n\nPublic API (`revision-term` package): `terminal-view`, `terminal-window`,\n`make-terminal`, `run-terminal`, `terminal-alive-p`, `terminal-child-pid`,\n`terminal-send-string`, `*terminal-keys*`.\n\n## How it works\n\n```\nchild ──stdout──▶ pty master ──(reader thread: read() only)──▶ run-on-ui\n                                                                   │\n UI thread: vterm_input_write ◀──────────────────────────────────┘\n            │  libvterm parses the stream and updates its grid\n            ▼\n draw: poll vterm_screen_get_cell for every visible cell ─▶ revision cells\n\n keystroke ─▶ vterm_keyboard_* ─▶ (output closure) ─▶ pty master ─▶ child\n```\n\nDesign choices worth calling out:\n\n- **Render by polling** `vterm_screen_get_cell` each frame. libvterm's\n  *damage* / *moverect* / *movecursor* callbacks pass `VTermRect` / `VTermPos`\n  **by value**, which the closure layer does not yet marshal — so we leave those\n  NULL and just read the grid. `VTermPos` (two ints) is packed into a `:uint64`\n  for the one by-value argument on the hot path, which is ABI-correct on arm64\n  and x86-64.\n- **Only the UI thread touches libvterm.** The reader thread does nothing but\n  the blocking `read()`; it appends bytes to a lock-guarded buffer and posts at\n  most **one** drain thunk per burst (so a flood of output isn't one closure per\n  read). The drain feeds all pending bytes in a single `vterm_input_write`; all\n  `vterm_*` calls (and therefore all callbacks) run on the UI thread, so no\n  locks guard libvterm itself — the same rule the framework's REPL follows.\n- **Draw re-polls only damaged rows.** libvterm's `damage` callback (a by-value\n  `VTermRect`, flattened into two `:uint64`s like `VTermPos`) marks changed rows\n  in a per-row dirty set; `draw` re-polls (`get_cell` + colour conversion) only\n  those rows into a packed-cell cache and blits the rest. A static screen costs\n  zero `get_cell` calls per frame; a one-line update re-polls one row, not all.\n  Scrolls go through the `moverect` callback (two by-value `VTermRect`s → four\n  `:uint64`s), which **shifts the cached rows** to match, so a scroll re-polls\n  only the newly-exposed row rather than the whole screen.\n- **Colours** are resolved to true RGB (`vterm_screen_convert_color_to_rgb`)\n  and drawn with `revision`'s 24-bit attributes, so themes render exactly.\n- **Scrollback** is a ring of packed-cell lines fed by the `sb_pushline`\n  closure; the viewport is bottom-aligned over `history ++ live screen`.\n\n## Test\n\n```sh\nmake test    # headless FiveAM suite (no tty needed)\nmake smoke   # end-to-end: drives the full-screen widget under a pty (python3)\n```\n\n`make test` runs a [FiveAM](https://github.com/lispci/fiveam) suite that creates\na `VTerm`, writes bytes and reads the grid back (proving the by-value `get_cell`\nand cell-struct layout), spawns a child on a real pty and checks its output\nreaches the grid, then covers each feature: **resize** propagates to the child\n(`stty size` changes), the child is **reaped** with the right exit status, an\nenabled **mouse** mode is picked up, an **OSC title** and **DECSCUSR cursor\nshape** are applied, a **text selection** extracts the right string, output\nlonger than the screen fills the **scrollback** ring, a **combining mark** folds\ninto one grapheme cluster, a **double-width** glyph claims two cells, content\n**reflows** when the terminal is widened, an SGR **underline** reaches the\nattribute, a 24-bit SGR **colour** resolves exactly, and **OSC 52** sets the\nclipboard. `make smoke` runs the\nactual full-screen program under a pseudo-terminal and verifies rendering,\nkeystroke delivery, and the process-exited banner.\n\n## Saving an image\n\n`save-lisp-and-die` requires a single thread and cannot preserve libffi closures\nor a child process, so a live terminal's reader thread would block a dump. Each\nrunning terminal registers itself, and an **image-dump hook** tears them all down\n(kills the child, joins the reader, frees foreign state) before the core is\nwritten — so dumping works, and a **restored image can start fresh terminals**\n(libvterm is reloaded and new closures are minted at `terminal-start`).\n`make image-test` proves both ends.\n\n## Resize and the variadic `ioctl`\n\n`TIOCSWINSZ` is delivered by `ioctl`, which is variadic; on Apple arm64 the\nvariadic ABI passes the pointer argument on the stack, so a plain\n`cffi:foreign-funcall` (which uses a register) is silently dropped by the kernel\n— the resize never reaches the child (the exact wrinkle noted in\n`cffi-callback-closures`' libcurl example). `revision-term` fixes this by\ncalling `ioctl` through a real **variadic libffi call interface**\n(`ffi_prep_cif_var`), reusing the `cffi-libffi` machinery that is already loaded.\nSo dynamic resize propagates correctly (there's a test for it), and the initial\nsize is set through `forkpty`'s `winp`.\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flispnik%2Frevision-term","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flispnik%2Frevision-term","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flispnik%2Frevision-term/lists"}