Build polished, transcript-style terminal interfaces for chat / agent / REPL-ish apps in 20 lines of Python.
Project description
xli
Build polished, transcript-style terminal UIs for chat, agent, and REPL apps โ in ~20 lines of Python. โจ
import xli
ui = xli.UI(title="echo")
@ui.on_prompt
async def reply(prompt: str) -> None:
with ui.streaming("assistant") as out:
for token in f"You said: {prompt}".split():
out.write(token + " ")
ui.run()
That little snippet gets you: markdown-rendered streaming responses, slash-command
autocomplete, @file mentions, multi-line input (Enter sends, Alt+Enter for newlines),
persistent history, a themed status bar, arrow-selectable prompts, inline approvals โ and a
terminal that feels right. ๐ช
And the best part: the transcript flows into your terminal's normal scrollback, so text stays selectable, scrollable, and searchable with your terminal's own tools. xli doesn't take over the screen.
โจ Highlights
- ๐ Real scrollback, not a screen takeover. Finalized output is printed into your terminal's native scrollback โ select, scroll, and find all work the way they always do.
- ๐ Streaming markdown that appears token-by-token, then settles into properly-rendered text.
- ๐ Mutable cards. A tool call flips from
runningโdonein place; a plan's checkboxes tick off live โ via a handle you hold and update from anywhere. - โจ๏ธ A real composer. Multi-line input, slash-command autocomplete,
@filementions, history, and paste handling out of the box. - โ Inline approvals, pickers & wizards โ arrow-selectable, no modal screen-takeover, and they block your agent until the user answers.
- โธ๏ธ Type-ahead & ESC-to-interrupt while the agent is working, with cooperative
asynciocancellation. - ๐จ Themeable via plain dataclasses โ minimal/glyph-driven by default, boxed if you insist.
- ๐ชถ Tiny surface, two deps. Wraps rich for rendering and prompt_toolkit for input. No build steps.
๐ค What it is
A small, opinionated library for one specific job: interactive agent / chat-style terminal apps where output is a flowing transcript, not an app screen.
The pattern xli handles:
- A scrolling transcript of structured cards โ user messages, assistant messages, tool calls, diffs, plans, reasoning, images โ committed to real scrollback.
- Streaming markdown that appears as it arrives, then settles into rendered scrollback.
- A persistent multi-line composer at the bottom with autocomplete,
@filementions, and history. - Mutable cards โ a tool card that flips
runningโdonein place, a plan whose checkboxes update โ via a handle you hold. - Inline, arrow-selectable approvals / pickers / wizards that block until resolved.
- A subtle status bar, an animated "working" spinner, type-ahead, and ESC-to-interrupt.
๐ซ What it is NOT
| Notโฆ | Because |
|---|---|
| A TUI framework | No widget tree, no CSS, no focus model, no mouse panes. Reach for Textual when you want a full app screen. |
A "richer print" |
xli is interactive โ it owns the event loop. For one-shot static rendering, use rich directly. |
| A full-screen app | It renders inline so your terminal keeps native scroll / select / find. That's the whole point. |
| Tied to any LLM / agent framework | It knows nothing about providers. It renders the events you emit โ OpenAI, Anthropic, LangChain, your own loop, whatever. |
| A REPL builder | It runs your code in response to prompts. For a Python REPL, use ptpython. |
โ๏ธ How it compares
xli lives in the gap between "low-level rendering toolkit" and "full-screen app framework." If your output is a conversation that scrolls, that gap is exactly where you want to be.
| xli | Textual | rich | prompt_toolkit | questionary | |
|---|---|---|---|---|---|
| Shape | Flowing transcript | Full-screen app | Print / render | Input / REPL | Prompts only |
| Native scrollback | โ keeps it | โ takes over screen | โ (it just prints) | โ ๏ธ partial | โ |
| Streaming + mutable cards | โ built-in | โ (you wire widgets) | โ DIY | โ DIY | โ |
Composer (multiline, history, @, /) |
โ built-in | ๐ง build from widgets | โ | ๐ง primitives | โ |
| Inline approvals / pickers / wizards | โ built-in | ๐ง build from widgets | โ | ๐ง primitives | โ (pickers) |
| Best for | Chat / agent transcripts | Dashboards, IDEs, full apps | Static output | Custom REPLs & input | One-off questionnaires |
In short: Textual gives you a canvas to build any app and asks you to design the whole screen. xli gives you one app shape โ the agent/chat transcript โ already assembled, and hands the scrollback back to your terminal. If you've been gluing rich + prompt_toolkit together to make a chat loop, xli is that glue, done well. ๐
๐ฆ Install
pip install xli
Optional extras:
xli[markdown]โpygmentsfor code-block syntax highlighting in messages (recommended).xli[images]โpillow, for inline images (ui.image(...)).
Two core dependencies (rich, prompt_toolkit). No build steps. Requires Python 3.11+.
๐ Quickstart
A fuller echo agent โ streaming, a status field, a tool card that updates in place, and a slash command:
import asyncio
import xli
ui = xli.UI(title="echo", status_fields=["turn"], pet="cat")
turn = 0
@ui.command("clear", description="clear the screen")
async def clear(ui, args):
ui.clear_transcript()
@ui.on_prompt
async def handle(prompt: str) -> None:
global turn
turn += 1
ui.status.set(turn=turn)
card = ui.tool("think", status="running") # live, mutable card
with ui.working("thinking"):
await asyncio.sleep(0.5)
card.update(status="done", output="ok") # commits to scrollback
with ui.streaming("assistant") as out:
for token in f"You said: **{prompt}**".split():
out.write(token + " ")
await asyncio.sleep(0.04)
ui.run()
๐ก Want to see everything at once?
examples/demo.pyexercises every feature in one file.
๐ The vocabulary
xli.UI exposes a small set of methods you call from any handler. Transcript methods return a
cell handle you can mutate.
# --- streaming + cards (return a Cell handle) ---
with ui.streaming(role) as out: # streamed text; out.write(chunk); out.text
...
card = ui.tool(name, args=, output=, status="running") # status="running" -> live + mutable
card.update(status="done", output=...) # mutate in place; commits when final
card.remove() # drop a live cell
ui.message(role, text) # one-shot message
ui.diff(diff, path=) # syntax-colored unified diff
ui.plan([("step", "status"), โฆ]) # checklist
ui.reasoning(summary) # muted thought rail
ui.image("plot.png") # inline image (kitty / iTerm2 / half-block fallback)
ui.link("label", "https://โฆ") # OSC 8 hyperlink
ui.note("โฆ") / ui.header("โฆ") # muted status lines
ui.print(any_rich_renderable) # escape hatch for custom rendering
# --- a spinner while you work ---
with ui.working("running tests"): ... # animated, with an elapsed timer
# --- blocking prompts (await) ---
decision = await ui.approve(title=, body=, reason=) # arrow-select Yes / Always / No
choice = await ui.pick("Model", ["gpt-5", "claude-opus"]) # โ/โ ยท 1-9 ยท enter
yes = await ui.confirm("Delete?")
name = await ui.input("Name?", default="")
answers = await ui.wizard([ # multi-step flow -> dict
ui.step.pick("Model", ["opus", "sonnet"]),
ui.step.confirm("Stream responses?"),
ui.step.text("Project name", default="app"),
])
# --- chrome + lifecycle ---
ui.status.set(model="gpt-5", tokens="3.2k/400k") # bottom bar (declare fields up front)
ui.notify("response ready") # desktop notification (OSC 9)
ui.clear_transcript()
ui.exit()
๐ง The one design decision
xli renders inline, not full-screen. Finalized cells are printed into your terminal's normal scrollback โ so selection, scrolling, and find all come from the terminal itself. Only a small live region at the bottom (the composer, status bar, an in-progress stream, a running tool card, a spinner, a picker) is redrawn.
A cell is mutable while it's live at the bottom; once it finalizes it commits to scrollback and becomes immutable (but selectable). That two-tier model is what lets xli have both editable, animated cards and native, selectable scrollback โ the thing full-screen TUIs give up.
๐ Mutable cards
The transcript methods return a handle. Hold it, mutate it from anywhere (including across awaits
and from other tasks) โ it re-renders in place while live, then commits to scrollback once it's
finalized:
card = ui.tool("shell", status="running", args={"command": ["pytest", "-q"]})
result = await run_shell(...)
card.update(status="done", output=result) # โ shell โฆ and the output, committed
A ui.tool(...) without a status is a one-shot card (committed immediately). With
status="running" it stays live and mutable until you update it to done / error / cancelled.
โจ๏ธ Slash commands & @file mentions
@ui.command("model", description="switch model")
async def cmd_model(ui, args):
sel = await ui.pick("Model", ["gpt-5", "claude-opus"])
if sel is not None:
ui.status.set(model=sel)
@ui.command("quit", aliases=["q", "exit"])
async def cmd_quit(ui, args):
ui.exit()
Typing / opens a command list below the composer (arrow to navigate, Tab to fill, Enter to
run). /help, /quit, and /clear are built in (override freely). Typing @ opens a file
picker from the working directory; Tab/Enter inserts the path โ handy for letting users reference
files for your agent.
โ Approvals, pickers, wizards
All are inline and arrow-selectable (no modal screen-takeover). The request commits to scrollback so it scrolls into view and persists; the choices appear in the live region; the outcome is recorded below.
decision = await ui.approve(
title="apply patch to README.md",
body="add a one-liner about xli",
reason="writes outside the workspace root",
) # -> "approved" | "approved_for_session" | "denied" | "aborted"
โ/โ move the highlight, 1-9 quick-select, Enter confirms, Esc cancels.
โธ๏ธ Interrupts
The composer stays live while your handler runs โ users can type ahead (queued prompts show as
muted โฏ lines) and press ESC to interrupt the current turn. Interrupt is cooperative asyncio
cancellation; register cleanup with @ui.on_interrupt:
@ui.on_interrupt
async def cleanup():
await release_resources() # don't write to the transcript here
A running tool card left behind by an interrupted turn is automatically marked cancelled.
๐จ Themes
ui = xli.UI(theme="codex") # default โ minimal, glyph-driven, no borders, no solid bg
ui = xli.UI(theme="minimal") # even more austere
ui = xli.UI(theme="boxed") # rounded borders if you really want them
ui = xli.UI(theme=xli.Theme( # custom โ it's a dataclass; override fields
user_color="cyan",
tool_glyph="โ",
code_theme="monokai",
))
Themes are dataclasses โ override fields, don't subclass. The default leans "light": chrome is font
color + thin rules, never solid background blocks. See docs/theme.md for the
design guide you can hand to a coding agent.
๐ Plugging into an agent
xli renders the event types you give it. It doesn't care if those come from OpenAI, Anthropic, LangChain, or your own framework:
@ui.on_prompt
async def handle(prompt: str) -> None:
cards = {} # tool-call id -> live card handle
async for event in my_agent.stream(prompt):
if event.kind == "message":
ui.message("assistant", event.text)
elif event.kind == "tool_call":
cards[event.id] = ui.tool(event.name, args=event.args, status="running")
elif event.kind == "tool_result":
cards[event.id].update(status="done", output=event.output) # flips in place
elif event.kind == "approval_request":
decision = await ui.approve(title=event.title, body=event.body)
my_agent.respond(event.id, decision)
For token-by-token output, wrap a with ui.streaming("assistant") as out: block and call
out.write(delta) as deltas arrive. Have your own event stream? Register a custom renderer:
@ui.renderer("benchmark")
def render_benchmark(ui, event):
ui.print(make_bar_chart(event["data"]))
ui.dispatch({"type": "benchmark", "data": [...]})
โจ๏ธ Keys
enter send (or run highlighted command / select picker option / accept input)
alt+enter โ
ctrl+j newline
โ / โ history ยท navigate the command/file list ยท move picker selection
tab accept the highlighted completion / insert @file path
1โ9 quick-select a picker option
esc cancel a picker/modal or close the list โ otherwise interrupt the turn
ctrl+c interrupt ctrl+d quit
๐ ๏ธ Contributing
Contributions, bug reports, and ideas are very welcome! ๐
git clone https://github.com/fariz/xli
cd xli
pip install -e ".[dev,markdown,images]"
pytest # run the tests
ruff check . # lint
mypy xli # type-check
Found a rough edge or have a use case xli doesn't cover cleanly? Open an issue โ the API is small on purpose, so design conversations matter.
๐ Status
Pre-1.0 (currently 0.2.0). The API is stable enough to build on; versions are bumped
thoughtfully if anything user-facing changes.
๐ Credits
xli stands on the shoulders of two excellent libraries:
- rich โ all the rendering.
- prompt_toolkit โ all the input.
xli's contribution is the composition: the API you'd actually want to write a chat/agent terminal app in.
๐ License
MIT ยฉ xli contributors
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file python_xli-0.2.0.tar.gz.
File metadata
- Download URL: python_xli-0.2.0.tar.gz
- Upload date:
- Size: 56.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f1461031069abe566e61e4bc9bc38c9c011236fa958ac408640c33b8ef1105f8
|
|
| MD5 |
bdbbdb282bb65d5325d21e8e8c8b5aa6
|
|
| BLAKE2b-256 |
4d7381d02b9ff31f171f6119f4e907561f09802af6237f249dec91194266172e
|
File details
Details for the file python_xli-0.2.0-py3-none-any.whl.
File metadata
- Download URL: python_xli-0.2.0-py3-none-any.whl
- Upload date:
- Size: 43.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d73a37f1570d6153fd2ac6b184593dd516f22839966769da0384d47b8e7837d5
|
|
| MD5 |
40733d636ac9ec0dc64fa336e4d5679f
|
|
| BLAKE2b-256 |
4b498b4d56e1397584bc5cc6695909e3fd39a3f665b5a836dd99e73d1cd514b7
|