Terminal Rendering
Build rich CLI output with Kida's terminal mode.
Build CLI tools with colored output, tables, progress bars, and live updates using Kida's terminal rendering surface.
Tip
Duration: ~30 min | Prerequisite: Kida basics, Python 3.14+
Terminal Rendering
Declarative terminal/CLI output with ANSI colors, components, and live rendering
Terminal Rendering
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.
syntax(language="json")
Regex-based syntax highlighting for structured data formats. Supported languages:json and yaml (alias yml).
{{ config_json | syntax }} {# JSON is the default #}
{{ config_yaml | syntax(language="yaml") }}
Color mapping:
| Token | Color | SGR Code |
|---|---|---|
| Keys | Cyan | 36 |
| String values | Green | 32 |
| Numbers | Yellow | 33 |
| Booleans / null | Magenta | 35 |
| Braces / brackets (JSON) | Dim | 2 |
| Comments (YAML) | Dim | 2 |
Unknown languages return the content unstyled. When colors are disabled, the content is returned as-is. Input is sanitized to prevent ANSI injection.
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 paddingcols(cells, sep=" ")-- multi-column layout from(content, width[, align])tuplesrule(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 borderheader(width=0, style="double")-- double-bordered title barfooter(width=0, style="heavy")-- heavy-bordered summary barbox(title="", width=0, style="round", padding=1)-- bordered box with title in a separate header rowconnector(indent=2)-- vertical pipe between panels for visual continuity
Layout helpers -- content arrangement:
banner(text, width=0, char="=", padding=1)-- full-width centered bannertwo_col(left_width=0, sep=" | ")-- two-column layout split on|||stack(threshold=60, sep=" | ")-- responsive: side-by-side when wide, stacked when narrowdl(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 %}
Header + Panel + Footer Layout
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:
- 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.
- RGB/256 to basic 16: Finds the nearest of the 16 standard ANSI colors by squared Euclidean distance against canonical RGB values.
- 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:
- Explicit override:
Environment(ambiguous_width=2)orterminal_env(ambiguous_width=2) - Terminal probe: Writes a test character and measures cursor movement via ANSI DSR
wcwidthlibrary: Uses per-character width data if the optionalwcwidthpackage is installed- Locale heuristic: CJK locales (
ja,ko,zh) default to 2 - 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 escapesansi_ljust(s, width)-- left-justify to visible widthansi_rjust(s, width)-- right-justify to visible widthansi_center(s, width)-- center within visible widthansi_truncate(s, width)-- truncate at visible width, preserving ANSI codesansi_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
- On
__enter__, the cursor is hidden to prevent flicker - Each
update(**context)re-renders the template and overwrites the previous output using\033[A(cursor up) and\033[2K(erase line) - On
__exit__, the cursor is restored; Ctrl+C is handled gracefully - Context accumulates across
update()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:
NO_COLORset -->"none"(no colors)FORCE_COLORset -->"basic"(16 colors)COLORTERMistruecoloror24bit-->"truecolor"TERMcontains256color-->"256"- stdout is a TTY -->
"basic" - 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 (unless
FORCE_COLORis set) LiveRendererappends output instead of overwritingstream_to_terminal()writes immediately with no delaykida 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 for
Environment,Template, and filters
Terminal Rendering
Build rich terminal output with colors, components, and live updates
Terminal Rendering
Kida templates can render to the terminal with full ANSI color support, responsive layouts, and real-time animation. The same template language you use for HTML works for CLI tools — with a dedicated set of filters and components designed for terminal output.
Prerequisites
- Python 3.14+
- Kida installed
- A terminal that supports ANSI escape codes (almost all modern terminals)
Your First Terminal Render
The fastest way to get colored terminal output is theterminal_env() factory. It returns a standard Kida Environment pre-configured with autoescape="terminal"and all terminal filters registered.
from kida.terminal import terminal_env
env = terminal_env()
tpl = env.from_string('{{ "Hello" | bold | cyan }} from Kida!')
print(tpl.render())
Run this and you will see "Hello" printed in bold cyan, followed by unstyled text. The bold, cyan, and other style filters wrap their input in the appropriate ANSI escape sequences. When autoescape="terminal"is active, Kida handles the reset codes automatically so styles do not bleed into surrounding text.
You can also use the CLI to render a template file directly. Save a template asgreeting.txt:
{{ name | green | bold }}: {{ message | dim }}
Then render it from the command line:
kida render greeting.txt --mode terminal -v name=World -v message="Welcome to Kida"
The --mode terminal flag tells the CLI to use the terminal rendering environment. You can pass variables with -v key=value.
The core style filters available out of the box are:bold, dim, italic, underline, cyan, green, red, yellow, and bright_cyan. These compose naturally — chain them in any order and Kida merges the escape codes into a single sequence.
Colors and Graceful Degradation
Terminals vary widely in their color support. Kida recognizes four color depth levels and degrades gracefully when a template uses colors that the terminal cannot display.
| Level | Description | Example |
|---|---|---|
none |
No color output | CI logs, piped output |
basic |
16 ANSI colors | Older terminals |
256 |
256-color palette | Most modern terminals |
truecolor |
24-bit RGB | iTerm2, Windows Terminal, Kitty |
By default,terminal_env()auto-detects the terminal's color depth. You can override this explicitly:
from kida.terminal import terminal_env
# Force basic 16-color mode
env = terminal_env(terminal_color="basic")
tpl = env.from_string('{{ status | fg("#ff6600") | bold }}')
print(tpl.render(status="DEGRADED"))
When this template runs with terminal_color="basic", the hex color #ff6600is automatically mapped to the nearest basic ANSI color (yellow). No error, no missing output — the template still works, just with reduced fidelity.
For full control over custom colors, use thefg() and bg()filters. They accept both named colors and hex values:
env = terminal_env(terminal_color="truecolor")
tpl = env.from_string("""
{{ title | fg("#00d4aa") | bold }}
{{ subtitle | fg("yellow") | bg("#1a1a2e") }}
""")
print(tpl.render(title="Deploy Report", subtitle="v2.1.0 — production"))
Kida also respects the NO_COLOR and FORCE_COLOR environment variables. When NO_COLOR is set, all color output is suppressed regardless of the terminal_color setting. When FORCE_COLORis set, auto-detection is skipped and colors are always emitted. This follows the community convention at no-color.org.
:::note[Testing Tip]
Usekida render template.txt --mode terminal --color noneto see exactly what your template renders without any ANSI codes. This is useful for verifying layout and content before adding color.
:::
To test how your template looks at different color depths from the CLI:
# See output with only basic colors
kida render dashboard.txt --mode terminal --color basic
# See output with no colors at all
kida render dashboard.txt --mode terminal --color none
Layout and Built-in Components
Terminal output benefits from structure — headers, dividers, aligned key-value pairs, and status indicators. Kida provides filters and built-in globals for all of these.
Thehr() global renders a horizontal rule. The icons object provides common Unicode glyphs. The pad() filter pads a value to a fixed width, and kv()formats label-value pairs with aligned separators:
from kida.terminal import terminal_env
env = terminal_env(terminal_width=80)
tpl = env.from_string("""
{{ "Service Status" | bold | underline }}
{{ hr(80) }}
{% for svc in services %}
{{ svc.name | pad(20) | bold }} {{ svc.status | badge() }} {{ svc.latency | kv("latency", sep=": ") }}
{% end %}
{{ hr(80) }}
{{ summary | dim }}
""")
services = [
{"name": "api-gateway", "status": "healthy", "latency": "12ms"},
{"name": "auth-service", "status": "healthy", "latency": "8ms"},
{"name": "worker-pool", "status": "degraded", "latency": "340ms"},
]
print(tpl.render(services=services, summary="3 services checked"))
The badge() filter wraps a value in a colored pill-style label — "healthy" renders in green, "degraded" in yellow, "down" in red. The bar()filter draws a progress bar of a given width, useful for displaying percentages or resource usage:
{{ "CPU" | pad(8) }} {{ cpu_pct | bar(40) }} {{ cpu_pct }}%
{{ "Memory" | pad(8) }} {{ mem_pct | bar(40) }} {{ mem_pct }}%
The terminal_width parameter on terminal_env() controls the default width used by hr()and other width-aware components. If not specified, Kida reads the actual terminal width at render time.
Thetable() filter formats a list of dictionaries as an aligned table with headers. The tree() filter renders nested data as a tree with box-drawing characters. And syntax()applies syntax highlighting to code strings:
{{ "Dependencies" | bold }}
{{ deps | tree() }}
{{ "Recent Queries" | bold }}
{{ queries | table() }}
Icons are available through the icons global: icons.check (checkmark), icons.cross (X mark), icons.warn (warning triangle), and icons.gear (gear). Use box.roundfor rounded box-drawing characters. These degrade to ASCII equivalents when the terminal does not support Unicode.
LiveRenderer — Animated Output
For CLI tools that run multi-step operations, static output is not enough.LiveRendererre-renders a template in place, replacing the previous output on each update. This is how you build progress displays, deploy trackers, and monitoring dashboards.
LiveRenderer works as a context manager. Call update()to push new data and trigger a re-render:
import time
from kida.terminal import LiveRenderer, terminal_env
env = terminal_env()
tpl = env.from_string("""
{{ "Deploy Progress" | bold | cyan }}
{{ hr(60) }}
{% for step in steps %}
{% if step.status == "done" %}
{{ icons.check | green }} {{ step.name | pad(30) }} {{ step.duration }}
{% elif step.status == "running" %}
{{ spinner() }} {{ step.name | pad(30) | yellow }} ...
{% else %}
{{ " " }} {{ step.name | pad(30) | dim }}
{% end %}
{% end %}
{{ hr(60) }}
{{ elapsed | dim }}
""")
steps = [
{"name": "Pull image", "status": "pending", "duration": ""},
{"name": "Run migrations", "status": "pending", "duration": ""},
{"name": "Health check", "status": "pending", "duration": ""},
{"name": "Switch traffic", "status": "pending", "duration": ""},
]
with LiveRenderer(tpl, refresh_rate=0.1) as live:
for i, step in enumerate(steps):
steps[i]["status"] = "running"
live.update(steps=steps, elapsed=f"{i * 2}s elapsed")
time.sleep(2) # Simulate work
steps[i]["status"] = "done"
steps[i]["duration"] = "2.0s"
live.update(steps=steps, elapsed=f"{(i + 1) * 2}s elapsed")
print("Deploy complete!")
The LiveRendererconstructor accepts several options:
refresh_rate— minimum seconds between screen updates (default0.1). Prevents flickering whenupdate()is called rapidly.file— output file object (defaultsys.stderr). Useful for redirecting live output.transient— ifTrue, the live display is cleared when the context manager exits. Use this for progress indicators that should disappear when done.
Thespinner() global produces an animated spinner character that cycles on each re-render. It works automatically inside LiveRenderer— no extra threading needed.
For operations where you want continuous animation without explicitupdate() calls, use start_auto() and stop_auto():
with LiveRenderer(tpl) as live:
live.start_auto(status="Connecting...", progress=0)
# Spinner animates while we wait
result = long_running_operation()
live.stop_auto()
live.update(status="Complete", progress=100, result=result)
LiveRenderer is thread-safe. Each call to update()atomically swaps the render context, so background threads can push updates without locks. The renderer freezes the context snapshot before each render pass to avoid tearing.
Optimize with static_context
When building CLI tools, some template values are known at startup and never change — the application name, version string, feature flags, environment label. Passing these throughstatic_contextlets Kida fold them at compile time, eliminating branches and lookups at render time.
from kida.terminal import terminal_env
env = terminal_env()
TEMPLATE = """
{{ app_name | bold | cyan }} {{ version | dim }}
{{ hr(60) }}
{% if debug %}
{{ "DEBUG MODE" | bold | red }}
{{ hr(60) }}
{% end %}
{% for svc in services %}
{{ svc.name | pad(20) }} {{ svc.status | badge() }}
{% end %}
"""
# Values known at app startup — fold at compile time
static = {"app_name": "deployer", "version": "2.1.0", "debug": False}
tpl = env.from_string(TEMPLATE, name="dashboard", static_context=static)
# Only pass dynamic data at render time
print(tpl.render(services=[
{"name": "api", "status": "healthy"},
{"name": "worker", "status": "degraded"},
]))
:::note[How it works]
Withdebug: False in static_context, the entire {% if debug %}block is removed from the compiled template. Zero runtime cost for that branch — it does not exist in the compiled AST.
:::
Note thatstatic_context is only available on env.from_string(), not on env.get_template(). This is intentional — file-loaded templates are cached globally, but static context specializes a template for particular values.
Use the--explainflag to inspect what the compiler optimized:
kida render dashboard.txt --mode terminal --explain
The explain output shows the compiled AST and highlights which nodes were constant-folded. This is helpful for verifying that your static values are actually being used by the optimizer.
Good candidates forstatic_context:
- Application name and version
- Environment label (production, staging)
- Feature flags
- Terminal width (if you want a fixed layout)
- Configuration that does not change per render
Streaming Output
For templates that produce long output — log formatters, report generators, file listings — streaming renders chunks progressively instead of buffering the entire result.
from kida.terminal import terminal_env, stream_to_terminal
env = terminal_env()
tpl = env.from_string("""
{{ "Build Log" | bold | underline }}
{{ hr(60) }}
{% for entry in log_entries %}
{{ entry.timestamp | dim }} {{ entry.level | badge() }} {{ entry.message }}
{% end %}
""")
context = {"log_entries": build_log}
stream_to_terminal(tpl, context, delay=0.02)
The delay parameter controls the pause between chunks in seconds. Set it to 0for maximum speed, or increase it for a typewriter effect that is easier to follow visually.
From the CLI, use the--streamflag:
kida render build-log.txt --mode terminal --stream --stream-delay 0.05
When should you use streaming versus LiveRenderer? Streaming is for output that grows — each line appears and stays. LiveRenderer is for output that changes in place — the same region is re-rendered with updated data. Use streaming for logs and reports. Use LiveRendererfor dashboards and progress displays.
Complete Example — Service Dashboard
Here is a full working example that ties togetherterminal_env, static_context, LiveRenderer, colors, and components. Create the following file structure:
my-cli/
dashboard.py
templates/
dashboard.txt
templates/dashboard.txt:
{{ app_name | bold | cyan }} {{ version | dim }} {{ env_label | badge() }}
{{ hr(width) }}
{% for svc in services %}
{% if svc.status == "healthy" %}
{{ icons.check | green }} {{ svc.name | pad(20) | bold }} {{ svc.latency | kv("latency") }}
{% elif svc.status == "degraded" %}
{{ icons.warn | yellow }} {{ svc.name | pad(20) | bold }} {{ svc.latency | kv("latency") }}
{% else %}
{{ icons.cross | red }} {{ svc.name | pad(20) | bold }} {{ svc.latency | kv("latency") }}
{% end %}
{% end %}
{{ hr(width) }}
{{ "CPU" | pad(8) }} {{ cpu | bar(40) }} {{ cpu }}%
{{ "Mem" | pad(8) }} {{ mem | bar(40) }} {{ mem }}%
{{ hr(width) }}
{{ updated_at | dim }}
dashboard.py:
import time
import random
from kida import FileSystemLoader
from kida.terminal import LiveRenderer, terminal_env
env = terminal_env(loader=FileSystemLoader("templates/"), terminal_width=72)
static = {"app_name": "myapp", "version": "2.1.0", "env_label": "production", "width": 72}
tpl = env.from_string(
open("templates/dashboard.txt").read(),
name="dashboard",
static_context=static,
)
services = [
{"name": "api-gateway", "status": "healthy", "latency": "12ms"},
{"name": "auth-service", "status": "healthy", "latency": "8ms"},
{"name": "search-index", "status": "healthy", "latency": "45ms"},
{"name": "worker-pool", "status": "healthy", "latency": "120ms"},
]
with LiveRenderer(tpl, refresh_rate=0.5, transient=False) as live:
for tick in range(30):
# Simulate changing metrics
for svc in services:
latency = random.randint(5, 500)
svc["latency"] = f"{latency}ms"
svc["status"] = "healthy" if latency < 200 else "degraded" if latency < 400 else "down"
live.update(
services=services,
cpu=random.randint(10, 95),
mem=random.randint(30, 85),
updated_at=f"Updated {tick + 1}s ago",
)
time.sleep(1)
Run it with python dashboard.pyand watch the service statuses, latencies, and resource bars update in real time.
Next Steps
You now have the tools to build rich terminal output with Kida. Here are some directions to explore:
- Terminal Rendering — terminal filters, layout components, and rendering patterns
- Filter Reference — full list of built-in filters
- The
--explainflag for inspecting compiled templates and verifying optimizations examples/terminal_dashboard/andexamples/terminal_deploy/in the Kida repository for more patterns- Milo for building fully interactive terminal applications on top of Kida's rendering layer
Worker Auto-Tuning
Workload-aware parallelization for free-threaded Python
Worker Auto-Tuning
Kida provides a workload-aware worker pool toolkit for framework authors who need to parallelize template rendering. It is calibrated for free-threaded Python (3.14t) where CPU-bound rendering achieves true parallelism.
from kida.utils.workers import get_optimal_workers, should_parallelize
Quick Start
from concurrent.futures import ThreadPoolExecutor
from kida.utils.workers import get_optimal_workers, should_parallelize
contexts = [{"name": f"User {i}"} for i in range(100)]
if should_parallelize(len(contexts)):
workers = get_optimal_workers(len(contexts))
with ThreadPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(template.render, contexts))
else:
results = [template.render(**ctx) for ctx in contexts]
Core Functions
Import: Use
from kida.utils.workers import ...orfrom kida import get_optimal_workers, should_parallelize, ...
should_parallelize
Determine if parallelization is worthwhile. Thread pool overhead (~1-2ms per task) only pays off above a threshold.
from kida.utils.workers import should_parallelize
should_parallelize(5) # False — below threshold
should_parallelize(100) # True — above threshold
# With work size estimate (bytes of template output)
should_parallelize(100, total_work_estimate=500) # False — too small
get_optimal_workers
Calculate the optimal worker count based on workload type, environment, CPU cores, and free-threading status.
from kida.utils.workers import get_optimal_workers, WorkloadType
# Template rendering (default)
get_optimal_workers(100) # 4 (local, free-threading)
# Template compilation
get_optimal_workers(100, workload_type=WorkloadType.COMPILE) # 2
# Override auto-tuning
get_optimal_workers(100, config_override=16) # 16
# Weight heavy templates higher
get_optimal_workers(50, task_weight=2.0) # Adjusts for heavy work
Workload Types
| Type | Use Case | Parallelism |
|---|---|---|
WorkloadType.RENDER |
Template rendering (CPU-bound) | High — benefits from free-threading |
WorkloadType.COMPILE |
Template compilation (CPU-bound) | Moderate — shared cache limits scaling |
WorkloadType.IO_BOUND |
File loading, network | High — threads wait on I/O |
Environment Detection
The toolkit auto-detects the execution environment to tune worker counts:
| Environment | Detection | Worker Strategy |
|---|---|---|
| CI | CI, GITHUB_ACTIONS, etc. |
Conservative (2 workers max) |
| Local | Default | Moderate (up to 4 workers) |
| Production | KIDA_ENV=production |
Aggressive (up to 8 workers) |
Override detection with theKIDA_ENVenvironment variable:
export KIDA_ENV=production # or "ci" or "local"
Free-Threading Detection
The toolkit detects whether the GIL is disabled and scales worker counts accordingly:
from kida.utils.workers import is_free_threading_enabled
if is_free_threading_enabled():
print("GIL disabled — true parallelism available")
On free-threaded Python, render workloads get a 1.5x multiplier on the CPU-based worker count.
Template Scheduling
For optimal throughput, schedule heavy templates first to avoid the "straggler effect" where one slow render delays overall completion.
estimate_template_weight
Estimate relative complexity of a template:
from kida.utils.workers import estimate_template_weight
weight = estimate_template_weight(template)
# 1.0 = average, >1 = heavy, <1 = light (capped at 5.0)
Weight factors:
- Source size: +0.5 per 5KB above 5KB threshold
- Block count: +0.1 per block above 3
- Macro count: +0.2 per macro
- Inheritance: +0.5 if extends another template
- Includes: +0.15 per include statement
order_by_complexity
Sort templates for optimal parallel execution:
from kida.utils.workers import order_by_complexity
# Heavy templates first (default — best for parallel execution)
ordered = order_by_complexity(templates)
# Light templates first (useful for testing)
ordered = order_by_complexity(templates, descending=False)
Workload Profiles
Inspect the tuning parameters for any workload/environment combination:
from kida.utils.workers import get_profile, WorkloadType
profile = get_profile(WorkloadType.RENDER)
print(profile.parallel_threshold) # 10
print(profile.max_workers) # 4
print(profile.free_threading_multiplier) # 1.5
WorkloadProfile Fields
| Field | Type | Description |
|---|---|---|
parallel_threshold |
int |
Minimum tasks before parallelizing |
min_workers |
int |
Floor for worker count |
max_workers |
int |
Ceiling for worker count |
cpu_fraction |
float |
Fraction of cores to use (0.0-1.0) |
free_threading_multiplier |
float |
Extra scaling when GIL is disabled |
Complete Example
from concurrent.futures import ThreadPoolExecutor
from kida import Environment, FileSystemLoader
from kida.utils.workers import (
get_optimal_workers,
order_by_complexity,
should_parallelize,
WorkloadType,
)
env = Environment(loader=FileSystemLoader("templates/"))
# Load and schedule templates (requires a loader; FunctionLoader returns [])
templates = [env.get_template(name) for name in env.loader.list_templates()]
ordered = order_by_complexity(templates)
# Build render tasks
tasks = [(tmpl, {"page": page}) for tmpl, page in zip(ordered, pages, strict=True)]
if should_parallelize(len(tasks)):
workers = get_optimal_workers(
len(tasks),
workload_type=WorkloadType.RENDER,
)
with ThreadPoolExecutor(max_workers=workers) as pool:
results = list(pool.map(lambda t: t[0].render(**t[1]), tasks))
else:
results = [tmpl.render(**ctx) for tmpl, ctx in tasks]
See Also
- Thread Safety — Free-threading design
- Performance — Concurrent benchmark results
- Static Analysis — Template complexity analysis