Terminal Rendering

Declarative terminal/CLI output with ANSI colors, components, and live rendering

17 min read 3431 words

Kida's terminal rendering mode lets you build CLI dashboards, status reports, and interactive terminal UIs using the same template syntax you use for HTML. Setautoescape="terminal"and you get ANSI color filters, box-drawing components, progress bars, live re-rendering, and automatic degradation across color depths and character sets.

Terminal mode replaces HTML escaping with ANSI sanitization. Untrusted input has dangerous escape sequences (cursor movement, screen manipulation) stripped while safe SGR styling codes are preserved. TheStyled class is the terminal analogue of Markup-- it marks content as already safe for terminal output.

Quick Start

The fastest way to activate terminal mode is withterminal_env(), which creates a pre-configured Environmentwith built-in component templates:

from kida.terminal import terminal_env

env = terminal_env()
template = env.from_string("""
{{ "Build Report" | bold | cyan }}
{{ hr(40) }}
{{ "Tests" | kv("412 passed", width=35) }}
{{ "Coverage" | kv("94.2%", width=35) }}
{{ 0.85 | bar(width=25) }}
""")
print(template.render())

You can also set terminal mode on a standard Environment:

from kida import Environment

env = Environment(autoescape="terminal")

The difference is that terminal_env() adds a loader for the built-in component templates (components.txt), so you can {% from "components.txt" import panel, header, footer %}. When using Environment(autoescape="terminal")directly, you get all terminal filters and globals but need to supply your own loader if you want to import the component library.

Environment Options

Both approaches accept these terminal-specific keyword arguments:

Argument Type Default Description
terminal_color str Auto-detected Color depth:"none", "basic", "256", or "truecolor"
terminal_width int Auto-detected Terminal width in columns
terminal_unicode bool Auto-detected Whether to use Unicode box-drawing and icons
ambiguous_width int Auto-detected Width for East Asian Ambiguous characters (1 or 2)
# Force basic 16-color output at 60 columns
env = terminal_env(terminal_color="basic", terminal_width=60)

Template Globals

Terminal mode injects these globals into every template:

Global Type Description
columns int Terminal width in columns
rows int Terminal height in lines
tty bool Whether stdout is a TTY
icons IconSet Unicode/ASCII icon proxy
box BoxSet Box-drawing character set proxy
hr() function Horizontal rule generator

Terminal Filters

Terminal mode registers over 30 filters for styling, layout, and data display. All style filters returnStyledstrings that bypass ANSI sanitization.

Color Filters

Named color filters apply ANSI foreground colors. When colors are disabled (terminal_color="none"), they return the text unchanged.

Filter SGR Code Example
black 30 {{ text \| black }}
red 31 {{ text \| red }}
green 32 {{ text \| green }}
yellow 33 {{ text \| yellow }}
blue 34 {{ text \| blue }}
magenta 35 {{ text \| magenta }}
cyan 36 {{ text \| cyan }}
white 37 {{ text \| white }}
bright_red 91 {{ text \| bright_red }}
bright_green 92 {{ text \| bright_green }}
bright_yellow 93 {{ text \| bright_yellow }}
bright_blue 94 {{ text \| bright_blue }}
bright_magenta 95 {{ text \| bright_magenta }}
bright_cyan 96 {{ text \| bright_cyan }}

Extended Color:fg and bg

Thefg and bgfilters accept named colors, hex strings, 256-color indices, and RGB tuples. They degrade automatically based on the terminal's color depth (see Color Depth & Fallback).

{# Hex color #}
{{ title | fg("#00ccff") }}

{# 256-color index #}
{{ status | fg(196) }}

{# Named color #}
{{ label | bg("blue") }}

Decoration Filters

Filter Effect SGR Code
bold Bold / bright 1
dim Dim / faint 2
italic Italic 3
underline Underline 4
blink Blink 5
inverse Swap fg/bg 7
strike Strikethrough 9

Filters chain naturally:

{{ "CRITICAL" | bold | red }}
{{ filename | underline | cyan }}
{{ "(deprecated)" | dim | strike }}

Layout Filters

pad(width, align="left", fill=" ")

ANSI-aware padding. Measures visible character width (ignoring escape sequences) and pads to the target width.

{{ name | pad(20) }}                     {# left-align in 20 cols #}
{{ count | pad(10, align="right") }}     {# right-align #}
{{ title | pad(40, align="center") }}    {# center #}

wordwrap / wrap

ANSI-aware word wrapping. Tracks active style state across line breaks so colors carry over to wrapped lines.

{{ long_text | wrap(60) }}

truncate

ANSI-aware truncation. Counts visible characters, not raw bytes, so ANSI codes never cause premature truncation or broken escape sequences.

{{ description | truncate(40) }}

center

ANSI-aware centering to the specified width.

{{ title | center(80) }}

Data Display Filters

badge

Maps status keywords to colored icons. Recognized statuses:pass/success/ok, fail/error/failed, warn/warning, skip/skipped, info.

{{ "pass" | badge }}   {# green checkmark #}
{{ "fail" | badge }}   {# red cross #}
{{ "warn" | badge }}   {# yellow warning #}
{{ "skip" | badge }}   {# dim circle #}

When colors are disabled, badges render as ASCII labels: [PASS], [FAIL], [WARN], [SKIP].

bar(width=20, show_pct=True)

Progress bar from a 0.0--1.0 float value.

{{ 0.75 | bar(width=30) }}           {# block chars + percentage #}
{{ ratio | bar(width=20, show_pct=False) }}

Uses Unicode block characters when available, falls back to [####----]on ASCII terminals.

kv(value, width=40, sep=" ", fill=".")

Key-value pair with dot-leader fill.

{{ "Version" | kv("2.4.1", width=35) }}
{{ "Uptime" | kv("3h 42m", width=35) }}

table(headers=None, border="light", align=None, max_width=None)

Renders a list of dicts or list of lists as a bordered table.

{{ users | table(headers=["Name", "Role", "Status"]) }}
{{ data | table(border="heavy", align={"Score": "right"}) }}
{{ results | table(max_width=60) }}

Available border styles: light, heavy, double, round, ascii.

tree(indent=2)

Renders a nested dict as a tree with box-drawing connectors.

{{ directory_structure | tree }}

Outputs:

├── src
│   ├── main.py
│   └── utils.py
└── tests
    └── test_main.py

diff(new, context=3)

Unified diff between two strings with colored additions/deletions.

{{ old_config | diff(new_config) }}

Green + lines for additions, red - lines for deletions, cyan @@headers.

Built-in Components

The component library lives incomponents.txt and provides reusable template macros for structured terminal layouts. Import them in any template loaded through terminal_env():

{% from "components.txt" import panel, header, footer, row, cols, rule, connector, banner, stack, two_col, dl %}

Components are organized in three layers:

Primitives -- single-line building blocks:

  • row(content, width, border="|")-- bordered line with ANSI-aware padding
  • cols(cells, sep=" ") -- multi-column layout from (content, width[, align])tuples
  • rule(width, title="", char="---")-- horizontal rule with optional title

Components -- bordered panels:

  • panel(title="", width=0, style="round", padding=1)-- box with inline title in the top border
  • header(width=0, style="double")-- double-bordered title bar
  • footer(width=0, style="heavy")-- heavy-bordered summary bar
  • box(title="", width=0, style="round", padding=1)-- bordered box with title in a separate header row
  • connector(indent=2)-- vertical pipe between panels for visual continuity

Layout helpers -- content arrangement:

  • banner(text, width=0, char="=", padding=1)-- full-width centered banner
  • two_col(left_width=0, sep=" | ") -- two-column layout split on |||
  • stack(threshold=60, sep=" | ")-- responsive: side-by-side when wide, stacked when narrow
  • dl(items, label_width=20, sep=" : ", color="cyan")-- definition list

All components default their width to thecolumns global (terminal width) when width=0.

Panel Example

{% from "components.txt" import panel %}

{% call panel(title="CPU Usage", width=60) %}
Core 0   {{ 0.67 | bar(width=20) }}
Core 1   {{ 0.25 | bar(width=20) }}
Core 2   {{ 0.91 | bar(width=20) }}
{% endcall %}

A complete dashboard layout with header, content panels, connectors, and a footer:

{% from "components.txt" import header, panel, footer, connector %}
{% set w = 64 %}

{% call header(width=w) %}
{{ icons.gear | bright_cyan }} {{ "DEPLOY PIPELINE" | bold | bright_cyan | pad(20) }}{{ version | dim }}
{% endcall %}

{% for stage in stages %}
{% call panel(title=stage.name, width=w) %}
{{ stage.name | bold | pad(26) }}{{ stage.status | badge | pad(10) }}{{ stage.duration | dim }}
{% endcall %}
{% if not loop.last %}{{ connector() }}{% endif %}
{% endfor %}

{% call footer(width=w) %}
{{ icons.zap | yellow }} {{ "SUMMARY" | bold | yellow }}   {{ "Duration" | kv(total_duration, width=32) }}
{% endcall %}

Responsive Stacking

Thestack component renders side-by-side when the terminal is wide enough, and vertically when narrow. Split content with |||:

{% from "components.txt" import stack %}

{% call stack(threshold=80) %}
{{ icons.star }} {{ "Latency" | bold }}
  p50 {{ metrics.p50 }}
  p99 {{ metrics.p99 }}
|||
{{ icons.zap }} {{ "Health" | bold }}
  Uptime     {{ metrics.uptime }}
  Error Rate {{ metrics.error_rate }}
{% endcall %}

Definition List

{% from "components.txt" import dl %}

{{ dl([
    ("Version", "2.4.1"),
    ("Uptime", "3h 42m"),
    ("Status", "running"),
], label_width=12) }}

Color Depth & Fallback

Thefg() and bg()filters degrade gracefully across four color depths. You write colors once and they render correctly everywhere:

Depth Env Variable / Detection fg("#00ccff")Emits
truecolor COLORTERM=truecolor or 24bit \033[38;2;0;204;255m
256 TERM contains 256color \033[38;5;45m(nearest cube color)
basic TTY detected \033[36m(nearest of 16 ANSI colors)
none NO_COLORset, or not a TTY Plain text, no escapes

The conversion pipeline:

  1. RGB to 256: Maps to the nearest color in the 6x6x6 color cube (indices 16--231) or the grayscale ramp (232--255), whichever is closer by Euclidean distance.
  2. RGB/256 to basic 16: Finds the nearest of the 16 standard ANSI colors by squared Euclidean distance against canonical RGB values.
  3. Named colors: Always work at any depth --red, cyan, bright_green, etc. use fixed SGR codes.
# Force a specific color depth
env = terminal_env(terminal_color="256")

# Or let auto-detection handle it (default)
env = terminal_env()

Character Width

Terminal output requires precise column alignment. ANSI escape sequences are zero-width (they occupy bytes but no screen columns), and some Unicode characters are double-width. Kida handles both.

WidthStrategy

TheWidthStrategydataclass controls how ambiguous-width characters are measured:

from kida.utils.ansi_width import WidthStrategy, configure_width

# Configure for CJK terminals where ambiguous chars render at double width
configure_width(ambiguous_width=2)

Width resolution follows a fallback chain when ambiguous_widthis not explicitly set:

  1. Explicit override:Environment(ambiguous_width=2) or terminal_env(ambiguous_width=2)
  2. Terminal probe: Writes a test character and measures cursor movement via ANSI DSR
  3. wcwidth library: Uses per-character width data if the optional wcwidthpackage is installed
  4. Locale heuristic: CJK locales (ja, ko, zh) default to 2
  5. Default: 1

ANSI-Aware String Operations

All built-in string operations in terminal mode are ANSI-aware. They measure visible width (ignoring escape sequences) and handle double-width characters:

  • visible_len(s)-- visible character count, ignoring ANSI escapes
  • ansi_ljust(s, width)-- left-justify to visible width
  • ansi_rjust(s, width)-- right-justify to visible width
  • ansi_center(s, width)-- center within visible width
  • ansi_truncate(s, width)-- truncate at visible width, preserving ANSI codes
  • ansi_wrap(s, width)-- word-wrap at visible width, re-applying styles on new lines

Thepad, truncate, center, and wordwraptemplate filters all use these functions automatically in terminal mode.

LiveRenderer

LiveRendererprovides in-place terminal re-rendering. It uses ANSI cursor movement to overwrite previously rendered output, creating smooth animation effects.

import time
from kida.terminal import terminal_env, LiveRenderer, Spinner

env = terminal_env()
tpl = env.from_string("""\
{% from "components.txt" import panel %}
{% call panel(title="Build", width=50) %}
{{ spinner() }} {{ status | bold | pad(20) }}{{ progress | bar(width=20) }}
{% endcall %}
""", name="live")

with LiveRenderer(tpl) as live:
    live.update(status="compiling", progress=0.0)
    time.sleep(1)
    live.update(status="testing", progress=0.5)
    time.sleep(1)
    live.update(status="done", progress=1.0)

How It Works

  1. On__enter__, the cursor is hidden to prevent flicker
  2. Eachupdate(**context) re-renders the template and overwrites the previous output using \033[A (cursor up) and \033[2K(erase line)
  3. On__exit__, the cursor is restored; Ctrl+C is handled gracefully
  4. Context accumulates acrossupdate()calls -- you only need to pass changed values

Constructor Options

Parameter Type Default Description
template Template required Compiled Kida template
refresh_rate float 0.1 Minimum seconds between auto-refreshes
file TextIO sys.stdout Output stream
transient bool False Clear output on exit instead of leaving it

Auto-Refresh

For continuous animation (spinners, progress), usestart_auto()to re-render in a background thread:

with LiveRenderer(tpl, refresh_rate=0.1) as live:
    live.start_auto(status="building")
    # Spinner animates automatically every 0.1s
    time.sleep(5)
    live.stop_auto()
    live.update(status="done")

Spinner

Spinner is a callable that returns a different frame on each call. It is automatically provided as spinner in LiveRenderercontext.

{{ spinner() }} Loading...

Built-in frame sets:

Name Frames Style
Spinner.BRAILLE / Spinner.DOTS Braille dot pattern Braille dots (default)
Spinner.LINE - \ | / ASCII line spinner
Spinner.ARROW Directional arrows 8-direction rotation

Custom frames:

spinner = Spinner(frames=(".", "..", "...", "...."))

Non-TTY Fallback

When output is not a TTY (piped, CI, etc.),LiveRendererappends each render separated by a blank line instead of overwriting. No cursor manipulation is attempted.

stream_to_terminal()

stream_to_terminal() writes render_stream()chunks progressively with an optional delay between them, creating a typewriter-style reveal effect.

from kida.terminal import terminal_env, stream_to_terminal

env = terminal_env()
template = env.from_string("""
{{ "Build Log" | bold | cyan }}
{{ hr(40) }}
{% for step in steps %}
{{ icons.check | green }} {{ step }}
{% endfor %}
""")

stream_to_terminal(template, {"steps": ["compile", "test", "deploy"]}, delay=0.05)
Parameter Type Default Description
template Template required Compiled Kida template
context dict None Template context variables
delay float 0.02 Seconds between chunks (0 = no delay)
file TextIO sys.stdout Output stream

When output is not a TTY, the delay is skipped and all chunks are written immediately.

Use{% flush %}in templates to create explicit chunk boundaries for streaming.

Icons & Box Drawing

Icons

Theicons global is an IconSetproxy. Access icons as attributes:

{{ icons.check }} All tests passed
{{ icons.arrow_r }} Next step
{{ icons.gear }} Settings

Available icons (Unicode / ASCII fallback):

Name Unicode ASCII Name Unicode ASCII
check [ok] arrow_r ->
cross [FAIL] arrow_l <-
warn [!] arrow_u ^
info [i] arrow_d v
dot * play >
circle o stop []
star * bullet *
diamond * gear [G]
flag [F] zap !
heart <3 spark *
lock 🔒 [L] folder 📁 [D]
key 🔑 [K] file 📄 [F]
fat_arrow =>

There is no genericicons.arrow — use a directional variant (arrow_r, arrow_l, arrow_u, arrow_d) or fat_arrow(⇒). If you reference an unknown icon name, Kida raisesAttributeErrorwith "did you mean?" suggestions.

Several icons append VS15 (U+FE0E, Variation Selector 15) to force text presentation and prevent emoji rendering in terminals that would otherwise display them as double-width color emoji.

Whenterminal_unicode=False, all icons return their ASCII fallback.

Box Drawing

Thebox global is a BoxSetproxy with five built-in styles:

{% set b = box.round %}
{{ b.tl }}{{ b.h * 38 }}{{ b.tr }}
{{ b.v }} {{ "Hello, Terminal!" | bold | pad(36) }} {{ b.v }}
{{ b.bl }}{{ b.h * 38 }}{{ b.br }}
Style Corners Lines Description
box.light +-+ -| Light single-line
box.heavy Heavy single Thick lines Heavy single-line
box.double Double-line Double lines Double-line borders
box.round Rounded corners Light lines Light with rounded corners
box.ascii + -| Pure ASCII, always available

Each style provides these characters:tl, tr, bl, br (corners), h, v (lines), lj, rj, tj, bj (junctions), and cross(four-way junction).

Whenterminal_unicode=False, all styles fall back to ascii.

Horizontal Rules

Thehr()global generates horizontal rules:

{{ hr() }}                          {# full terminal width #}
{{ hr(40) }}                        {# 40 characters wide #}
{{ hr(60, title="Results") }}       {# -- Results ---------- #}
{{ hr(40, char="=") }}              {# ======== #}

CLI: kida render

Thekida rendercommand renders a template to stdout from the command line. It defaults to terminal mode.

# Basic render with JSON data
kida render template.txt --data context.json

# Inline JSON
kida render template.txt --data-str '{"name": "world"}'

# Override width and color depth
kida render template.txt --data data.json --width 60 --color basic

# Progressive streaming output
kida render template.txt --data data.json --stream --stream-delay 0.05

# HTML mode (disables terminal features)
kida render template.txt --data data.json --mode html

Options

Flag Type Default Description
--data FILE Path -- JSON file providing template context
--data-str JSON String -- Inline JSON context
--mode html / terminal terminal Rendering mode
--width N int Auto Override terminal width
--color DEPTH none/basic/256/truecolor Auto Override color depth
--stream flag off Progressive chunk-by-chunk output
--stream-delay SECS float 0.02 Delay between streamed chunks

The template's parent directory is used as the loader root, so{% from "components.txt" import panel %} works when components.txtis in the same directory.

NO_COLOR / TTY Detection

Kida follows the NO_COLOR convention and performs automatic TTY detection.

Environment Variables

Variable Effect
NO_COLOR Set to any value to disable all color output
FORCE_COLOR Set to any value to force color output (overridesNO_COLORand non-TTY)
COLORTERM=truecolor or 24bit Enables 24-bit RGB color
TERM=*256color* Enables 256-color mode

Detection Order

Color depth is auto-detected in this order:

  1. NO_COLOR set --> "none"(no colors)
  2. FORCE_COLOR set --> "basic"(16 colors)
  3. COLORTERM is truecolor or 24bit --> "truecolor"
  4. TERM contains 256color --> "256"
  5. stdout is a TTY -->"basic"
  6. Otherwise -->"none"

Unicode Detection

Unicode support is detected from theLANG environment variable. If LANG contains UTF(case-insensitive), Unicode box-drawing and icon characters are used. Otherwise, ASCII fallbacks are substituted.

Non-TTY Behavior

When stdout is not a TTY:

  • All colors are disabled (unlessFORCE_COLORis set)
  • LiveRendererappends output instead of overwriting
  • stream_to_terminal()writes immediately with no delay
  • kida renderoutput is safe to pipe or redirect

See Also

  • Streaming --render_stream() and {% flush %}for progressive output
  • Escaping -- How autoescape modes work, including terminal sanitization
  • API Reference -- Full method signatures forEnvironment, Template, and filters