Elementos
Terminal
A terminal shows the output of a program (a shell, a build, a remote session) and sends what the user types back to it, as a terminal application does. It interprets the program’s escape sequences (colors, cursor moves, erasing, scroll regions, the alternate screen of full-screen programs, titles, the bell) and draws the lines with the platform’s own text engine, so it runs everywhere NSH does: macOS, Windows, Linux, iOS, Android, the web and the terminal backend. It keeps a scrollback that scrolls like a list and follows the end while the user is there.
import { mount, Text, View } from "nsh";
import { Terminal, type TerminalRef } from "nsh/base";
// The program is the app's: here a small shell written in TypeScript. A real
// app connects `write` and `onData` to a pseudo-terminal, a WebSocket or SSH.
const PROMPT = "\x1b[32mnsh\x1b[0m:\x1b[34m~\x1b[0m$ ";
const HELP = [
"Commands:",
" \x1b[1mls\x1b[0m the files here",
" \x1b[1mcolors\x1b[0m ANSI colors, a 24-bit ramp",
" \x1b[1mprogress\x1b[0m a bar redrawn in place",
" \x1b[1mecho\x1b[0m … prints what follows",
" \x1b[1mclear\x1b[0m clears the screen",
].join("\r\n");
const LS = "\x1b[1;34mcrates\x1b[0m \x1b[1;34mexamples\x1b[0m \x1b[1;34mwebsite\x1b[0m Cargo.toml README.md \x1b[1;32minstall.sh\x1b[0m";
function colors() {
let out = "";
for (let i = 0; i < 16; i++) {
out += `\x1b[48;5;${i}m \x1b[0m`;
if (i === 7) out += "\r\n";
}
out += "\r\n";
for (let i = 0; i < 48; i++) {
const r = Math.round(255 * (1 - i / 47));
const b = Math.round(255 * (i / 47));
out += `\x1b[48;2;${r};96;${b}m \x1b[0m`;
}
return out;
}
function App() {
let term: TerminalRef | undefined;
let line = "";
let busy = false;
let welcomed = false;
const write = (s: string) => term?.write(s);
const progress = () => {
busy = true;
let done = 0;
const tick = () => {
done += 5;
const filled = done / 5;
write(`\r\x1b[36m${"█".repeat(filled)}\x1b[2m${"░".repeat(20 - filled)}\x1b[0m ${done}%`);
if (done < 100) setTimeout(tick, 60);
else {
busy = false;
write("\r\n" + PROMPT);
}
};
tick();
};
const run = (command: string) => {
const [name, ...rest] = command.trim().split(/\s+/);
switch (name) {
case "":
break;
case "help":
write(HELP + "\r\n");
break;
case "ls":
write(LS + "\r\n");
break;
case "colors":
write(colors() + "\r\n");
break;
case "echo":
write(rest.join(" ") + "\r\n");
break;
case "clear":
write("\x1b[2J\x1b[H");
break;
case "progress":
progress();
return;
default:
write(`\x1b[31m${name}: command not found\x1b[0m\r\n`);
}
write(PROMPT);
};
const input = ({ data }: { data: string }) => {
if (busy) return;
for (const ch of data) {
if (ch === "\r") {
write("\r\n");
const command = line;
line = "";
run(command);
} else if (ch === "\x7f") {
if (line.length > 0) {
line = line.slice(0, -1);
write("\b \b");
}
} else if (ch === "\x03") {
line = "";
write("^C\r\n" + PROMPT);
} else if (ch >= " ") {
line += ch;
write(ch);
}
}
};
return (
<View class="flex-1 gap-3 bg-background p-4">
<Text class="text-[13px] text-muted-foreground">Type help, then press Enter.</Text>
<Terminal
ref={(r) => (term = r)}
onResize={() => {
// The welcome waits for the first size, so it wraps at the
// terminal's width.
if (welcomed) return;
welcomed = true;
write("Welcome to the \x1b[1mNSH\x1b[0m terminal.\r\n\r\n" + HELP + "\r\n\r\n" + PROMPT);
}}
onData={input}
accessibilityLabel="Terminal"
cursorClass="bg-foreground/50"
class="flex-1 rounded-xl border border-border bg-card px-3 py-2"
/>
</View>
);
}
mount(App);The program is the app’s. A Terminal starts nothing: the app connects it to a source of bytes (a pseudo-terminal
on the desktop, a WebSocket, an SSH channel) with the ref’s write and the onData event, and tells the program the
size in cells that onResize reports.
import { mount } from "nsh";
import { Terminal, type TerminalRef } from "nsh/base";
function App() {
let term: TerminalRef | undefined;
const socket = new WebSocket("wss://example.com/shell");
socket.onmessage = (e) => term?.write(e.data);
return (
<Terminal
ref={(r) => (term = r)}
onData={({ data }) => socket.send(data)}
onResize={({ cols, rows }) => socket.send(JSON.stringify({ cols, rows }))}
accessibilityLabel="Shell"
cursorClass="bg-foreground/50"
class="flex-1 rounded-xl bg-card px-3 py-2"
/>
);
}
mount(App);use nsh_base::terminal;
use nsh_core::prelude::*;
fn shell() -> impl IntoElement {
terminal()
.accessibility_label("Shell")
.cursor_class("bg-foreground/50")
.on_data(|data: &SharedString| send_to_program(data))
.flex_1()
}Terminal comes from nsh/base, the nsh-base crate in Rust (feature term, on by
default).
#Props
| Prop | Type | Default | Description |
|---|---|---|---|
scrollback |
number |
1000 |
How many lines scrolled off the top are kept. |
fontFamily |
string |
the theme’s font-mono |
The monospaced font. |
fontSize |
number |
13 |
The font size, in points. |
lineHeight |
number |
18 |
The height of a row, in points. |
accessibilityLabel |
string |
none | What screen readers say the terminal is. |
cursorClass, cursorStyle |
string, StyleProp<ViewStyle> |
none | The cursor: a box one cell wide over the cell it is at; its color and shape are the app’s. |
class, style |
string, StyleProp<ViewStyle> |
none | The terminal itself: its background, padding, border. |
#Events
| Event | Payload | When |
|---|---|---|
onData |
{ data } |
Text for the program: what the user typed or pasted (bracketed when the program asks), the keys with no text as the program expects them, and answers to the program’s queries. |
onResize |
{ cols, rows } |
The size in cells changed, first once laid out. |
onTitleChange |
{ title } |
The program set the title. |
onBell |
The program rang the bell. |
#Ref
| Method | Description |
|---|---|
write(data) |
Shows the program’s output. |
reset() |
Clears the screen and the scrollback. |
text() |
The text of every line, as a promise. |
focus() |
Takes the keyboard focus, showing the software keyboard on phones. |
#Colors
The program’s colors show as it asks for them: the 16 ANSI colors in VS Code’s terminal palette, which reads well on
light and dark backgrounds, 256-color and 24-bit colors as given. Text without a color is the theme’s foreground;
cells without a background show the terminal’s own, the app’s class.
#Another emulator
The emulator built in is NSH’s own, written in Rust on vte. A Rust app uses another one (libghostty,
alacritty_terminal, …) by implementing TerminalEmulator over it and giving the terminals below a
TerminalEmulatorFactory; the view, the keys and the events stay the same:
provide_context(TerminalEmulatorFactory::new(|size, scrollback| {
Box::new(MyEmulator::new(size, scrollback))
}));
An app that wants another terminal’s whole view, its renderer included, registers it as a native component.