ktm v0.28.3

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.

WordsWhat they do
black, red, green, yellow, blue, magenta, cyan, white, graythe text's color
on red, on blue, ... (any color)the background
bold, dim, italic, underline, reverse, strikethe style

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
CallSymbolWhere
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 boldstdout

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:

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

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

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  |

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.

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

The keys, as in bash and GNU readline:

KeysWhat 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, Deletedelete the character before or under the cursor
Ctrl-W, Alt-Backspacedelete the word before the cursor
Ctrl-U, Ctrl-Kdelete everything before or after the cursor
Up, Down (Ctrl-P, Ctrl-N)the line before or after in the history
Ctrl-Lclear the screen
Ctrl-Cdrop the line and ask again
Ctrl-Dend the input on an empty line; delete the character under the cursor otherwise
Enterthe 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

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

Reference

CallWhat 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.infoone line on stdout after a green ✓ or a blue •
term.warn, term.errorone 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.borderterm.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