prain: terminal UI

This commit is contained in:
2026-10-27 13:52:16 +01:00
parent 041398d849
commit 0531aaed44
2 changed files with 185 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
"""prain tui: a terminal chat that warms experts up while you type. Stdlib curses."""
import curses
import locale
import os
import queue
import textwrap
import threading
import time
from prain.chat import (ABORTED, Warmer, chat_body, first_model, post, send_request,
stream_text, warm_body)
WARMUP_MODES = ("idle", "always", "off")
class Chat:
"""Conversation state without curses, so tests can drive it.
warmup: "idle" warms up only while no answer streams, "always" also during one, "off" never."""
def __init__(self, base_url, model, warmup="idle", priority=1):
self.base_url, self.model, self.warmup = base_url, model, warmup
self.messages = [] # finished turns
self.draft = ""
self.reply = None # the answer text while it streams, else None
self.status = "type a message; Enter sends, Esc quits"
self.events = queue.Queue()
self.warmer = Warmer(lambda draft: send_request(
base_url, "/v1/chat/completions", warm_body(model, self.messages, draft, priority)))
def type(self, text):
self.draft = text
idle = self.reply is None
if text.strip() and (self.warmup == "always" or (self.warmup == "idle" and idle)):
self.warmer.update(text)
def send(self):
if self.reply is not None or not self.draft.strip():
return
self.warmer.cancel() # a queued draft is stale now; the in-flight one only delays us
self.messages.append({"role": "user", "content": self.draft.strip()})
self.draft, self.reply = "", ""
threading.Thread(target=self._stream, args=(list(self.messages),), daemon=True).start()
def _stream(self, messages):
start, first, pieces = time.monotonic(), None, 0
try:
with post(self.base_url, "/v1/chat/completions", chat_body(self.model, messages)) as r:
for piece in stream_text(r):
first = first or time.monotonic()
pieces += 1
self.events.put(("piece", piece))
rate = (pieces - 1) / (time.monotonic() - first) if pieces > 1 else 0.0
ttft = (first or time.monotonic()) - start
self.events.put(("done", f"first token {ttft:.1f} s, {rate:.1f} tok/s"))
except ABORTED as e:
self.events.put(("done", f"error: {e}"))
def poll(self):
"""Apply streamed pieces; call from the UI thread."""
while True:
try:
kind, value = self.events.get_nowait()
except queue.Empty:
return
if kind == "piece":
self.reply += value
else:
self.messages.append({"role": "assistant", "content": self.reply})
self.reply, self.status = None, value
def draw(screen, chat):
screen.erase()
height, width = screen.getmaxyx()
turns = chat.messages + ([{"role": "assistant", "content": chat.reply}]
if chat.reply is not None else [])
lines = []
for turn in turns:
prefix = "you> " if turn["role"] == "user" else "ai> "
for paragraph in (prefix + turn["content"]).split("\n"):
lines += textwrap.wrap(paragraph, width - 1) or [""]
for y, line in enumerate(lines[-(height - 2):]):
screen.addstr(y, 0, line)
warming = "warming experts... " if chat.warmer.busy else ""
screen.addstr(height - 2, 0, f"[{chat.model}] {warming}{chat.status}"[: width - 1],
curses.A_REVERSE)
screen.addstr(height - 1, 0, ("> " + chat.draft)[-(width - 1):])
screen.refresh()
def run(screen, chat):
screen.timeout(50) # redraw at least every 50 ms while an answer streams
while True:
chat.poll()
draw(screen, chat)
try:
key = screen.get_wch()
except curses.error:
continue # nothing typed
if key in ("\x1b", "\x04"): # Esc or Ctrl-D
return
if key in ("\n", "\r", curses.KEY_ENTER):
chat.send()
elif key in (curses.KEY_BACKSPACE, "\x7f", "\b"):
chat.type(chat.draft[:-1])
elif isinstance(key, str) and key.isprintable():
chat.type(chat.draft + key)
def main(base_url, warmup="idle", priority=1):
locale.setlocale(locale.LC_ALL, "")
os.environ.setdefault("ESCDELAY", "25") # Esc quits at once, not after 1 s
chat = Chat(base_url, first_model(base_url), warmup, priority)
curses.wrapper(run, chat)
+70
View File
@@ -0,0 +1,70 @@
import fcntl
import os
import pty
import struct
import subprocess
import sys
import termios
import threading
import unittest
from prain.tui import Chat
from tests.helpers import StubVllm, wait_for
class Tui(unittest.TestCase):
def setUp(self):
self.stub = StubVllm()
self.addCleanup(self.stub.close)
def test_typing_warms_up_and_enter_streams_the_answer(self):
chat = Chat(self.stub.url, "stub-model")
chat.type("Why is the sky")
wait_for(lambda: self.stub.bodies and not chat.warmer.busy)
self.assertEqual(self.stub.bodies[0]["priority"], 1)
chat.send()
chat.type("next question") # an answer is streaming: no warm-up in "idle" mode
wait_for(lambda: chat.poll() or chat.reply is None)
self.assertEqual(chat.messages, [{"role": "user", "content": "Why is the sky"},
{"role": "assistant", "content": "Hello!"}])
self.assertEqual(len(self.stub.bodies), 2)
self.assertIn("tok/s", chat.status)
def test_warmup_off_and_cancel_on_send(self):
off = Chat(self.stub.url, "stub-model", warmup="off")
off.type("hello")
self.assertFalse(off.warmer.busy)
self.stub.delay = 3.0
chat = Chat(self.stub.url, "stub-model")
chat.type("a slow warm-up")
wait_for(lambda: self.stub.bodies)
chat.send() # aborts the warm-up instead of queueing behind it
wait_for(lambda: not chat.warmer.busy, timeout=2.0)
def test_curses_screen_runs_in_a_real_terminal(self):
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
tui = subprocess.Popen([sys.executable, "-m", "prain.cli", "tui", "--url", self.stub.url],
stdin=slave, stdout=slave, stderr=slave,
env={**os.environ, "TERM": "xterm"}, start_new_session=True)
os.close(slave)
screen = bytearray()
threading.Thread(target=self._drain, args=(master, screen), daemon=True).start()
wait_for(lambda: b"stub-model" in screen) # status line drawn
os.write(master, b"hi\r")
wait_for(lambda: any(body.get("stream") and "priority" not in body
for body in self.stub.bodies))
wait_for(lambda: b"tok/s" in screen)
os.write(master, b"\x1b")
self.assertEqual(tui.wait(timeout=5), 0)
@staticmethod
def _drain(fd, screen):
while True:
try:
data = os.read(fd, 4096)
except OSError:
return
if not data:
return
screen.extend(data)