npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@thelacanians/tuimark

v0.4.6

Published

Tuimark for npm: the `tuimark` command (validate, dump, play, run terminal UIs written as .tui markup) and a TypeScript client to drive a view from Bun over `tuimark host`.

Readme

@thelacanians/tuimark

Tuimark on npm, in one package: the tuimark command, and the TypeScript client of Tuimark's host protocol.

npx @thelacanians/tuimark new my-app      # or: bunx @thelacanians/tuimark new my-app
bun add @thelacanians/tuimark             # the client, in a Bun project
npm install -g @thelacanians/tuimark      # the `tuimark` command everywhere

The tuimark command

The package has a bin named tuimark. It runs the Go binary of the Tuimark release that has the same version as this package (@thelacanians/[email protected] runs v0.4.6), with your arguments, your terminal, and your signals, and exits with its exit code. It needs Node 18 or later, or Bun, and no postinstall script (bun and pnpm 10 block those). tuimark host also works through it when a parent starts the installed command directly (a global install, or node_modules/.bin/tuimark) with its pipes on fd 3 and fd 4; through npx or bunx the runtime owns those descriptors, so a parent should start tuimark host itself, or, from TypeScript, use spawnHost, which runs the binary directly.

The binary is found in this order:

  1. TUIMARK_BINARY, a path to a tuimark you installed yourself. It must exist.
  2. The cache: <cache>/tuimark/<version>/<os>_<arch>/tuimark (.exe on Windows), where <cache> is $XDG_CACHE_HOME or ~/.cache on Linux, ~/Library/Caches on macOS, and %LOCALAPPDATA% on Windows.
  3. A download of tuimark_<version>_<os>_<arch> (.exe on Windows; os is darwin, linux, or windows, arch is amd64 or arm64) from the GitHub release v<version> of abdul-hamid-achik/tuimark. Its SHA-256 must match the table built into this package, or the file is thrown away and never run. A verified binary is written to the cache with an atomic rename, so the download happens once per version, and a cached binary is checked against the same hash on every run.

Security. The hashes of the six binaries are written into the package when it is built, from the release's checksums.txt (src/embedded.ts, see scripts/embed-checksums.mjs), so they reach you through npm and not through the GitHub release the binary comes from: a swapped or corrupted release asset is rejected. They are as trustworthy as the package you installed. In a clone of the repository there is no such table, and the release's own checksums.txt is used, which only catches corruption.

TUIMARK_RELEASE_URL replaces https://github.com/abdul-hamid-achik/tuimark/releases/download for mirrors. Then the table is not used: <url>/v<version>/checksums.txt and the binary are both read from the mirror, so the checksum only catches a corrupt download, and the binary is cached in a directory of its own (<os>_<arch>-mirror-<hash of the URL>), never the one a normal run reads.

Downloads use Node's fetch, which ignores HTTPS_PROXY unless you set NODE_USE_ENV_PROXY=1 (Node 24 and later). Behind a proxy that does not work, fetch the binary yourself and set TUIMARK_BINARY.

Releases before the one that first shipped raw binaries (0.4.1 and older) only have archives, so this package has no version for them. Offline, or on a platform without a release binary, install tuimark another way (brew install --cask abdul-hamid-achik/tap/tuimark, or go install github.com/abdul-hamid-achik/tuimark/cmd/tuimark@latest) and set TUIMARK_BINARY.

The client

It spawns tuimark host, which owns the terminal and runs a .tui document, and lets your Bun program drive it: write data into the document, read it back, and answer the actions it fires.

It is a parent library, not a second host. tuimark still lays out and paints everything; this package only speaks the protocol and follows its rules for parents.

  • ESM, written in TypeScript, no runtime dependencies.
  • The client runs on Bun (CI tests it with Bun). Node is not supported for the client; the tuimark command above works on both.
  • It speaks protocol: 1.
  • Needs the tuimark of the same release, found as above. Pass bin to spawnHost to use another executable. The host protocol guide describes the wire protocol this package speaks.

Example

import { spawnHost } from "@thelacanians/tuimark";

const ui = await spawnHost("app.tui", { data: "sample.json", theme: "dark", replyTimeout: "5s" });

ui.on("open", async (ev) => {
  // ev: {seq, action, source, keys, value}
  await ui.set("detail", await load(ev.keys.item));
});
ui.on("bye", () => "quit"); // resolving to "quit" ends the session

const exit = await ui.exited; // {reason, signal?, error?, code}
process.exit(exit.code);

While the child runs, your program must not read or write the terminal: tuimark owns fds 0, 1, and 2. Do not touch process.stdin, and print only after ui.exited settles. A program with its own full-screen UI unmounts it before spawnHost and mounts it again after exited.

spawnHost(file, options?)

Runs tuimark host FILE, waits for its ready message, and resolves with a Host.

It rejects with HostError when the child cannot be started, or exits before it says ready (a FILE that does not load, stdin that is not a terminal). The child prints its own reason on stderr, and error.code is its exit code.

| Option | Type | Meaning | |---|---|---| | data | string \| object | The document's data, bound at the root, as --data. A string is the path of a JSON file. An object is written to a temporary file (mode 0600) that is removed as soon as the child is up. | | theme | "dark" \| "light" | --theme. TUIMARK_THEME still beats it. | | replyTimeout | string \| number | --reply-timeout: a Go duration from 100ms to 1m ("5s"), or milliseconds. The child's default is 5s. | | inline | boolean | --inline: run the session inline, in the terminal's own scrollback, instead of on the alternate screen. Needs a document without version. | | screen | string | --screen ID: the screen the session starts on, applied before the first frame. An id no screen has makes spawnHost reject (error.code is 1; the child's stderr says no screen with id "ID"). | | bin | string | The executable. Defaults to the binary of this package's release: TUIMARK_BINARY (also read from env), the cache, or a checked download. A bare name such as "tuimark" is looked up on PATH. | | handlers | Record<string, handler> | Handlers registered before the first event can arrive. | | onError | (ev, err) => unknown | See Replies. | | onProtocolError | (msg) => void | Called with each error message that belongs to no pending request: {error, id?, seq?}. These are reply timeouts, late or unknown replies, and failed writes sent without an id. | | exitWithChild | boolean | Call process.exit with the child's code once it has exited. Default false: the parent owns its process. If the child ended without an exit message, it prints the error to stderr and exits with the child's code, or 1 if that was 0. | | cwd, env | | The child's working directory and environment. |

Host

| Member | | |---|---| | ready | The ready message: protocol, version, cols, rows, theme, catalog, diagnostics, and messages, the message types the child accepts (absent for a child older than 0.4d). | | pid | The child's process id. | | on(action, handler) | Registers the handler of one action (a later call for the same action replaces it). Returns the host. | | set(path, value) | Set. Resolves with the ack. | | append(path, value) | Append: adds value to the string at path (it must be a string too) or to the array there, as one element (an array value is one element, never spread). Resolves with the ack. | | commit(commit) | Commit: prints {lines}, or a copy of {template, with?, props?}, into the scrollback of an inline session, above the live region. Resolves with the ack once the rows are written. | | batch(writes) | Batch, all or nothing. writes is [{path, value, op?}, …], where op is "set" (the default) or "append", checked when the batch is applied, so it sees the writes before it. Resolves with the ack. | | get(path) | Get. Resolves with {found, value}; value is absent unless found. | | exited | Settles when the child has exited. See Ending. | | close() | Sends SIGTERM, then SIGKILL after 2 seconds if the child is still running. Resolves once it has exited, whatever the outcome. Safe to call twice. |

Messages

set, append, commit, batch, and get each send their message with a fresh id and resolve with the child's ack. When the child refuses (ok: false), the promise rejects with a RequestError carrying the message and the ack (error.ack). The same happens when the child answers the id with an error instead, which is what it does for a malformed message. If the child exits first, pending requests reject with a HostError.

A value of undefined is sent as null. A message whose line is 64 MiB or more is refused with a RangeError before it is sent, because the child ends the session on such a line.

Messages are applied in the order they are sent, from one reader. Writes made before a handler returns are applied before its reply, so the frame drawn after the handler shows them. set, append, commit, batch, and get are served while an event waits for its reply, so a handler can read and write state first. A commit sent then prints at once, above the live region, so an approval can print its diff before the user answers.

Older children

A child tells which message types it accepts in ready.messages. Before it sends a type or a batch entry with an op other than "set", the client checks that list: if the child does not list it, the call rejects with an UnsupportedMessageError (the tuimark child does not accept "commit" messages) and nothing is sent. That way a newer parent fails with a message against an older child, not with the child's malformed-message error, and a batch entry with op: "append" is never applied as a set. A child whose ready has no messages is taken to accept set, bind, batch, get, and reply, so those calls, and a batch without such an entry, go to it unchanged.

Submit and clear

A handler that takes the text of an input or a textarea should clear it before it replies, so the next frame is drawn without the sent text (enter changes nothing in a textarea, so a composer's on:submit handler always does this):

ui.on("send", async (ev) => {
  const text = String(ev.value);
  await ui.set("draft", ""); // applied before the reply, so the next frame shows an empty input
  await ui.append("messages", { role: "user", text });
});

Replies

Each event waits for the parent's reply, and the client sends it when the handler settles:

  • Resolving to "quit" replies quit: true, which ends the session with reason quit.
  • Resolving to anything else replies plainly.
  • Throwing replies with the error's message as error, which ends the session with reason error. An empty message is still sent, as "".
  • With the onError(ev, err) option, a throw goes to that function instead, and the reply is plain, so the session survives, as Go's OnError lets it. If onError throws in turn, the reply carries that error.
  • An event with no handler is answered at once, plainly. Register handlers with the handlers option, or call on right after spawnHost resolves: messages that arrive with ready are held until the caller has had a turn, but an event that arrives after an await of your own may find no handler.

The reply timeout (replyTimeout) is the child's: a handler that takes longer is answered by the child with a plain reply and an error message, which reaches onProtocolError.

An event is {seq, action, source, keys, value}:

| Member | | |---|---| | seq | 1, 2, … per session. | | action | The action's name. | | source | The id of the node it came from, or "". | | keys | For a list or table row, {alias: key}. Always an object ({} when empty), never null. | | value | The action's value (an input's text, a checked array), or null. |

Ending

exited settles once the child has exited and its output is read:

  • It resolves with {reason, signal?, error?, code}. reason is quit, eof, signal, or error, as in the exit message. signal names the signal for signal, and error holds the message for error. code is the child's exit code (0 for quit and eof, 1 for error, 128 plus the signal number for signal).
  • It rejects with ProtocolVersionError when ready named a protocol other than 1. The client stops the child and fails every request. spawnHost still resolves in that case, so that you can read host.ready.protocol.
  • It rejects with HostError when the child exited without an exit message (error.code is its exit code). Treat that as error.

A rejection nobody awaits does not crash the process.

Rules for parents, and what the client does

| Rule for parents | Client | |---|---| | Pass fd 3 and fd 4 and read them as Bun needs. | stdio: ["inherit", "inherit", "inherit", "pipe", "pipe"]; fd 4 is read with Bun.file(fd).stream(), fd 3 is written with Bun.file(fd).writer() (a FileSink: fs.createWriteStream breaks on writes of about 8 KB or more). Both descriptors are closed when the session ends, so the child sees end of file on fd 3 and nothing leaks. | | Forward SIGTERM and SIGHUP to the child. | Listeners are installed from spawnHost until the child exits, and removed after. While they are installed the signals no longer end your process: wait for exited, or set exitWithChild, and exit with the child's code. The forward goes out 1.5 seconds after the parent received the signal, and only if the child is still running then (below). | | Never read or write the terminal while the child runs. | The client never touches fds 0, 1, or 2. | | End the child with SIGTERM, then SIGKILL after at least 2 seconds. | close(). | | Check protocol; treat an exit without exit as error. | exited rejects in both cases. |

Signals to the parent

A signal sent to the whole process group (closing the terminal window sends SIGHUP to the foreground group; systemd's KillMode=control-group; kill -TERM -PGID) reaches the child from the kernel and the parent at once. The child treats a second signal as fatal (the default action), so forwarding at once would kill it before it writes exit, and exited would reject with a HostError. The client therefore waits 1.5 seconds after it received SIGTERM or SIGHUP, and forwards only if the child is still running. The child's own grace is 1 second, so one that got the signal has exited by then and exited resolves with its exit message (reason: "signal"). A signal sent only to the parent's pid (kill PID, a test harness) is forwarded once the 1.5 seconds are over, and the child ends then. If the same signal reaches the parent again during the wait, it is forwarded at once: the user insists.

Errors

All are exported and extend HostError (which extends Error).

| Class | When | |---|---| | HostError | The child could not start, ended before ready, or ended without an exit message; the transport failed. code is the child's exit code when known. | | ProtocolVersionError | ready.protocol is not 1. protocol holds the value. | | RequestError | A set, append, commit, batch, or get was refused. ack holds the ack when there was one. | | UnsupportedMessageError | The call needs a message type the child's ready.messages does not list. messageType holds it. Nothing was sent. |

Develop

bun install
bun test        # against a fake child, test/fake-child.ts
bunx tsc --noEmit
bun run build   # dist/, for publishing

The tests need no terminal and no tuimark binary (the download tests use a local HTTP server). test/real-child.test.ts also runs a real child on a pseudo-terminal (it needs python3), against the binary named by TUIMARK_TEST_CHILD and a 0.4.5 one named by TUIMARK_TEST_OLD_CHILD; each is skipped, with its reason, when the variable is not set. bin/tuimark.js runs the TypeScript source under Bun when dist/ is not built; after bun run build its tests also run it under Node.

The package version is the Tuimark release it drives. .github/workflows/release.yml publishes it with npm trusted publishing after GoReleaser has uploaded the release's binaries: the npm-pack and npm-publish jobs set the version from the v* tag, so the version in this package.json only matters for local runs.

examples/host-ts depends on this package (file:../../sdk/ts) and records its dev dependencies in its own bun.lock. After you change the dependencies in package.json here, run bun install in examples/host-ts and commit its bun.lock, or bun install --frozen-lockfile fails in CI.

License

MIT