add_renderable
add_renderable mounts any Rich renderable inside ask-shell's global Live region and returns a RemoveLivePart callable to detach it. Use it for status that should stay pinned while shell runs progress, instead of print_to_live (which lives in scrollback) or new_task (which renders a rich.progress.Progress bar).
The live region is a sorted Group of all attached parts. Each part has a name (used for stable sort and removal) and an order (vertical placement; shell-run progress uses -100, so any non-negative order stays below it).
Dynamic renderable via __rich_console__
The cheapest way to get live updates is to implement __rich_console__ on a small class that reads mutable state. Rich's auto-refresh thread re-invokes the method ~4 times per second while the live region is up, so updates from any thread show without calling a refresh helper yourself.
from io import StringIO
from rich.console import Console
from rich.text import Text
class JobStatus:
def __init__(self) -> None:
self.done = 0
self.in_flight = 0
self.elapsed_s = 0.0
def __rich_console__(self, console, options):
yield Text(f"refresh: {self.done} complete, {self.in_flight} in progress ({self.elapsed_s:.0f}s)")
status = JobStatus()
status.done = 5
status.in_flight = 3
status.elapsed_s = 12.0
probe = Console(file=StringIO(), width=80, color_system=None, legacy_windows=False)
probe.print(status)
print(probe.file.getvalue().strip())
#> refresh: 5 complete, 3 in progress (12s)
The example renders against a captured console so it can be tested without a real terminal. In production code you would pass the same instance to add_renderable (see below).
Wiring it into the live region
Pass the instance once, then mutate its fields from any thread. The 4 Hz auto-refresh picks up the new state. Always release the part in a finally so a crash does not leave the live region pinned.
```python test="skip" from ask_shell.console import add_renderable
class JobStatus: ... # as above
status = JobStatus() remove = add_renderable(status, name="job-status", order=10) try: # mutate status.done / status.in_flight from your event loop; # Rich re-renders automatically on its 4 Hz tick ... finally: remove(print_after_removing=True)
`remove(print_after_removing=True)` flushes one final render into scrollback before detaching, which is useful for converting a transient status into a permanent log entry.
## Apply-live demo (`terraform apply -json`)
The `apply-live` CLI demonstrates the same pattern tfdo uses for streaming Terraform output: a Typer app with [`configure_logging`](../../console/index.md#configure_logging_def), a shell run with `skip_progress_output=True`, and an NDJSON handler wired through `message_callbacks`.
The demo lives at `examples/apply_live/`:
- `workspace/` holds four chained `time_sleep` resources (`create_duration = "5s"`, each depends on the previous). A fresh `terraform apply -json -auto-approve` takes ~20s as resources are created one after another.
- The CLI always runs `terraform init -input=false` first so a clean checkout works in CI without a separate init step.
- Re-run after the first apply is a no-op and finishes quickly. Run `terraform destroy -auto-approve` in `workspace/` to reset state.
Run from the ask-shell repo (`code/ask-shell/`):
```sh
just apply-live
Locally you should see a pinned refresh · N done · M running · … line above the shell progress row. In CI the panel is invisible; the handler emits logger.info heartbeats every five seconds instead:
The handler is a minimal copy of tfdo's plan stream path (no tfdo import):
```python test="skip" from pathlib import Path
from ask_shell.shell import run_and_wait from apply_live.stream_handler import PlanStreamHandler, plan_stream_callback
handler = PlanStreamHandler() callback = plan_stream_callback(handler) workspace = Path("code/ask-shell/examples/apply_live/workspace") run = run_and_wait( "terraform apply -json -auto-approve", cwd=workspace, allow_non_zero_exit=True, skip_progress_output=True, message_callbacks=[callback], ) handler.flush() print(run.exit_code)
> 0
```
When to pick which API
add_renderable: dynamic status that must stay visible while shell commands and tasks update. Disappears with the live region when no parts remain (transient=True).print_to_live/log_to_live: one-shot lines that belong in scrollback (warnings, phase changes, diagnostics).new_task: discrete units of work with a progress bar, elapsed timer, and an automaticINFOlog on completion.
Mixing them is normal. A typical pattern keeps a add_renderable panel for live counters, calls print_to_live once per diagnostic, and lets ask-shell's own _RunState manage the per-process new_task.
CI and non-TTY rendering
interactive_shell() returns False when any of these are true:
CI=truein the environment.TERMisdumborunknown.stdoutis not a TTY.- The process runs under pytest.
- The process runs inside a container.
When the console is not interactive, Rich's Live.process_renderables does not append the live render and Live.refresh is a no-op on non-terminals. That means add_renderable content is invisible in CI logs. What still reaches the log stream:
print_to_live(...)andlog_to_live(...)write throughconsole.print()directly to stdout, no live overlay involved.logger.info(...)wired byconfigure_logging(...)emits throughRichHandleronto the same console.new_taskcompletion:log_task_donecallslogger.info, so theRunning: '...' completed in Nsline appears even in CI.
Avoiding frozen GitHub Actions logs
GitHub Actions streams a step's log by line. A long shell command whose only signal is an add_renderable panel produces no stdout, so the step appears stalled until it exits. Mitigations, in order of impact:
- Set
PYTHONUNBUFFERED=1in the workflowenv. Without it, Python falls back to block buffering when stdout is not a TTY and short bursts of output may sit in the buffer for minutes. - Terminal width in CI: when
interactive_shell()is false, ask-shell usesASK_SHELL_TERMINAL_WIDTHandASK_SHELL_TERMINAL_HEIGHT(defaults120and40) for the shared Rich console. Rich ignores a lonewidth; both must be set. - Mirror the dynamic state into
print_to_liveorlogger.infoon a heartbeat (every few seconds). The renderable still drives the local UX; the heartbeat keeps the CI stream alive. The apply-live demo useslogger.info("refresh: N complete, M in progress (…)")wheninteractive_shell()is false. - Set
log_updates=Trueonnew_taskwhen you want everytask.update(...)call to emit a log line. - Set
include_log_time=TrueonShellConfigso each captured shell line carries a[hh:mm:ss]prefix. Stalls become easier to spot in CI output. - Pass
skip_progress_output=Truetorun_and_waitfor verbose commands liketerraform plan -json. The shell-run progress collapses to"...", which keeps the live region short on local runs and stops Rich's cursor positioning from drifting out of the viewport.
To reproduce CI semantics locally without pushing a workflow: CI=true just apply-live from code/ask-shell/. interactive_shell() flips to False and the rendering path matches the GitHub runner.