Skip to main content

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. โœจ

PyPI Python License: MIT Built on rich + prompt_toolkit

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 โ†’ done in 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, @file mentions, 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 asyncio cancellation.
  • ๐ŸŽจ 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, @file mentions, and history.
  • Mutable cards โ€” a tool card that flips running โ†’ done in 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] โ€” pygments for 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.py exercises 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:

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_xli-0.2.0.tar.gz (56.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_xli-0.2.0-py3-none-any.whl (43.3 kB view details)

Uploaded Python 3

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

Hashes for python_xli-0.2.0.tar.gz
Algorithm Hash digest
SHA256 f1461031069abe566e61e4bc9bc38c9c011236fa958ac408640c33b8ef1105f8
MD5 bdbbdb282bb65d5325d21e8e8c8b5aa6
BLAKE2b-256 4d7381d02b9ff31f171f6119f4e907561f09802af6237f249dec91194266172e

See more details on using hashes here.

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

Hashes for python_xli-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d73a37f1570d6153fd2ac6b184593dd516f22839966769da0384d47b8e7837d5
MD5 40733d636ac9ec0dc64fa336e4d5679f
BLAKE2b-256 4b498b4d56e1397584bc5cc6695909e3fd39a3f665b5a836dd99e73d1cd514b7

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page