Tutorial: Chatbot

This tutorial builds a conversational TUI: a self-scrolling chat window with a text input, a “bot” that streams its reply one word at a time, and Send / Clear buttons. It is a great second project after the Quick Start because it touches reactive state, a live template, a self-tailing log, a plain action handler, and an async handler - the exact shape you’d use to render tokens from a streaming LLM response.

The finished script ships as examples/apps/chatbot.py.

What you’ll build

  • A conversation history that automatically scrolls to the newest message.

  • A message box that sends on Enter or via the Send button.

  • Word-by-word streaming of the bot’s reply.

  • A rule-based “bot” (no network or LLM required) you can later swap for a real API call.

  • A Clear button that resets the conversation.

Prerequisites

Step 1 - state and a bot

Start with the imports, a tiny rule-based responder, and the app state. The transcript is just a list of strings - one line per turn - which is exactly what the LogView widget consumes.

chatbot.py
 from __future__ import annotations

 import asyncio
 from datetime import datetime

 from wijjit import ANSIColor, Wijjit, colorize, render_template_string

 STREAM_DELAY = 0.05  # seconds between words while streaming

 # LogView passes ANSI through untouched, so we color the speaker prefix at
 # render time and keep the stored transcript plain. colorize() honors
 # NO_COLOR, so this degrades gracefully on color-free terminals.
 SPEAKER_COLORS = {
     "You:": ANSIColor.BRIGHT_CYAN,
     "Bot:": ANSIColor.BRIGHT_GREEN,
 }

 WELCOME = "Bot: Hi! I'm a little Wijjit bot. Type 'help' to see what I can do."


 def colorize_line(line: str) -> str:
     for prefix, color in SPEAKER_COLORS.items():
         if line.startswith(prefix):
             return colorize(prefix, color=color, bold=True) + line[len(prefix):]
     return line


 def bot_reply(message: str) -> str:
     text = message.strip().lower()
     if not text:
         return "Say something and I'll do my best to reply!"
     if any(word in text for word in ("hello", "hi", "hey")):
         return "Hello there! How can I help?"
     if "help" in text:
         return "I can chat (loosely). Try 'time', say 'hello', or just type."
     if "time" in text:
         return f"It's {datetime.now().strftime('%H:%M:%S')} right now."
     return f'You said: "{message.strip()}". Tell me more!'


 app = Wijjit(
     initial_state={
         "history": [WELCOME],
         "chat_input": "",
         "status": "Type a message and press Enter.",
     }
 )

Step 2 - render the chat window with LogView

The LogView widget is purpose-built for scrolling text. Two of its options do the heavy lifting for us:

  • auto_scroll=True (the default) tails the newest line as content grows, and automatically pauses if the user scrolls up to read history.

  • soft_wrap=True wraps long messages to the width, so we don’t have to wrap them ourselves.

We also set detect_log_levels=False so ordinary words (like “info”) aren’t colored as if they were log levels.

@app.view("main", default=True)
def main_view():
    return render_template_string("""
{% frame border="rounded" title="Wijjit Chatbot" width=72 height=24 %}
  {% vstack spacing=1 padding=1 %}

    {% logview id="history" lines=history width="fill" height=15
        auto_scroll=True soft_wrap=True detect_log_levels=False
        bind=False border="single" %}
    {% endlogview %}

    {% hstack spacing=1 %}
      {% textinput id="chat_input" placeholder="Type your message..." width="fill" action="send" autofocus=True %}{% endtextinput %}
      {% button action="send" %}Send{% endbutton %}
      {% button action="clear" %}Clear{% endbutton %}
    {% endhstack %}

    {% text %}{{ state.status }}{% endtext %}

  {% endvstack %}
{% endframe %}
        """, history=[colorize_line(line) for line in app.state["history"]])

The textinput id="chat_input" binds to state["chat_input"] automatically, and its action="send" fires the send action when you press Enter in the box. We pass the colorized transcript as live context (recomputed every render). Note bind=False: by default LogView would bind its lines to state["history"] (our plain transcript), which would override the colorized context - turning binding off lets the lines=history value win, and LogView renders the embedded ANSI via its passthrough support.

Step 3 - stream the reply

Here’s the interesting part. Making the handler async lets us await between updates; each assignment to state["history"] schedules a render, and the await yields to the event loop so the UI repaints between words. That is precisely how you’d render a streaming model response.

async def stream_bot_reply(reply: str) -> None:
    app.state["history"] = app.state["history"] + ["Bot:"]
    app.state["status"] = "Bot is typing..."

    partial = "Bot:"
    for word in reply.split():
        partial = f"{partial} {word}"
        # Rewrite the last line in place as the reply grows.
        app.state["history"] = app.state["history"][:-1] + [partial]
        await asyncio.sleep(STREAM_DELAY)

    app.state["status"] = f"{len(app.state['history'])} messages - Ctrl+Q to quit"


@app.on_action("send")
async def send_message(event):
    text = app.state.get("chat_input", "").strip()
    if not text:
        app.state["status"] = "Nothing to send - type a message first."
        return

    app.state["chat_input"] = ""
    app.state["history"] = app.state["history"] + [f"You: {text}"]
    await stream_bot_reply(bot_reply(text))


@app.on_action("clear")
def clear_conversation(event):
    app.state["history"] = [WELCOME]
    app.state["status"] = "Conversation cleared"


if __name__ == "__main__":
    app.run()

To use a real model, replace the for word in reply.split() loop with an async for over your provider’s stream:

partial = "Bot:"
async for chunk in client.stream(...):
    partial += chunk
    app.state["history"] = app.state["history"][:-1] + [partial]

Important

Wijjit’s reactive State detects reassignment, not in-place mutation. Building a new list with old + [items] (or old[:-1] + [line]) and assigning it back is what schedules the re-render; calling app.state["history"].append(...) would not.

Step 4 - run it

uv run python examples/apps/chatbot.py

Type something and press Enter - the message box is marked autofocus=True, so it holds focus from the first frame. The bot “types” its reply word by word, and the history tails to the bottom on its own. Scroll up with the arrow keys to read earlier messages - auto-scroll pauses until you scroll back to the bottom. Quit with Ctrl+Q.

Where to next

  • Swap bot_reply + the streaming loop for a real chat API. Keeping the call in the async handler is what keeps the UI responsive while tokens arrive - see Event Handling.

  • Ready for something meatier? Tutorial: Terminal Spreadsheet builds an editable spreadsheet with a live chart.