A tabbed TUI for running multiple commands simultaneously with searchable, scrollable output. Built with Ink.
When you exit, the interleaved output is flushed to your terminal scrollback so you don’t lose your logs, up to the --stream-buffer-size limit.
Without an interactive terminal — piped, redirected, or in CI — it runs inline instead: same labelled, interleaved output, printed as it arrives, with no TUI.
npm install -g @laravel/multiplex
Or run directly:
npx @laravel/multiplex 'server,php artisan serve' 'queue,php artisan queue:listen'
php artisan tinker, a migration confirmation — won’t work.COLUMNS when they start, and that can’t be updated afterwards. Resizing the terminal leaves already-running commands sizing their output to the old width; press r to restart one against the new width.multiplex [options] <commands...>
Each command is passed as a positional argument in the format:
label,command
label@color,command
Only the first comma is structural: everything after it is the command, so commas, colons, hashes and ats inside it need no escaping.
The color is optional and attaches to the label with an @. It can be a 6-digit hex value such as #93c5fd, or one of the names black, red, green, yellow, blue, magenta, cyan, white, gray (grey, blackBright) and the *Bright variants of the other seven. A name is left to your terminal’s theme to resolve; a hex value is sent as-is. If the color is omitted it’s assigned from a built-in palette, avoiding duplicates. A malformed color is an error rather than being ignored, and shorthand like #fff is not accepted.
The label itself can hold anything but the last @ — queue:work and @scope/pkg are both fine.
# Basic usage
multiplex 'server,php artisan serve' 'queue,php artisan queue:listen' 'vite,pnpm run dev'
# With custom colors
multiplex 'server@#93c5fd,php artisan serve' 'queue@#fb7185,php artisan queue:listen'
# Or with color names
multiplex 'server@blue,php artisan serve' 'queue@magentaBright,php artisan queue:listen'
# Set the terminal tab title
multiplex --title "Admin" 'server,php artisan serve' 'queue,php artisan queue:listen'
# Start in stream mode with timestamps
multiplex -s --timestamps 'server,php artisan serve' 'queue,php artisan queue:listen'
# Custom working directory
multiplex --cwd /path/to/project 'server,php artisan serve'
# Disable auto-restart
multiplex --no-restart 'build,pnpm run build'
| Option | Description | Default |
|---|---|---|
--title <name> |
Set the terminal tab title | |
--cwd <path> |
Set the working directory (must exist) | Current directory |
-s, --stream |
Start in stream mode (interleaved output) | false |
-i, --inline |
Print output inline instead of rendering the TUI | On when not a TTY |
--json |
Emit newline-delimited JSON events. Implies --inline |
false |
--timestamps |
Display timestamps on each output line | false |
--no-restart |
Disable auto-restart on crash | |
--buffer-size <lines> |
Max lines kept per command buffer | 2000 |
--stream-buffer-size <lines> |
Max lines kept in stream buffer | 10000 |
Inline mode is multiplex without the TUI: no alternate screen, no keyboard handling, no tabs or search. Every line is written straight to your terminal as it arrives, prefixed with the command’s colored label — the same interleaved format the TUI flushes to scrollback when you quit. Your existing scrollback is left alone, output scrolls normally, and Ctrl-C still tears down the whole process tree.
It’s used automatically whenever stdin or stdout isn’t a TTY, so multiplex ... | tee log, make dev and CI jobs all work. It’s also used when the terminal is smaller than 26 columns by 8 rows, which is too small to draw a layout in — multiplex says so on stderr and carries on. Use -i to ask for it in a real terminal.
A few things behave differently from the TUI, because the TUI’s defaults are wrong for a pipeline:
0. The TUI always exits 0.FORCE_COLOR, disabled by NO_COLOR.multiplex -i 'build,pnpm run build' 'test,pnpm test'
multiplex 'lint,pnpm lint' 'types,tsc --noEmit' | tee ci.log
--json writes newline-delimited JSON to stdout, one object per event, instead of formatted lines. It implies --inline. Nothing else is written to stdout, so the stream is safe to pipe into jq.
multiplex --json 'build,pnpm run build' 'test,pnpm test' | jq -c 'select(.type == "failed")'
{"v":1,"time":"2026-01-01T12:00:00.000Z","type":"start","label":"build","command":"pnpm run build","pid":4123}
{"v":1,"time":"2026-01-01T12:00:00.412Z","type":"output","label":"build","stream":"stdout","text":"compiled in 320ms"}
{"v":1,"time":"2026-01-01T12:00:01.900Z","type":"exit","label":"test","code":1,"signal":null}
{"v":1,"time":"2026-01-01T12:00:01.901Z","type":"restarting","label":"test","attempt":1,"max":5}
{"v":1,"time":"2026-01-01T12:00:06.100Z","type":"failed","label":"test","code":1}
{"v":1,"time":"2026-01-01T12:00:06.101Z","type":"done","code":1}
reason on a failed event is one of spawn-error, crashed-immediately, attempts-exhausted or restart-disabled. Every event carries v (the schema version, currently 1) and an ISO-8601 time. text has ANSI escape codes stripped. A start event is emitted for each restart as well as the first spawn, and done is always the last line.
type |
Fields | Meaning |
|---|---|---|
start |
label, command, pid |
A command was spawned |
output |
label, stream, text |
One line of output, from stdout or stderr |
exit |
label, code, signal |
A command exited |
restarting |
label, attempt, max |
An auto-restart was scheduled |
error |
label, message |
The command could not be spawned |
failed |
label, code, reason |
A command failed for good; no more restarts |
done |
code |
Every command has stopped; the process exit code |
The package also exports multiplex(), so you can start the TUI from your own script instead of going through the CLI:
import { multiplex } from "@laravel/multiplex";
const code = await multiplex({
commands: [
{ label: "server", command: "php artisan serve" },
{ label: "queue", color: "#fb7185", command: "php artisan queue:listen" },
{ label: "vite", color: "cyan", command: "pnpm run dev" },
],
stream: true,
});
process.exit(code);
Every command is run through sh -c, so it is a shell string, not an argv array — pipes, redirects and && all work. That also means you must never build a command out of untrusted input. Anything that reaches the string is executed with the privileges of the calling process, so a value taken from a config file, a request payload or a workspace manifest is a remote code execution vector. If the commands are not written by you, quote every interpolated value yourself before passing it in.
multiplex() takes over the terminal for the duration of the call: it enters the alternate screen, installs its own SIGINT/SIGTERM/SIGHUP/SIGQUIT handlers, and renders the TUI. It resolves with the same exit code the CLI would have used — 0 normally, 1 if rendering failed. By then the terminal is restored, every child process is dead, the buffered output has been flushed to scrollback, and the signal handlers it installed have been removed, so the calling process is free to carry on.
Shutting down sends each command’s process group a SIGTERM and waits for it, so anything that tidies up on the way out — a dev server unlinking the hot file it wrote, a watcher releasing a lock — gets to. Whatever is still running two seconds later is killed outright, and pressing Ctrl-C a second time skips the wait entirely.
If stdin or stdout isn’t a TTY, or the terminal is smaller than 26 columns by 8 rows, it runs inline instead, resolving with the first failing command’s exit code. Set inline: true to ask for that in a real terminal.
Options are validated before anything is written to the terminal, so an invalid option throws with the screen untouched.
| Option | Type | Description | Default |
|---|---|---|---|
commands |
{ label, command, color? }[] |
Required, at least one. color is an optional 6-digit hex value or color name |
|
title |
string |
Set the terminal tab title | |
cwd |
string |
Set the working directory (must exist) | process.cwd() |
inline |
boolean |
Print output inline instead of rendering the TUI | On when not a TTY |
json |
boolean |
Emit newline-delimited JSON events on stdout. Implies inline | false |
stream |
boolean |
Start in stream mode (interleaved output) | false |
timestamps |
boolean |
Display timestamps on each output line | false |
restart |
boolean |
Auto-restart on crash | true |
bufferSize |
number |
Max lines kept per command buffer | 2000 |
streamBufferSize |
number |
Max lines kept in stream buffer | 10000 |
Commands that omit a color are assigned one from the built-in palette (also exported as DEFAULT_COLORS), avoiding colors used elsewhere in the list.
Processes that crash (exit with a non-zero code) are automatically restarted after a 1-second delay, up to 5 times; on the 6th consecutive failure they stop restarting and are marked as failed. A manual restart with r resets the counter. This is the same in the TUI and inline.
A command that dies within a second of starting is not retried at all. Something that fails that fast never got off the ground — a typo, a port already bound, a missing binary — and retrying only scrolls the real error out of view while you fix it. A command that was up for longer was working until it wasn’t, which is the case auto-restart exists for.
A desktop notification is sent when a process permanently fails (macOS via osascript, Linux via notify-send if available).
Use --no-restart to turn it off entirely, for one-shot commands like builds or migrations.
| Key | Action |
|---|---|
1-9 |
Jump to tab by number |
Tab |
Toggle focus between sidebar and content |
Left / Right |
Move focus to sidebar / content |
Up / Down / j / k |
Navigate tabs (sidebar) or scroll (content) |
Page Up / Page Down |
Scroll one page |
g / G |
Scroll to top / bottom |
| Key | Action |
|---|---|
s |
Switch to stream mode |
t |
Switch to tabbed mode |
r |
Restart the selected process |
c |
Clear output (current tab or stream) |
f |
Filter which commands appear in the stream |
/ |
Open search |
q |
Quit |
s, r, Tab and Left/Right apply to tabbed mode; t and f apply to stream mode. Pressing the key for the mode you are already in does nothing. In filter mode, 1-9 toggle each command on and off and f or Esc closes it — you can always leave at least one command visible.
| Key | Action |
|---|---|
Enter |
Confirm search |
Esc |
Cancel search / clear results |
n / N |
Next / previous match |
SIGINT/SIGTERM/SIGHUP/SIGQUIT, so closing the terminal window doesn’t leave dev servers running and holding their portsSIGTERM and up to two seconds to clean up after themselves before they are killed, so a dev server removes the files it wrote