Metadata-Version: 2.5
Name: tuiloom
Version: 0.8.0
Summary: Beautiful terminal interfaces, simple to build, easy to navigate.
Project-URL: Homepage, https://github.com/maroard/Tuiloom
Project-URL: Repository, https://github.com/maroard/Tuiloom
Project-URL: Issues, https://github.com/maroard/Tuiloom/issues
Author: maroard
License-Expression: MIT
License-File: LICENSE
Keywords: cli,interface,menu,python,terminal,tui
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Terminals
Requires-Python: >=3.12
Requires-Dist: blessed<2,>=1.48
Requires-Dist: wcwidth>=0.8
Description-Content-Type: text/markdown

# Tuiloom

[![PyPI](https://img.shields.io/pypi/v/tuiloom.svg)](https://pypi.org/project/tuiloom/)
[![Python](https://img.shields.io/pypi/pyversions/tuiloom.svg)](https://pypi.org/project/tuiloom/)
[![CI](https://github.com/maroard/Tuiloom/actions/workflows/ci.yml/badge.svg)](https://github.com/maroard/Tuiloom/actions/workflows/ci.yml)
[![License](https://img.shields.io/pypi/l/tuiloom.svg)](https://github.com/maroard/Tuiloom/blob/main/LICENSE)

Tuiloom builds typed, keyboard-navigable terminal menus with dynamic content,
Unicode-safe rendering, captured task output, alerts, and free-form input. It is
small enough to learn from one document while still handling the awkward parts
of terminal state and background-work shutdown.

This README documents the complete public API of Tuiloom 0.8.0. Tuiloom requires
Python 3.12 or newer and is tested on Linux and macOS with Python 3.12–3.14.

## Contents

- [Installation](#installation)
- [Quick start](#quick-start)
- [Core concepts](#core-concepts)
- [Navigation and focus](#navigation-and-focus)
- [Screen state and visibility](#screen-state-and-visibility)
- [Commands and submenus](#commands-and-submenus)
- [Screen content](#screen-content)
- [Status bar](#status-bar)
- [Captured task output](#captured-task-output)
- [Safe shutdown](#safe-shutdown)
- [Free-form and hidden input](#free-form-and-hidden-input)
- [Alerts](#alerts)
- [Messages](#messages)
- [Key bindings and global commands](#key-bindings-and-global-commands)
- [Text styling](#text-styling)
- [Terminal hyperlinks](#terminal-hyperlinks)
- [API reference](#api-reference)
- [Runtime constraints](#runtime-constraints)
- [Development](#development)

## Installation

Install the latest release from PyPI:

```bash
python -m pip install tuiloom
```

To install this version explicitly:

```bash
python -m pip install tuiloom==0.8.0
```

Tuiloom ships inline typing information through `py.typed` and has no required
framework or event-loop dependency.

## Quick start

```python
from tuiloom import (
    CommandContext,
    ScreenContent,
    ScreenContext,
    TerminalApp,
    TerminalMenu,
)

app = TerminalApp("Generator")
menu = TerminalMenu(
    app,
    ScreenContext(
        menu_name="main",
        title="Generation",
        text="Choose an operation",
        width=24,
    ),
)
status = menu.add_content_panel(ScreenContent.static("Ready"))


def generate(context: CommandContext) -> None:
    status.set_content(ScreenContent.static("Generated"))


menu.add_command("Generate", generate)
app.set_main_menu(menu)
app.run()
```

`TerminalApp.run()` is blocking. Call it from Python's main thread in a real
interactive terminal. It switches to the terminal's alternate screen and hides
the cursor while the application runs, then restores input, cursor, and screen
state before returning or propagating an exception.

## Core concepts

A Tuiloom application has three main layers:

1. `TerminalApp` owns application-wide configuration, global commands,
   messages, captured-output state, and the root menu.
2. `TerminalMenu` owns selectable commands, content panels, input, alerts,
   an optional status bar, local overrides, and its event loop.
3. `ScreenContext` is the mutable visible state of one menu: name, title,
   minimum width, descriptive text, and footer message.

Every menu belongs to exactly one application. A submenu and its parent must
belong to the same `TerminalApp`. While the application is running, the
navigation stack determines the automatic final row: the bottom menu displays
`Quit`, and every deeper menu displays `Back`. Registering another main menu
restores `Back` on the previous one until stack depth determines its runtime
label.

Callbacks receive a frozen `CommandContext` containing the active application,
menu, command handle, and triggering binding:

```python
from tuiloom import CommandContext


def inspect_invocation(context: CommandContext) -> None:
    context.menu.show_alert(
        f"Command: {context.command!r}\nBinding: {context.binding!r}"
    )
```

## Navigation and focus

The menu has focus initially. The default controls are:

| Key | Action |
| --- | --- |
| Tab | Cycle through the menu and each content panel |
| Up / Down | Move the selected command, or scroll focused content vertically |
| Left / Right | Scroll focused content horizontally |
| Enter | Activate the selected command or confirm an alert |
| Escape | Leave input mode, go Back, or request Quit |

Command selection loops and skips disabled commands. The automatic `Back` or
`Quit` row is always selectable and always follows user commands. The selected
row contains `>`, so it remains visible without ANSI color support.

Tab changes focus only when the menu has content. It cycles through
`menu -> panel 1 -> panel 2 -> ... -> menu`. Focused boxes use solid borders;
unfocused boxes use dotted borders. Moving upward in a panel with
`auto_scroll="smart"` suspends its automatic following, and reaching the bottom
enables it again.

All controls can be remapped through `KeyMap`; see
[Key bindings and global commands](#key-bindings-and-global-commands).

Navigation is owned by `TerminalApp`. Its centralized application loop drives
the runtimes of all opened menus without recursive `run()` calls. Start the
application with `app.run()` after registering a main menu, or pass an
application-owned entry menu to `app.run(entry_menu)`. In callbacks, transition
with the stack methods:

```python
def open_settings(context: CommandContext) -> None:
    context.app.push_menu(settings)


def open_dashboard(context: CommandContext) -> None:
    context.app.replace_menu(dashboard)


def return_home(context: CommandContext) -> None:
    context.app.reset_to(menu)
```

`push_menu()` adds a menu above the current one, `pop_menu()` returns to the
previous menu (or requests shutdown at the bottom), and `replace_menu()` swaps
only the current top menu. `reset_to()` truncates the stack after the requested
menu when it is already present; otherwise it replaces the entire stack with
that menu. Navigation targets must belong to the application, and a menu cannot
be pushed if it is already on the stack. Stack depth supplies `Quit` for the
bottom menu and `Back` above it unless `set_exit_label()` has assigned that menu
an explicit label; an explicit label always takes priority.

Menus that have not been opened remain lazy: pushing or otherwise scheduling a
menu does not initialize it until the application loop reaches it. Its runtime
and content workers are then initialized once per application run. Popping or
replacing the menu removes it from the visible stack but retains and services
that runtime in the background, including source processing and captured-task
completion callbacks. Reopening it resets transient navigation state without
restarting its workers. On graceful exit, `TerminalApp.run()` closes every
initialized menu runtime and joins its workers, including menus no longer on the
stack.

## Screen state and visibility

`ScreenContext` fields are live and mutable. Tuiloom observes changes while the
menu runs:

```python
menu.screen_context.title = "New title"
menu.screen_context.text = "Updated instructions"
menu.screen_context.message = "Saved"
menu.screen_context.width = 32
```

`width` is the minimum inner width. It must be a positive integer or `None`;
booleans are rejected. The menu grows to fit its text up to the terminal's inner
width. Text that still exceeds the available space wraps onto additional lines,
preferring word boundaries and splitting long words when necessary. Explicit
line breaks are preserved. Headings remain centered, and command continuations
align under their label with the selection marker on the first line only.

Set `strict_width=True` to keep the menu at exactly the specified inner `width`:

```python
menu.screen_context.width = 32
menu.screen_context.strict_width = True
```

`strict_width` defaults to `False` and has no effect when `width=None` (automatic
sizing). Both fields can be changed while the menu runs. Wrapping affects only
display; the original text and input buffer are retained. Content panels keep
using the available terminal width and their existing scrolling behavior.

The two border columns are additional: `width=32` needs at least 34 terminal
columns. Tuiloom still displays `Terminal window is too small.` when that minimum
cannot fit, or when the frame has insufficient height after wrapping. Automatic
sizing keeps a minimum inner width of four columns; an explicit strict width
may be smaller.

By default, a content box and menu box have one blank row between them. Pass
`content_spacing=False` to remove it.

Setting `menu.show = False` clears the entire frame while its event loop,
content sources, and tasks keep running. While hidden, only global commands and
the Back/Escape binding are handled; other input is discarded and cannot be
replayed when the menu becomes visible again.

## Commands and submenus

### Menu commands

`add_command()` returns a stable `MenuCommand` handle. Use the owning menu to
mutate it:

```python
def connect(context: CommandContext) -> None:
    context.menu.show_alert("Connected")


def disconnect(context: CommandContext) -> None:
    context.menu.clear_alert()


command = menu.add_command("Connect", connect)
menu.set_command_label(command, "Disconnect")
menu.set_command_behavior(command, disconnect)
menu.move_command(command, 0)
menu.disable_command(command)
menu.enable_command(command)
menu.set_exit_label("Close")
```

Positions are zero-based. `position=None` appends a command; an explicit valid
position inserts it there. Booleans, negative positions, and out-of-range
positions are rejected before mutation. `menu.commands` is an immutable tuple
view, and a handle from another menu is rejected.

The handle exposes read-only `label`, `behavior`, `position`, and `enabled`
properties. Their current values reflect mutations performed through the menu.

### Horizontal choices

Use `add_choice()` when a command should reveal several options on hover. The
callback receives a `ChoiceContext` with `app`, `menu`, `command`, `option`,
`index`, and the triggering `binding`. Hover callbacks receive the same context
for options; the command label's `on_hover` receives a `CommandContext`.

```python
from tuiloom import ChoiceOption

app.add_message("fast_help", "Run the simulation to completion.")
mode = menu.add_choice(
    "Simulation mode",
    [
        ChoiceOption("Fast", hover_message="fast_help"),
        ChoiceOption("Accurate", on_hover=lambda context: show_accurate_preview()),
        ChoiceOption("Custom", row=1),
    ],
    lambda context: apply_mode(context.option.label),
    rows=2,
    selected_index=0,
)
menu.set_choice_value(mode, 1)  # Update without calling on_select.
```

`ChoiceOption(label, *, row=0, on_hover=None, hover_message=None)` uses zero-based
declared rows. `hover_message` names a registered application message shown only
while the cursor is on that option. It overlays the persistent footer without
changing `ScreenContext.message` or `active_message_key`; the previous footer
reappears when the cursor leaves. Unknown message keys raise `KeyError` when
the choice is added. `on_hover` remains available for other actions.
Empty declared rows are skipped. A narrow terminal wraps between options, and
arrow navigation follows those visible lines. Right from the label enters the
options; Down moves to the next command. Enter from the label moves to the
active option. Moving the cursor previews an option without changing
`mode.value`. Enter on an option validates it and calls `on_select`, including
when it is already active. The `>` marker follows the cursor; `✓` appears after
the validated option's label. Option lines start two cells to the right of
command markers when the width permits. Use the owning menu's command mutation
methods to rename, move, disable, or delete a choice.

Every time a menu opens, its marker starts on the first enabled command from
the top. Disabled commands are skipped during initialization and navigation; if
all commands are disabled, the marker starts on the automatic `Back` or `Quit`
row.

### Submenus

```python
settings = TerminalMenu(
    app,
    ScreenContext("settings", "Settings", text="Configure the application"),
)
open_settings = menu.add_menu(settings, "Settings", position=0)
```

Activating the returned command pushes the submenu onto the application's
navigation stack. `add_menu()` is the normal convenience helper for a submenu:
it creates a command whose callback calls `context.app.push_menu(settings)`; it
does not start a recursive menu loop. It rejects a submenu owned by a different
application. Use an explicit command callback with `push_menu()`,
`replace_menu()`, or `reset_to()` when a different transition is needed. Do not
call `settings.run()` from a callback.

## Screen content

`ScreenContent` uses explicit factories for each production strategy:

```python
from collections.abc import Iterator
from tuiloom import ScreenContent


def stream() -> Iterator[str]:
    yield "one\n"
    yield "two\n"


def current_state() -> str | list[str]:
    return ["current", "state"]


panel = menu.add_content_panel(ScreenContent.static("one\ntwo"))
panel.set_content(ScreenContent.lines(["one", "two"]))
panel.set_content(ScreenContent.stream(stream()))
panel.set_description("Loading records")
panel.set_content(ScreenContent.dynamic(current_state))
panel.set_description("Refreshing state")
```

The factories behave differently:

| Factory | Behavior |
| --- | --- |
| `static(str)` | Fixed text split into display lines |
| `lines(list[str])` | An immutable copy of fixed display lines |
| `stream(Iterator[str])` | A worker consumes chunks until exhaustion |
| `dynamic(Callable[[], str \| list[str]])` | A worker evaluates the latest state up to 60 Hz |
| `responsive(Callable[[ContentSize], str \| list[str]])` | A worker renders for the panel's effective size |

Static content is normalized immediately. Iterator chunks may contain partial
lines and multiple newlines; carriage returns replace the unfinished line, which
supports progress-style output. Iterators must yield only strings. Dynamic
callables must return `str` or `list[str]` and have at most one evaluation in
flight.

Responsive content can declare `min_width` and `min_height`. Below either
minimum, the physical viewport remains the size of the terminal while the
renderer receives the larger virtual `ContentSize`; the result stays clipped
and scrollable. `refresh_mode="resize"` renders on first display, effective
size changes, and `panel.refresh()`. `refresh_mode="continuous"` additionally
renders up to 60 Hz.

Unsafe terminal controls are removed from rendered content. Unicode graphemes,
cell widths, safe SGR styling, tabs, and line boundaries are normalized for the
terminal.

### Multiple content panels

`add_content_panel()` creates an independently managed content box and returns
a stable `ContentPanel` handle:

```python
logs = menu.add_content_panel(
    ScreenContent.stream(log_stream),
    description="Downloading",
    auto_scroll="strict",
)
metrics = menu.add_content_panel(
    ScreenContent.dynamic(get_metrics),
    description="Indexing",
)

logs.set_content(ScreenContent.stream(new_log_stream))
logs.set_description("Downloading model")
logs.set_auto_scroll("smart")
metrics.move(0)
metrics.remove()
```

`menu.content_panels` exposes the handles in display order as an immutable
tuple. Visible panels are stacked vertically at equal height by default and
labeled when more than one is present. Each panel owns its source worker,
viewport, scroll position, and auto-scroll mode. Panels can be added, reordered,
replaced, or removed while the menu is running.

Every panel has the same capabilities; there is no primary panel. A menu with
one panel shows an unlabeled content box. Create panels with
`menu.add_content_panel()` and keep their handles to change content, labels,
auto-scroll, or layout.

### Content panel sizing

Each panel can set its share of visible vertical space:

```python
logs = menu.add_content_panel(
    ScreenContent.stream(log_stream),
    description="Logs",
    weight=3,
    min_height=4,
    max_height=30,
)
metrics = menu.add_content_panel(
    ScreenContent.dynamic(get_metrics),
    description="Metrics",
    weight=1,
    min_height=4,
)

# Configure another panel through its handle.
status = menu.add_content_panel(ScreenContent.static("Ready"))
status.set_layout(weight=2, min_height=3, max_height=10)

# Replace the complete layout; omitted options use their defaults.
logs.set_layout(weight=4, min_height=2, max_height=20)
logs.set_layout()  # Restore equal sharing without an upper limit.
```

`weight` defaults to `1` and accepts a finite positive integer or float.
`min_height` defaults to `1`; `max_height` defaults to `None` (no upper limit).
Minimum and maximum heights count visible content rows, excluding borders.
Supplied heights must be positive integers, and `max_height` must be at least
`min_height`. Booleans are rejected for all three options. Invalid creation or
layout updates raise `TypeError` or `ValueError` before changing panel state.

Weights target ratios on the **total** available panel height, including each
panel's two border rows and excluding the content/menu gap. For 23 rows,
weights `3` and `1` produce complete frames of `17` and `6` rows, containing
`15` and `4` visible content rows respectively. Bounds still apply to the inner
content: with 20 available frame rows, weights `1` and `3` and minimums of `4`
produce frames of `6` and `14` rows (content heights of `4` and `12`).
A panel that reaches a minimum or maximum keeps that
bound while the other panels share the remaining rows by weight. Fractional
rows are rounded down, then leftover rows go to the largest fractional
remainders, with ties resolved in display order. Defaults preserve the original
equal sharing, including giving leftover rows to the first panels.

If the minimum heights cannot all fit, Tuiloom displays
`Terminal window is too small.` If every panel reaches its maximum, unused
terminal rows remain below the frame. Layout changes preserve panel identity,
workers, focus, scroll positions and auto-scroll policy; viewport growth can
clamp an existing scroll offset to the new bottom. Replacing content or moving
a panel keeps its layout configuration.

Panel bounds size the physical viewport. The `min_height` on
`ScreenContent.responsive()` still specifies a **virtual** rendering minimum:
the callback receives the larger of the allocated height and that virtual
minimum, and its result remains clipped and scrollable. A layout change
refreshes responsive content when its effective `ContentSize` changes.

### Collapsible content panels

Panels start expanded. Collapse a panel to a fixed, smaller viewport while
keeping its content and runtime mounted:

```python
logs = menu.add_content_panel(
    ScreenContent.stream(log_stream),
    description="Logs",
    weight=3,
    min_height=4,
    max_height=30,
    collapsed_height=2,
)
logs.collapse()
assert logs.collapsed
logs.set_collapsed_height(1)
logs.expand()
logs.toggle_collapse()
```

`collapsed_height` defaults to `1` and counts visible content rows, excluding
the two border rows. It must be a positive integer; booleans are rejected.
Invalid creation or height updates raise `TypeError` or `ValueError` before
changing panel state. `collapse()` and `expand()` are idempotent;
`toggle_collapse()` switches the state. All mutation methods return `None`.

A collapsed panel reserves exactly `collapsed_height` content rows, independently
of its expanded `weight`, `min_height`, and `max_height`. Expanded panels share
the remaining frame rows using the usual weighted sizing. If the required
heights cannot fit, the existing terminal-too-small message is displayed. If
all panels are collapsed, unused terminal rows remain below the frame.

`expand()` recalculates the viewport from the current terminal size and expanded
sizing options. `set_layout()` changes only those expanded options, even while
collapsed; it never changes the collapse state or `collapsed_height`.

Collapsed panels remain visible, focusable, and scrollable. Collapsing or
expanding preserves the panel, renderer, viewport, worker, content buffer, and
auto-scroll policy. Streams and dynamic sources continue producing content.
Scroll offsets follow the existing viewport rules and may be clamped when the
viewport grows. Responsive content refreshes when its effective `ContentSize`
changes, still respecting its virtual rendering minimums and using the same
worker. No collapse shortcut is installed automatically; applications can bind
`toggle_collapse()` through their existing commands.

### Inherited and local content

```python
app = TerminalApp(
    "Monitor",
    global_content=ScreenContent.static("Shared status"),
)
menu = TerminalMenu(app, ScreenContext("logs", "Logs"))
shared = menu.content_panels[0]  # Automatically added from global_content.
logs = menu.add_content_panel(ScreenContent.static("Local status"))

shared.set_description("Shared status")
shared.set_layout(weight=1, min_height=2)
shared.set_content(ScreenContent.static("Updated status"))
# shared.remove()  # Remove it like any other panel.
```

Each menu captures `app.global_content` at construction. When it is present,
Tuiloom automatically adds an ordinary panel for that source. Retrieve its
handle through `menu.content_panels`; it can be changed, reordered, or removed
like any other panel. Explicitly added local panels are additional panels and
do not override inherited content.

When `global_content` is `None`, a new menu has no panels until one is added.
A menu without panels omits the content box without displaying an automatic
message. Applications can still show `MessageKey.NO_CONTENT_SOURCE` explicitly
with `show_message()`.

### Replacing active content

`panel.set_content(content)` replaces that panel's source and returns `None`.
Use `panel.set_description(description)` to change its label separately. If the
menu is running, Tuiloom installs the source through the active event loop.

Replacing an iterator or an in-flight dynamic evaluation requests cooperative
cancellation of the old source. The UI remains responsive, waits for the old
worker to stop, and then installs only the latest requested replacement. If
several replacements arrive during cleanup, the last request wins. Results and
errors from an explicitly cancelled source are discarded; ordinary source
errors still propagate after resources are cleaned up.

For cooperative iterator cancellation, Tuiloom calls an optional `cancel()`
method as soon as cancellation is requested and an optional `close()` method
when the consumer exits. A blocking iterator should implement `cancel()` so it
can wake or release its own `__next__()` call. A dynamic callable cannot be
interrupted in the middle of an evaluation; that call must return before its
worker can stop.

### Auto-scroll

Set each panel's `auto_scroll` when adding it or through its handle later:

```python
menu = TerminalMenu(app, ScreenContext("logs", "Logs"))
logs = menu.add_content_panel(
    ScreenContent.stream(stream()),
    auto_scroll="smart",
)

logs.set_auto_scroll("strict")
logs.set_auto_scroll(None)
logs.set_auto_scroll("smart")
```

- `"smart"` follows new iterator content until the user scrolls upward, then
  resumes when the viewport reaches the bottom.
- `"strict"` always follows the newest iterator content.
- `None` disables automatic following.

Changing a panel's mode or replacing its content resets its smart-scroll state.
Invalid modes raise `ValueError`.

### Migrating to panel-only content

The panel-only API is a breaking change: `TerminalMenu` no longer accepts
`content` or `auto_scroll`, and `menu.set_content()` and `menu.auto_scroll` have
been removed. Create a panel with `menu.add_content_panel(content, ...)`, keep
its handle, and use `panel.set_content()`, `panel.set_description()`,
`panel.set_auto_scroll()`, and `panel.set_layout()` for updates. The menu's
`content_spacing` and `show` constructor options are keyword-only.
`TerminalApp.global_content` remains supported; access its automatically added
panel through `menu.content_panels`.

## Status bar

A menu can display one persistent status line at the bottom of the terminal,
separate from its content panels and menu box:

```python
from tuiloom import StatusBar

menu.set_status_bar("READY │ [L] Logs │ [Q] Quit")
menu.set_status_bar(StatusBar.dynamic(lambda: f"RUNNING │ Turn {turn}/80"))


def render_status(width: int) -> str:
    if width < 65:
        return f"RUNNING │ {turn}/80 │ {delivered}/10 │ [L] Logs"
    return (
        f"RUNNING │ Turn {turn}/80 │ {delivered}/10 delivered"
        " │ [Space] Pause │ [L] Logs │ [Q] Quit"
    )


menu.set_status_bar(StatusBar.responsive(render_status))
menu.refresh_status_bar()  # state changed without a width change
menu.clear_status_bar()
```

The application owns `turn` and `delivered` in this example. Shortcut labels are
text only; register their behaviors separately as commands or global commands.
Tuiloom does not choose shorter labels or introduce application-specific logic.

| Factory | Evaluation |
| --- | --- |
| `StatusBar.static(str)` | Fixed text; a plain string passed to `set_status_bar()` is equivalent |
| `StatusBar.dynamic(Callable[[], str])` | On scheduled visible UI frames, up to 60 Hz |
| `StatusBar.responsive(Callable[[int], str])` | On first display, replacement, terminal width changes, or `menu.refresh_status_bar()` |

Status producers are lightweight renderers running synchronously in the UI
loop. They must read current state and return a string quickly, without I/O,
blocking calls, or expensive computation. Perform slow work in an application
task or worker, then update state for the renderer to read. No status worker is
created. Callback exceptions propagate through the normal terminal-restoration
path. Producers are not evaluated while the menu is hidden or covered by a
submenu. Responsive output is cached; use `refresh_status_bar()` when application
state changes at the same width. Height changes alone do not reevaluate it.

The status line reserves exactly one physical row before panel and menu layout.
It stays on the final terminal row even with no panels, capped panels, or all
panels collapsed; any unused rows above it are blank. It has no focus, scrolling,
borders, or virtual minimum size. The responsive width is the full available
terminal width in cells. An empty string still reserves the row.

Text is sanitized and clipped with the existing Unicode/ANSI utilities, without
wrapping or splitting graphemes. Tabs are expanded and newline/carriage-return
controls are removed by single-line normalization. Strings longer than the
terminal width are clipped; responsive callbacks can select their own shorter
representation. When the body cannot fit, a clipped terminal-too-small message
appears above the status; when only one row is available, only the status appears.
A terminal reporting zero rows or columns receives an empty frame.

Each menu owns its own status configuration, retained when returning from a
submenu. Changes take effect without recreating the menu. `ScreenContext.message`
continues to render independently inside the menu box. `menu.show = False`
clears the entire frame, including the status line. Without a status bar, frame
composition and sizing remain unchanged from v0.7.

## Captured task output

`run_with_output()` runs blocking Python work on a non-daemon worker and uses
its captured stdout/stderr in a temporary content panel:

```python
def download() -> str:
    print("Downloading…")
    return "archive.zip"


def start_download(context: CommandContext) -> None:
    context.menu.run_with_output(
        download,
        on_success=lambda path: context.menu.show_alert(f"Saved {path}"),
        on_error=lambda error: context.menu.show_alert(str(error)),
        description="Downloading archive",
    )


menu.add_command("Download", start_download)
```

The call itself starts the work and returns immediately. It is valid only while
the menu is active, and only one captured task may run in the application at a
time. A second task raises `RuntimeError`.

During the task, output appears in a temporary panel with strict auto-scroll;
the menu's other panels remain visible and unchanged. On normal completion,
Tuiloom removes the temporary panel after its output is consumed, then runs
`on_success(result)` or `on_error(exception)` on the UI thread.

Capture includes `print()` and Python writes to `sys.stdout` and `sys.stderr`.
It cannot capture subprocess output or direct POSIX file-descriptor writes.
Tuiloom does not inject a cancellation token into `action`. Wait and quit
therefore requires the action to return, while Force quit terminates the whole
process without waiting for the action.

## Safe shutdown

Quitting the root menu while one operation is active opens a normal menu titled
`Operation in progress`; multiple operations use `Operations in progress`:

```text
Force quit
Wait and quit
Cancel
```

- **Force quit** immediately restores the terminal and terminates the process
  with status zero because it is an explicitly handled user action. It does not
  wait for active Python threads or native calls, and it does not run their
  completion callbacks. Partial third-party writes, such as model cache
  downloads, may be resumed or cleaned up by that library on the next launch.
- **Wait and quit** lets work finish normally, animates its description, runs a
  captured task's completion callback, and exposes only a selectable `Cancel`
  row while waiting.
- **Cancel** restores the previous menu, focus, and selection. The configured
  Back action is equivalent to Cancel in both interactive exit states.

Navigate with the configured Up/Down actions and activate with Enter. The former
numeric shortcuts `1`, `2`, and `0` are not accepted.

Iterator sources count as active work until they finish. Dynamic sources count
as active only while an evaluation is in progress. Static content does not block
exit. Every active operation is displayed in its own labeled panel. The list is
live in every mode: completed panels disappear immediately, singular/plural
titles update, and newly started operations are included. The application exits
automatically when no blocking operation remains, even before a choice is made.

**Wait and quit** and ordinary shutdown use cooperative cancellation and wait
without a timeout before restoring the terminal. **Force quit** is the escape
hatch for native or application code that does not return: it restores terminal
modes and then terminates the whole process without waiting for workers.

The same guarantee applies when a callback, source, or renderer raises: Tuiloom
joins active workers before restoring the terminal and propagating the error.

## Free-form and hidden input

```python
def submit_password(value: str) -> None:
    if value:
        menu.leave_input_mode()
        menu.show_alert("Password received")


menu.enter_input_mode("Password: ", submit_password, hidden=True)
```

`enter_input_mode()` clears the previous buffer. Printable input is appended,
Backspace removes a complete Unicode grapheme, Enter calls the `InputBehavior`
with the full string, and Escape calls `leave_input_mode()` without submitting.
The callback decides whether input mode stays active after submission.

With `hidden=True`, one `*` is displayed per grapheme, including combining
characters and emoji sequences. The original Unicode text is passed to the
callback. Every global command is disabled while free-form input is active.

An alert temporarily suspends the prompt, buffer, hidden state, and input
callback without destroying them. Clearing the alert reveals the same input
state again.

## Alerts

A blocking alert has no confirmation prompt and Enter does not close it:

```python
menu.show_alert("Waiting for an external event")
menu.clear_alert()
```

A confirmable alert receives a `CommandContext`:

```python
menu.show_alert(
    "Saved",
    on_confirm=lambda context: status.set_content(ScreenContent.static("Ready")),
    prompt="Continue",
)
```

If `prompt` is omitted for a confirmable alert, Tuiloom uses
`Press Enter to continue`. The alert is cleared only after its callback returns
normally. If the callback raises, the alert remains and the exception
propagates. Alert confirmation has `context.command is None` and the Enter
binding in `context.binding`.

Alerts preserve the content box. Global commands remain active while an alert
is displayed, and Escape still performs Back/Quit.

## Messages

Messages occupy `ScreenContext.message`, the menu footer. Register custom
messages on the application, then show or suppress them by key:

```python
from tuiloom import MessageKey

app.add_message("connected", "Connected successfully")
menu.show_message("connected")  # True when displayed
menu.clear_message()

menu.disable_message("connected")  # suppress only in this menu
menu.enable_message("connected")
app.disable_message("connected")  # suppress in every menu
app.enable_message("connected")

menu.show_message(MessageKey.NO_CONTENT_SOURCE)
```

The built-in keys are:

| `MessageKey` | Value | Purpose |
| --- | --- | --- |
| `NO_CONTENT_SOURCE` | `"no_content_source"` | Explain missing content |
| `UNKNOWN_COMMAND` | `"unknown_command"` | Report discarded textual command input |
| `TASK_EXIT_CHOICES` | `"task_exit_choices"` | Compatibility key for the former numeric exit prompt |
| `TASK_WAITING` | `"task_waiting"` | Compatibility key for the former waiting footer |
| `TASK_STOPPING` | `"task_stopping"` | Compatibility key for the former stopping footer |

`show_message()` validates the key and returns `False` without changing the
footer when the message is suppressed. Otherwise it displays the message and
returns `True`. Local and application-wide suppression combine;
`is_message_enabled()` reports the effective state. Unknown keys raise
`KeyError`. Custom keys must be nonempty and unique, including against built-in
keys.

Automatic messages use the same registry and respect suppression.
`menu.active_message_key` is a read-only property identifying the registered
message currently displayed, or `None` for an empty or directly assigned footer.
Assigning `screen_context.message` directly resets that identity, even when the
text is unchanged. Messages with identical text retain distinct keys.

Register a message once during application setup, then toggle it from a callback:

```python
app.add_message("credit", "Created by maroard")

def credit(context: CommandContext) -> None:
    context.menu.toggle_message("credit")
```

`toggle_message(key)` returns `True` when the requested message is displayed,
or `False` when it is hidden or suppressed. An active message is cleared; another
message is replaced only if the requested message is enabled. Unknown keys raise
`KeyError` without changing the footer. Suppression does not retroactively clear
an active message, which can still be hidden by toggling it. Clearing does not
restore a previous message.

The three `TASK_*` compatibility keys remain available to application code but
no longer control Tuiloom's automatic task-exit menu.

## Key bindings and global commands

### Custom system bindings

```python
from tuiloom import KeyBinding, KeyMap, TerminalApp

keymap = KeyMap()
keymap.set_binding("focus", KeyBinding("f", ctrl=True))
app = TerminalApp("App", keymap=keymap)
```

The seven system actions are `focus`, `up`, `down`, `left`, `right`, `activate`,
and `back`. `keymap.bindings` is a read-only live mapping, and the same bindings
are available as `keymap.focus`, `keymap.up`, and so on. `action_for(binding)`
returns the matching action or `None`.

`set_binding()` rejects unknown actions, non-`KeyBinding` values, and collisions
with another system action or application global command. Validation happens
before mutation, so a failure leaves the previous binding unchanged.

`KeyBinding` accepts a nonempty key string or normalized special-key name and the
boolean modifiers `ctrl`, `alt`, and `shift`. These aliases normalize to the
canonical names:

| Alias | Canonical key |
| --- | --- |
| `return` | `enter` |
| `esc` | `escape` |
| `arrow_up` | `up` |
| `arrow_down` | `down` |
| `arrow_left` | `left` |
| `arrow_right` | `right` |

Multi-character names are lowercased. Terminal protocols cannot always report
every modifier distinctly: Ctrl+letter is commonly case-insensitive, and Shift
may arrive only as character case.

### Invisible global commands

```python
status = menu.add_content_panel(ScreenContent.static("Ready"))


def refresh(context: CommandContext) -> None:
    status.set_content(ScreenContent.static("Refreshed"))


refresh_command = app.add_global_command(
    KeyBinding("r", ctrl=True),
    "Refresh",
    refresh,
)

app.set_global_command_binding(refresh_command, KeyBinding("f5"))
app.set_global_command_label(refresh_command, "Reload")
app.set_global_command_behavior(refresh_command, refresh)
```

Global commands are invoked immediately when their binding arrives and are not
rendered as menu rows. `app.global_commands` is an immutable tuple of handles;
their read-only metadata can power a custom help screen.

A menu can override or disable an application global command locally:

```python
menu.set_global_command_behavior(refresh_command, refresh)
menu.disable_global_command(refresh_command)
menu.enable_global_command(refresh_command)
menu.clear_global_command_behavior(refresh_command)
```

Global-command handles belong to one application. Foreign handles are rejected.
Global commands remain available while a menu is hidden or an alert is shown,
but not during free-form input or a root task-exit choice.

Input priority is: task-exit choice, hidden-menu handling, free-form input,
global commands, alerts, then focus/navigation. Unknown terminal sequences are
consumed and do not block later input.

## Text styling

Use `style()` to combine terminal effects and foreground/background colors in
one safe string:

```python
from tuiloom import style

title = style("Important", bold=True)
warning = style(
    "Check this value",
    bold=True,
    underline=True,
    color="red",
    highlight="yellow",
)
```

The named colors are `black`, `red`, `green`, `yellow`, `blue`, `magenta`,
`cyan`, and `white`, plus their `bright_` variants. These use the terminal's
ANSI palette. The following additional names use fixed RGB values for both
`color` (text) and `highlight` (background):

| Name | RGB hexadecimal |
| --- | --- |
| `orange` | `#FFA500` |
| `gray` | `#808080` |
| `dark_red` | `#7F0000` |
| `dark_green` | `#007F00` |
| `dark_yellow` | `#7F7F00` |
| `dark_blue` | `#00007F` |
| `dark_magenta` | `#7F007F` |
| `dark_cyan` | `#007F7F` |
| `dark_orange` | `#7F5200` |
| `dark_gray` | `#404040` |

Dark variants halve RGB components, rounding down, using full-intensity RGB
primaries/secondaries as the reference for red through cyan. They do not derive
their values from the terminal's configurable ANSI palette.

Colors can also use an ANSI index, an RGB tuple, or a hexadecimal string:

```python
indexed = style("Indexed", color=202)
named = style("Warning", color="orange", highlight="dark_gray")
rgb = style("RGB", color=(120, 40, 210), highlight=(245, 245, 245))
hexadecimal = style("Hex", color="#7A28D2", highlight="#F5F5F5")
```

The available effects are `bold`, `dim`, `italic`, `underline`,
`strikethrough`, and `reverse`. Input is sanitized before styling: printable
Unicode, safe SGR sequences, newlines, tabs, and HTTP(S) terminal hyperlinks
are retained, while unsafe controls are removed. If no effect or color is
selected, `style()` returns only that sanitized text.

## Terminal hyperlinks

```python
from tuiloom import hyperlink, style

label = hyperlink(
    style("Project", underline=True, color="bright_blue"),
    "https://github.com/maroard/Tuiloom",
)
```

`hyperlink()` produces a complete OSC 8 hyperlink. It accepts only absolute
HTTP or HTTPS URLs with a network location. Empty URLs, whitespace, C0/C1
controls, Escape, and backslash are rejected with `ValueError`.

Visible text is sanitized: unsafe controls and nested OSC links are removed,
while printable Unicode and safe SGR color/style sequences are preserved.

## API reference

All supported imports come directly from `tuiloom`:

```python
from tuiloom import (
    AutoScrollMode,
    CommandBehavior,
    CommandContext,
    ContentPanel,
    ContentRefreshMode,
    ContentSize,
    GlobalCommand,
    InputBehavior,
    KeyBinding,
    KeyMap,
    MenuCommand,
    MessageKey,
    ScreenContent,
    ScreenContext,
    StatusBar,
    TerminalApp,
    TerminalMenu,
    TextColor,
    hyperlink,
    style,
)
```

Anything outside this export list is internal and may change without notice.

### Type aliases

```python
type ContentRefreshMode = Literal["resize", "continuous"]
type AutoScrollMode = Literal["smart", "strict"]
type CommandBehavior = Callable[[CommandContext], None]
type InputBehavior = Callable[[str], None]
type TextColor = str | int | tuple[int, int, int]
```

`AutoScrollMode | None` is used where automatic scrolling may be disabled.

`ContentSize` is a frozen dataclass with integer `width` and `height` fields.
`ScreenContent` is an immutable configuration created with `static()`,
`lines()`, `stream()`, `dynamic()`, or `responsive()`. Responsive minimums are
positive integers or `None`; its refresh mode is `"resize"` or `"continuous"`.

### `StatusBar`

An immutable current-state configuration created through named factories:

```text
StatusBar.static(text: str) -> StatusBar
StatusBar.dynamic(renderer: Callable[[], str]) -> StatusBar
StatusBar.responsive(renderer: Callable[[int], str]) -> StatusBar
```

Direct construction raises `TypeError`. `static()` requires a string; dynamic
and responsive factories require a callable. Producers must return a string;
invalid results raise `TypeError` during rendering. See [Status bar](#status-bar)
for evaluation, refresh, normalization, and layout behavior.

### `ScreenContext`

```text
ScreenContext(
    menu_name: str,
    title: str,
    width: int | None = None,
    text: str | None = None,
    message: str | None = None,
    strict_width: bool = False,
)
```

A mutable dataclass holding visible menu state:

- `menu_name`: internal name used in contextual messages;
- `title`: heading in the menu box;
- `width`: positive minimum inner width, or `None` for content-based sizing;
- `text`: optional description above commands;
- `message`: optional footer;
- `strict_width`: fix the inner width when `width` is specified; otherwise use
  automatic sizing. Defaults to `False`.

Construction and later assignment validate `width` and `strict_width`. Invalid
widths raise `ValueError`; non-boolean `strict_width` values raise `TypeError`.
Menu growth is bounded by the terminal, with overflowing text wrapped inside the
borders. The requested width plus two border columns must still fit.

### `KeyBinding`

```text
KeyBinding(
    key: str,
    ctrl: bool = False,
    alt: bool = False,
    shift: bool = False,
)
```

A frozen, hashable binding value. `key` must be a nonempty string or
construction raises `ValueError`; every modifier must be `bool` or construction
raises `TypeError`. Special aliases and multi-character normalization are
described above.

### `KeyMap`

```python
KeyMap()
```

- `bindings` → `Mapping[str, KeyBinding]`: read-only live action mapping.
- `focus`, `up`, `down`, `left`, `right`, `activate`, `back -> KeyBinding`:
  current bindings exposed as dynamic read-only properties.
- `set_binding(action: str, binding: KeyBinding) -> None`: atomically replace a
  system binding. Raises `KeyError` for an unknown action, `TypeError` for a
  non-binding, or `ValueError` for a collision.
- `action_for(binding: KeyBinding) -> str | None`: return the matching system
  action.

### `CommandContext`

```text
CommandContext(
    app: TerminalApp,
    menu: TerminalMenu,
    command: MenuCommand | GlobalCommand | None,
    binding: KeyBinding | None,
)
```

A frozen dataclass created by Tuiloom for callbacks. `command` is `None` for
alert confirmation. `binding` may be `None` for programmatic execution.

`ChoiceContext` is the corresponding frozen context for option hover and
selection. It adds `option: ChoiceOption` and `index: int`, and its `command`
is always the owning `MenuChoice`. `ChoiceBehavior` is a callback accepting
this context. `MenuChoice` extends `MenuCommand` with read-only `options`,
`rows`, `selected_index`, and the validated label in `value`.

### `MenuCommand`

```text
MenuCommand(
    menu: TerminalMenu,
    label: str,
    behavior: CommandBehavior,
)
```

Applications normally obtain this stable handle from `add_command()` or
`add_menu()` instead of constructing it directly. Its properties are read-only:

- `label -> str`;
- `behavior -> CommandBehavior`;
- `position -> int`, zero-based among user commands;
- `enabled -> bool`.

Use the owning menu's `set_command_*`, `move_command`, `disable_command`, and
`enable_command` methods to mutate it.

### `GlobalCommand`

```text
GlobalCommand(
    app: TerminalApp,
    binding: KeyBinding,
    label: str,
    behavior: CommandBehavior,
)
```

Applications normally obtain this handle from `add_global_command()`. Its
read-only properties are `binding`, `label`, and `behavior`. Use the owning
application's `set_global_command_*` methods for application-wide mutation, or a
menu's global-command methods for local behavior and enablement.

### `MessageKey`

`MessageKey` is a `StrEnum` with `NO_CONTENT_SOURCE`, `UNKNOWN_COMMAND`,
`TASK_EXIT_CHOICES`, `TASK_WAITING`, and `TASK_STOPPING`. The values and purpose
of each member are listed in [Messages](#messages). Enum members can be passed
where a message key string is accepted.

The three task-related members are retained for compatibility. The automatic
safe-shutdown interface is rendered as a temporary menu and does not read these
message values.

### `ContentPanel`

Applications obtain `ContentPanel` handles from
`TerminalMenu.add_content_panel()` or `menu.content_panels` rather than
constructing them directly. All panels, including automatically inherited
content, support the same methods.

Read-only properties:

- `content -> ScreenContent`: current mounted production configuration;
- `description -> str`: current visible and shutdown label;
- `position -> int`: current zero-based display position;
- `auto_scroll -> AutoScrollMode | None`: independent iterator-follow policy;
- `weight -> float`: positive relative share of total panel height, including borders;
- `min_height -> int`: minimum visible content rows, default `1`;
- `max_height -> int | None`: maximum expanded visible content rows, default `None`;
- `collapsed -> bool`: whether the panel uses its fixed collapsed height, default `False`;
- `collapsed_height -> int`: collapsed visible content rows, default `1`.

Explicit mutation methods:

```text
set_content(content: ScreenContent) -> None
refresh() -> None
set_description(description: str) -> None
set_auto_scroll(mode: AutoScrollMode | None) -> None
collapse() -> None
expand() -> None
toggle_collapse() -> None
set_collapsed_height(height: int) -> None
set_layout(
    *,
    weight: float = 1,
    min_height: int = 1,
    max_height: int | None = None,
) -> None
move(position: int) -> None
remove() -> None
```

The handle keeps its identity across content, label, mode, layout and position
changes.
`set_layout()` replaces the expanded sizing configuration; omitted arguments
reset to their defaults. It leaves `collapsed` and `collapsed_height` unchanged.
`collapse()` and `expand()` are idempotent; `toggle_collapse()` switches the state.
`set_collapsed_height()` sets a positive integer content height independently of
the expanded bounds. See [Collapsible content panels](#collapsible-content-panels)
for allocation and runtime behavior. These methods can be called before or
during application execution.
`refresh()` forces a responsive evaluation and raises `RuntimeError` for other
content variants. Calling a mutation method after removal raises `ValueError`.

### `TerminalApp`

```text
TerminalApp(
    name: str,
    global_content: ScreenContent | None = None,
    *,
    keymap: KeyMap | None = None,
)
```

The constructor stores the display name, optional content inherited by every
menu at construction as an ordinary panel, and an optional custom system key
map. Each menu captures the global configuration when it is created.

Read-only properties:

- `name -> str`: application name displayed by every menu;
- `global_content -> ScreenContent | None`: content inherited at menu
  construction;
- `keymap -> KeyMap`: configurable system key map;
- `global_commands -> tuple[GlobalCommand, ...]`: immutable ordered handle view;
- `main_menu -> TerminalMenu | None`: registered root menu.

Methods:

```text
set_main_menu(menu: TerminalMenu) -> None
```

Register an application-owned root menu and update automatic exit labels.
Raises `ValueError` for a foreign menu.

```text
add_global_command(
    binding: KeyBinding,
    label: str,
    behavior: CommandBehavior,
) -> GlobalCommand
```

Register an invisible application command. Raises `TypeError` for a non-binding
and `ValueError` when the binding collides with a system or global command.

```text
set_global_command_binding(
    command: GlobalCommand,
    binding: KeyBinding,
) -> None
set_global_command_label(command: GlobalCommand, label: str) -> None
set_global_command_behavior(
    command: GlobalCommand,
    behavior: CommandBehavior,
) -> None
```

Mutate an owned global handle. Binding replacement is validated atomically.
Foreign handles raise `ValueError`.

```text
add_message(key: str, text: str) -> None
disable_message(key: str) -> None
enable_message(key: str) -> None
```

Register or globally suppress messages. `add_message()` raises `ValueError` for
an empty or duplicate key; enable/disable raise `KeyError` for unknown keys.

```text
push_menu(menu: TerminalMenu) -> None
pop_menu() -> None
replace_menu(menu: TerminalMenu) -> None
reset_to(menu: TerminalMenu) -> None
run(entry_menu: TerminalMenu | None = None) -> None
```

`push_menu()` adds an application-owned menu to the top of the stack and rejects
a menu already present there. `pop_menu()` removes the top menu when the stack
has multiple entries; at the bottom it requests application shutdown.
`replace_menu()` swaps only the top entry, or creates the first entry when the
stack is empty. `reset_to()` truncates the stack through an existing menu, or
replaces the whole stack when the menu is absent. Foreign navigation targets
raise `ValueError`; invalid simultaneous duplicates are rejected.

`run()` starts with `entry_menu` when supplied, otherwise with the registered
main menu. It raises `RuntimeError` if neither is available or if called outside
Python's main thread, and raises `ValueError` for a foreign entry menu. The call
blocks until the application exits, joins workers, restores terminal state, and
then propagates any pending error. Each run starts with a fresh navigation
stack. The bottom entry displays Quit and deeper entries display Back regardless
of which menu was registered as the main menu, except that a label assigned with
`set_exit_label()` always takes priority.

### `TerminalMenu`

```text
TerminalMenu(
    app: TerminalApp,
    screen_context: ScreenContext,
    *,
    content_spacing: bool = True,
    show: bool = True,
)
```

Create a menu owned by `app`. When `app.global_content` is present, construction
automatically adds an ordinary panel for it, available through `content_panels`.
Otherwise the menu starts without panels. Add explicit panels with
`add_content_panel()`; they are additional to any inherited panel.
`content_spacing` and `show` must be booleans and are keyword-only.

Properties:

- `app -> TerminalApp`: read-only owner;
- `screen_context -> ScreenContext`: read-only reference to mutable display
  state;
- `commands -> tuple[MenuCommand, ...]`: immutable ordered handle view;
- `is_main -> bool`: whether this is the registered root;
- `show -> bool`: readable and writable visibility state;
- `content_panels -> tuple[ContentPanel, ...]`: immutable ordered panel-handle
  view;
- `status_bar -> StatusBar | None`: read-only optional status configuration.

#### Status bar methods

```text
set_status_bar(content: str | StatusBar) -> None
clear_status_bar() -> None
refresh_status_bar() -> None
```

Install or replace the status configuration with `set_status_bar()`; a string
is shorthand for `StatusBar.static()`. Other types raise `TypeError` without
changing the current configuration. `clear_status_bar()` is idempotent and
returns the reserved row to the layout. `refresh_status_bar()` requests a new
responsive result on the next visible frame; it raises `RuntimeError` if there
is no status or its kind is static or dynamic. These methods preserve panels,
focus, scroll state, and messages.

#### Command methods

```text
add_command(
    label: str,
    behavior: CommandBehavior,
    *,
    on_hover: CommandBehavior | None = None,
    position: int | None = None,
) -> MenuCommand
add_choice(
    label: str,
    options: list[ChoiceOption] | tuple[ChoiceOption, ...],
    on_select: ChoiceBehavior,
    *,
    rows: int = 1,
    selected_index: int = 0,
    on_hover: CommandBehavior | None = None,
    position: int | None = None,
) -> MenuChoice
set_choice_value(choice: MenuChoice, selected_index: int) -> None
add_menu(
    submenu: TerminalMenu,
    label: str,
    *,
    on_hover: CommandBehavior | None = None,
    position: int | None = None,
) -> MenuCommand
delete_command(command: MenuCommand) -> None
```

Add a command or application-owned submenu and return its stable handle.
Invalid positions raise `TypeError` or `ValueError`; foreign submenus raise
`ValueError`. `add_menu()` installs a normal command that pushes `submenu` onto
the application's navigation stack; it never calls `TerminalMenu.run()`.

`delete_command()` atomically removes an owned command. The deleted handle is
immediately invalid: subsequent menu mutations, another deletion, and access to
its position raise `ValueError`. Handles from another menu and objects that are
not live command handles are also rejected. If the deleted command was
selected, selection moves to the next enabled command, then the previous enabled
command, and finally the automatic Back/Quit row. Deleting any other command
preserves the selected handle. A command callback may safely delete its own
handle; the same selection rules apply before activation returns.

```text
set_command_label(command: MenuCommand, label: str) -> None
set_command_behavior(
    command: MenuCommand,
    behavior: CommandBehavior,
) -> None
move_command(command: MenuCommand, position: int) -> None
disable_command(command: MenuCommand) -> None
enable_command(command: MenuCommand) -> None
set_exit_label(label: str) -> None
```

Mutate owned menu commands or the automatic Back/Quit label. `move_command()`
requires an existing zero-based position. Foreign handles raise `ValueError`.

#### Global-command methods

```text
set_global_command_behavior(
    command: GlobalCommand,
    behavior: CommandBehavior,
) -> None
clear_global_command_behavior(command: GlobalCommand) -> None
disable_global_command(command: GlobalCommand) -> None
enable_global_command(command: GlobalCommand) -> None
```

Override, restore, disable, or enable an application global command in this menu
only. Foreign handles raise `ValueError`.

#### Content and task methods

```text
add_content_panel(
    content: ScreenContent,
    *,
    description: str = "Content in progress",
    auto_scroll: AutoScrollMode | None = None,
    position: int | None = None,
    weight: float = 1,
    min_height: int = 1,
    max_height: int | None = None,
    collapsed_height: int = 1,
) -> ContentPanel
```

Add an independently rendered panel and return its stable handle. `position`
is zero-based; invalid positions or auto-scroll modes raise `TypeError` or
`ValueError`. Sizing options are validated before insertion; see
[Content panel sizing](#content-panel-sizing) for allocation and bounds.
`collapsed_height` sets the fixed content row count used after `collapse()`;
panels start expanded. See [Collapsible content panels](#collapsible-content-panels).

Change a panel through its `ContentPanel` handle. See
[Replacing active content](#replacing-active-content) for source replacement
semantics.

```text
run_with_output[T](
    action: Callable[[], T],
    *,
    on_success: Callable[[T], None],
    on_error: Callable[[Exception], None],
    description: str = "Task in progress",
) -> None
```

Start one captured application task. Raises `RuntimeError` outside an active
menu or when another task is running. Completion callbacks run on the UI thread.

#### Input and alert methods

```text
enter_input_mode(
    prompt: str,
    behavior: InputBehavior,
    *,
    hidden: bool = False,
) -> None
leave_input_mode() -> None
```

Start a fresh input buffer or clear all input state.

```text
show_alert(
    text: str,
    *,
    on_confirm: CommandBehavior | None = None,
    prompt: str | None = None,
) -> None
clear_alert() -> None
```

Show a blocking/confirmable alert or clear it and reveal suspended input state.

#### Message methods

```text
show_message(key: str) -> bool
toggle_message(key: str) -> bool
active_message_key: str | None  # read-only property
clear_message() -> None
disable_message(key: str) -> None
enable_message(key: str) -> None
is_message_enabled(key: str) -> bool
```

Show, clear, locally suppress, or inspect registered messages. Every keyed
operation validates the key. Effective enablement combines local and global
suppression.

#### Lifecycle methods

```text
run() -> None
stop() -> None
```

`run()` is a transitional compatibility method and emits `DeprecationWarning`.
When an application is already running it delegates to `app.push_menu(self)` and
does not start a nested event loop. New code should use `TerminalApp.run()` as
the application entry point and `context.app.push_menu()` or `add_menu()` in
callbacks; do not call `TerminalMenu.run()` from callbacks. Calling the
compatibility method without an application lifecycle raises `RuntimeError`.

`stop()` performs the current menu's Back/Quit action. At stack depth greater
than one it pops the menu. At the bottom it requests shutdown and presents safe
shutdown choices when background work is active; otherwise it stops
immediately.

### `style`

```text
style(
    text: str,
    *,
    bold: bool = False,
    dim: bool = False,
    italic: bool = False,
    underline: bool = False,
    strikethrough: bool = False,
    reverse: bool = False,
    color: TextColor | None = None,
    highlight: TextColor | None = None,
) -> str
```

Return sanitized text wrapped in one ANSI SGR opening sequence and targeted
resets for the selected categories. Named colors, ANSI indexes from 0 to 255,
RGB tuples with components from 0 to 255, and strict `#RRGGBB` strings are
accepted. Invalid types raise `TypeError`; unknown names, malformed hexadecimal
strings, and out-of-range numbers raise `ValueError`.

### `display_width`

```python
from tuiloom import display_width, style

display_width(style("●", color="green"))  # 1
display_width("e\u0301")  # 1: combining accent
display_width("界")  # 2: wide character
```

`display_width(text: str) -> int` measures terminal columns rather than Python
string length. It uses the same text sanitization and width calculation as
Tuiloom's renderer: supported ANSI styles and hyperlinks do not add width,
Unicode graphemes retain their terminal width, and tabs use eight-column stops.
Use it to measure a rendered line, for example when validating a canvas cell.

### `hyperlink`

```text
hyperlink(text: str, url: str) -> str
```

Return sanitized visible text wrapped in a complete OSC 8 open/close pair.
Unsafe or non-HTTP(S) URLs raise `ValueError`.

## Runtime constraints

- `TerminalApp.run()` is blocking, main-thread-only, and requires an interactive
  terminal.
- Popped and replaced menu runtimes continue servicing their content workers
  until the application closes them when `TerminalApp.run()` exits.
- Only one `run_with_output()` task can run per application.
- Python stdout/stderr capture does not include subprocess or direct
  file-descriptor output.
- Normal shutdown joins non-daemon content and task workers before returning.
  Force quit instead restores terminal modes and terminates the process without
  joining workers.
- Terminal protocols may collapse modifier combinations, so not every theoretical
  `KeyBinding` is distinguishable on every terminal.
- Rendered content is Unicode-cell-aware and sanitizes unsafe terminal control
  sequences, but application callbacks remain responsible for their own domain
  errors and side effects.

## Development

Clone the repository and install the locked development environment:

```bash
git clone https://github.com/maroard/Tuiloom.git
cd Tuiloom
make install
```

Available checks:

```bash
make check       # Ruff lint/format check, strict MyPy, tests, and coverage
make fix         # apply Ruff formatting and safe lint fixes
make build       # build wheel/sdist and validate both with Twine
```

CI runs on Linux and macOS with Python 3.12, 3.13, and 3.14. It verifies typing,
tests, at least 90% branch-aware coverage, distributions, package metadata,
`py.typed`, licensing, and installation of the built wheel in a clean
environment.

Tuiloom is released under the [MIT License](LICENSE). Report defects and request
features through [GitHub Issues](https://github.com/maroard/Tuiloom/issues).
