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
Python 3.11+
Wijjit installed (see Installation)
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.
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=Truewraps 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 theasynchandler 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.