Terminal output
Make a command-line program's output easy to read with the standard library's term module: colors, lines that say what happened, tables whose columns line up, and a progress bar. Then read what people type, with line editing and a history, as GNU readline does. This guide shows each one, then puts them together in a small report. Every step is a whole program you can build and run.
What you need
Any ktm from v0.27.0, and v0.28.0 for reading what people type. term is part of the standard library, so there is nothing to fetch. Every program in this guide is one file, app.ktm, built and run like this:
ktm app.ktm -o app
./app
Run the programs in a terminal to see the colors. The outputs below are what a pipe or a file gets: the same text, without colors. The next section but one says why.
Colors
term.print is printf with colors. Put the words of a color in square brackets before the text, and [/] after it:
import term;
i32 main() {
term.print("[green]passed[/] 12 tests\n");
term.print("[bold red]failed[/] 2 tests\n");
term.print("[yellow]skipped[/] %d tests in %s\n", 3, "net/http");
term.print("[dim]took 1.4s[/]\n");
return 0;
}
passed 12 tests
failed 2 tests
skipped 3 tests in net/http
took 1.4s
In a terminal, passed is green, failed bold and red, skipped yellow and the last line dim.
| Words | What they do |
|---|---|
black, red, green, yellow, blue, magenta, cyan, white, gray | the text's color |
on red, on blue, ... (any color) | the background |
bold, dim, italic, underline, reverse, strike | the style |
- A tag can hold several words:
[bold red],[white on blue],[underline cyan]. [/]closes the last tag that is still open, so tags can nest:[bold]total: [green]ok[/] in 3s[/]keepsin 3sbold.- A tag still open at the end of the text is closed there, so a color never runs into the next line.
%d,%sand the rest work as inprintf. The values are written as they are, never read as markup: a file called[red]prints as[red].- Square brackets around anything else stay as they are:
[1, 2],[ok]. To write a word of the markup in brackets, double the opening one:[[red]prints[red]. term.eprintis the same for stderr.
Lines that say what happened
Four calls write one line each, after a symbol in its color. They add the newline:
import term;
i32 main() {
term.title("Deploying shop v2.4.0");
term.info("building %d services", 3);
term.success("api is up");
term.success("web is up");
term.warn("worker took %ds to start", 41);
term.error("can't reach %s", "db.internal:5432");
return 0;
}
Deploying shop v2.4.0
• building 3 services
✓ api is up
✓ web is up
! worker took 41s to start
✗ can't reach db.internal:5432
| Call | Symbol | Where |
|---|---|---|
term.success(fmt, ...) | a green ✓ | stdout |
term.info(fmt, ...) | a blue • | stdout |
term.warn(fmt, ...) | a yellow ! | stderr |
term.error(fmt, ...) | a red ✗ | stderr |
term.title(fmt, ...) | none: the line is bold | stdout |
Warnings and errors go to stderr, so ./app > report.txt keeps them on the screen and the report clean. The message can hold markup too: term.error("can't reach [bold]%s[/]", host).
When there are no colors
Colors are escape codes: bytes a terminal reads as "turn green". In a file they are noise, so term only writes them to a terminal. For each of stdout and stderr, colors are:
- on when it is a terminal;
- off when it is a pipe or a file (
./app | less,./app > out.txt); - off when the
NO_COLORenvironment variable is set, which is the standard way for a user to turn them off everywhere; - off when
TERMisdumb, as in some editors' terminals; - on when
FORCE_COLORis set (and isn't0), for CI logs that show colors.
With colors off, the markup is removed and the text stays, as in the outputs on this page. A program can decide itself with term.setColors, and ask with term.colors():
import term;
i32 main() {
term.print("colors on stdout: %s\n", term.colors() ? "yes" : "no");
term.setColors(true);
String ok = term.format("[green]ok[/]");
printf("with colors, \"ok\" takes %d bytes\n", ok.len);
ok.free();
term.setColors(false);
term.print("[green]never green[/]\n");
return 0;
}
colors on stdout: no
with colors, "ok" takes 11 bytes
never green
- The output is a pipe here, so
term.colors()says no. It answers for stdout. term.setColors(true)turns colors on for stdout and stderr, whatever they are. The two letters then take 11 bytes: 5 to turn green, 2 forokand 4 to turn back. Use it for a--colorflag that the user asked for, and not otherwise.term.setColors(false)turns them off, even in a terminal.
Tables
term.table takes the column headers. t.row adds a row, and t.print writes the table with its columns lined up:
import term;
i32 main() {
term.Table *t = term.table("SERVICE", "STATUS", "CPU %", "MEMORY MB");
t.row("api", "[green]running[/]", 12.5, 512);
t.row("web", "[green]running[/]", 3.25, 128);
t.row("worker", "[red]stopped[/]", 0, 0);
t.print();
t.free();
return 0;
}
SERVICE STATUS CPU % MEMORY MB
api running 12.5 512
web running 3.25 128
worker stopped 0 0
- The headers are bold in a terminal.
- A cell can be text, a whole number, a number with a fraction, a
bool, an enum (its member's name) or aString. Numbers are written asprintf's%gwrites them. - A column whose cells are all numbers lines up on the right, so the digits line up. Any other column lines up on the left.
- Text cells can hold markup. The colors don't count in the width, so the columns still line up.
- Columns are two spaces apart, and no line ends in spaces.
t.free()frees the table and its cells.
Lining up and borders
t.align puts one column on the left, the right or the center. t.border draws lines around the cells, or makes a Markdown table to paste into a README or an issue:
import term;
enum Plan { free, team, business };
i32 main() {
term.Table *t = term.table("CUSTOMER", "PLAN", "SEATS", "ACTIVE");
t.row("Acme", Plan.business, 120, true);
t.row("Globex", Plan.team, 14, true);
t.row("Initech", Plan.free, 1, false);
t.align(1, term.Align.center);
t.border = term.Border.box;
t.print();
t.border = term.Border.markdown;
t.print();
t.free();
return 0;
}
╭──────────┬──────────┬───────┬────────╮
│ CUSTOMER │ PLAN │ SEATS │ ACTIVE │
├──────────┼──────────┼───────┼────────┤
│ Acme │ business │ 120 │ true │
│ Globex │ team │ 14 │ true │
│ Initech │ free │ 1 │ false │
╰──────────┴──────────┴───────┴────────╯
| CUSTOMER | PLAN | SEATS | ACTIVE |
| -------- | -------: | ----: | ------ |
| Acme | business | 120 | true |
| Globex | team | 14 | true |
| Initech | free | 1 | false |
- Columns count from 0:
t.align(1, ...)isPLAN. term.Alignisleft,rightorcenter.term.Borderisnone(the default),boxormarkdown. A Markdown table never has colors, and its second line says how each column lines up.
A table without a header, and a table as text
term.table() with no headers makes a table without a header line: a list of names and values. t.format() gives the table as a String instead of printing it, to write it to a file or put it in a message:
import term;
i32 main() {
term.Table *t = term.table();
t.row("version", "2.4.0");
t.row("commit", "a91f3c2");
t.row("built", "2026-10-16");
String text = t.format();
printf("%d bytes:\n%.*s", text.len, text.len, text.ptr);
text.free();
t.free();
return 0;
}
52 bytes:
version 2.4.0
commit a91f3c2
built 2026-10-16
t.format() has no colors. t.format(true) keeps them, for a String that goes to the terminal. t.rows() says how many rows were added.
A progress bar
term.progress takes a label and the number of steps. p.add() says one more step is done, and p.done() ends the bar and frees it:
import term;
i32 main() {
i64 files = 240;
term.Progress *p = term.progress("copying", files);
for (i64 i = 0; i < files; i++) {
/* copy one file here */
p.add();
}
p.done();
term.success("copied %d files", files);
return 0;
}
✓ copied 240 files
copying ██████████████████████████████ 100% 240/240 0.0s
In a terminal, the bar is drawn on one line and redrawn in place as the work goes on:
copying ███████████████░░░░░░░░░░░░░░░ 50% 120/240
At p.done() it is drawn full, with the time the work took, and the program goes on below it.
- The bar goes to stderr, like the warnings, so stdout stays for the program's real output.
- It is redrawn when its percent changes, or after 100 ms, so a loop of a million steps costs about a hundred redraws, not a million.
- Lines written with
term.print,term.infoand the others while the bar runs go above it, and the bar is drawn again under them. - In a pipe or a file there is nothing to redraw, so only the last line is written, at
p.done(). That is why it comes after the✓line above: stdout and stderr are two streams, and this page shows stdout first. p.add(n)addsnsteps, andp.set(n)says how many are done in all.
Reading what people type
term.readLine shows a prompt and reads one line. On a terminal, the line can be edited with the keys of a shell, and Up and Down go through the lines typed before. It answers false when the input ends, so a loop over it is a whole interactive program. This one keeps a to-do list:
import str, term, vec;
i32 main() {
term.historyFile(".todo_history");
scoped vec.Vec<String> todos = vec.new<String>();
term.print("[bold]todo[/]: add <text>, done <n>, list, clear, exit\n");
scoped String line;
while (term.readLine("[cyan]todo>[/] ", &line)) {
u8[] cmd = str.trim(line[0:]);
if (str.equals(cmd, "exit")) {
break;
} else if (str.equals(cmd, "clear")) {
term.clear();
} else if (str.startsWith(cmd, "add ")) {
todos.push(str.from(str.trim(cmd[4:])));
term.success("added #%d", todos.len);
} else if (str.startsWith(cmd, "done ")) {
i64 n;
if (!str.toInt(str.trim(cmd[5:]), &n) || n < 1 || n > todos.len) {
term.error("no todo %.*s", cmd.len - 5, cmd.ptr + 5);
continue;
}
String gone = todos.removeAt(n - 1);
term.success("done: %.*s", gone.len, gone.ptr);
gone.free();
} else if (str.equals(cmd, "list")) {
term.Table *t = term.table("#", "TODO");
for (i64 i = 0; i < todos.len; i++) {
t.row(i + 1, todos.data[i]);
}
t.print();
t.free();
} else if (cmd.len) {
term.error("unknown command: %.*s", cmd.len, cmd.ptr);
}
}
term.print("bye\n");
return 0;
}
ktm app.ktm -o app
./app
A session, with what was typed after each todo>:
todo: add <text>, done <n>, list, clear, exit
todo> add buy milk
✓ added #1
todo> add call the bank
✓ added #2
todo> list
# TODO
1 buy milk
2 call the bank
todo> done 1
✓ done: buy milk
todo> done 9
✗ no todo 9
todo> frobnicate
✗ unknown command: frobnicate
todo> exit
bye
term.readLine(prompt, &line)puts the line inline, without its newline, and replaces whatlineheld: onescoped Stringserves the whole loop.- It answers
falsewhen the input ends: Ctrl-D on an empty line, or the end of a pipe. Typingexitis the program's own command, as here. - The prompt can hold markup, like everything
termprints. - Every line read goes into the history, except empty ones and a line the same as the one before.
term.historyFile(path)reads the lines saved there by earlier runs and appends each new one, so Up brings back yesterday's commands too. Without it the history lasts as long as the program.term.clear()clears the screen. Ctrl-L does the same while a line is typed.- When stdin isn't a terminal, the lines are read as they come, with nothing to edit. The same program runs a script:
printf 'add milk\nlist\n' | ./app.
The keys, as in bash and GNU readline:
| Keys | What they do |
|---|---|
| Left, Right (Ctrl-B, Ctrl-F) | move one character |
| Ctrl-Left, Ctrl-Right (Alt-B, Alt-F) | move one word |
| Home, End (Ctrl-A, Ctrl-E) | go to the start or the end |
| Backspace, Delete | delete the character before or under the cursor |
| Ctrl-W, Alt-Backspace | delete the word before the cursor |
| Ctrl-U, Ctrl-K | delete everything before or after the cursor |
| Up, Down (Ctrl-P, Ctrl-N) | the line before or after in the history |
| Ctrl-L | clear the screen |
| Ctrl-C | drop the line and ask again |
| Ctrl-D | end the input on an empty line; delete the character under the cursor otherwise |
| Enter | the line is done |
A line longer than the terminal scrolls sideways under the cursor.
Passwords and the history by hand
term.readPassword reads a line without showing it, and never puts it in the history:
import term;
i32 main() {
scoped String user;
scoped String pass;
if (!term.readLine("user: ", &user) || !term.readPassword("password: ", &pass)) {
return 1;
}
term.success("%.*s, your password has %d characters", user.len, user.ptr, pass.len);
return 0;
}
user: ada
password:
✓ ada, your password has 9 characters
- Nothing appears while the password is typed, not even stars. Backspace and Ctrl-U work.
- Ctrl-C or Ctrl-D makes it answer
false, so the program can stop.
term.historyAuto(false) stops readLine from adding lines; term.historyAdd(line) then adds the ones you choose, such as the commands that worked. term.historyMax(n) keeps the last n lines (1000 at first), term.historyLen() and term.historyAt(i) read them, oldest first, and term.historyClear() empties the history in memory.
Putting it together
A program that checks a few services and reports on them, with each part of term where it fits:
import term;
struct Service {
u8 *name;
u8 *host;
i64 ms; /* how long it took to answer; -1: no answer */
};
i32 main() {
Service services[5] = {
{"api", "api.internal:8080", 12},
{"web", "web.internal:80", 48},
{"search", "search.internal:9200", 730},
{"db", "db.internal:5432", -1},
{"cache", "cache.internal:6379", 2},
};
term.title("Checking %d services", 5);
term.Progress *p = term.progress("checking", 5);
for (i64 i = 0; i < 5; i++) {
/* ask services[i] here */
p.add();
}
p.done();
term.Table *t = term.table("SERVICE", "HOST", "STATUS", "MS");
i64 down = 0, slow = 0;
for (i64 i = 0; i < 5; i++) {
Service *s = &services[i];
if (s.ms < 0) {
t.row(s.name, s.host, "[red]down[/]", "-");
down++;
} else if (s.ms > 500) {
t.row(s.name, s.host, "[yellow]slow[/]", s.ms);
slow++;
} else {
t.row(s.name, s.host, "[green]up[/]", s.ms);
}
}
t.align(3, term.Align.right);
t.print();
t.free();
if (slow) {
term.warn("%d service answered slowly", slow);
}
if (down) {
term.error("%d service is down", down);
return 1;
}
term.success("all services are up");
return 0;
}
Checking 5 services
SERVICE HOST STATUS MS
api api.internal:8080 up 12
web web.internal:80 up 48
search search.internal:9200 slow 730
db db.internal:5432 down -
cache cache.internal:6379 up 2
checking ██████████████████████████████ 100% 5/5 0.0s
! 1 service answered slowly
✗ 1 service is down
- The
MScolumn holds a-for the service that didn't answer. That makes it a text column, which would line up on the left, sot.align(3, term.Align.right)keeps it on the right. - The program ends with exit code 1 when a service is down, so a script or a CI job can tell.
Reference
| Call | What it does |
|---|---|
term.print(fmt, ...) | printf to stdout, with markup in fmt |
term.eprint(fmt, ...) | the same, to stderr |
term.format(fmt, ...) | the same, into a new String (colors as stdout has them) |
term.success, term.info | one line on stdout after a green ✓ or a blue • |
term.warn, term.error | one line on stderr after a yellow ! or a red ✗ |
term.title(fmt, ...) | one bold line on stdout |
term.colors() | does stdout get colors? |
term.setColors(on) | colors on or off for stdout and stderr, whatever they are |
term.width(text) | the columns text takes on a terminal, without its markup |
term.table(headers...) | a new table; no headers for a table without a header line |
t.row(cells...) | one row: text, numbers, bools, enums or Strings |
t.align(col, a) | term.Align.left, right or center for column col (from 0) |
t.border | term.Border.none, box or markdown |
t.print() | writes the table to stdout |
t.format(colors) | the table as a new String; colors is false when left out |
t.rows(), t.free() | how many rows; frees the table |
term.readLine(prompt, &line) | reads a line, with editing and the history on a terminal; false at the end |
term.readPassword(prompt, &line) | reads a line without showing it, not into the history |
term.clear() | clears the terminal |
term.historyFile(path) | reads the history from a file and appends new lines to it |
term.historyAdd(line), term.historyAuto(on) | add a line by hand; whether readLine adds them |
term.historyMax(n), term.historyLen(), term.historyAt(i), term.historyClear() | the history's size and lines |
term.progress(label, total) | a progress bar on stderr |
p.add(n), p.set(n) | n more steps done (1 when left out); n steps done in all |
p.done() | the last line, with the time taken; frees the bar |
The whole module is in the library reference.
Rules of thumb
- Color a word, not a line.
[red]failed[/] 2 testsis easier to read than a whole red line. - Give each color one meaning and keep it: green for done, yellow for a warning, red for an error. Color only adds to the text:
failedstill has to say it, for the people and the files that don't see the color. - Results to stdout, everything about them to stderr. The
termcalls already do this: tables andsuccesslines are output, warnings, errors and the progress bar are not. - Never force colors on. Leave the choice to
termand the user'sNO_COLOR, unless the user asked for colors with a flag. - Put numbers in a table as numbers, not as text, so they line up on the right.