tai.py --tui is the grr app in the terminal: a stdlib client of the grr server with every screen (drawer, bots, profile, chat with approvals, questions, files and live process cards, automations, library, projects, search, the whole settings tree, bot computer terminal, side pane), the grr palette, strings and caps read from the server; old Telegram-style scene kept as --tui-local; spec in grrtui.md with drift tests, 77 unit tests, 4 integration tests through the real binary and a real model
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
- `tai.py` is the entire product in one standalone stdlib-only file: CLI entry plus the stable `import tai` surface, grouped into classes (instance classes for stateful clusters, static utility classes for stateless groups). It must import nothing outside the standard library; a bare copy of the file has to import and run anywhere. Canonical patch targets are `tai.<Class>.<name>` (or the module attribute for module-level data).
|
||||
- There are two kinds of test, in two different places, on purpose — see "Testing" below for the full split. In short: unit tests are top-level `test_*.py`; integration tests are `tests/<topic>.py` (no `test_` prefix) built on `tai_test.py`.
|
||||
- The in-`tai.py` TUI framework is documented in [tui.md](tui.md) — read it before touching any `Tui*` code.
|
||||
- The grr terminal client (`tai.py --tui`, the `Grr*` classes) is specified in [grrtui.md](grrtui.md): read it before touching any `Grr*` code, and keep its screen, key and cap tables in step with the code (`GrrSpecTests` fails when they drift). It is a client of the grr server and shows what the server's `/api/spec` and `/locales.json` say; never copy a string, color or limit of the server into `tai.py`. `tests/grr_tui.py` drives the real binary against a real grr server (sibling `../grr`, `GRR_DIR` to move it) and a real model.
|
||||
- The terminal multiplexer (`Mplex*` classes, `tai.py --mplex`, `~/.tai/mplex.toml`) is specified in [mplex.md](mplex.md): read it before touching any `Mplex*` code, and keep its option, command and key tables in step with the code (`MplexSpecTests` fails when they drift).
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -268,7 +268,7 @@ A **profile** is a complete identity: system message plus session history under
|
||||
|
||||
A **bot** is a lighter-weight thing inside one profile: just a system message plus its own history, with the whole vault shared. Every profile starts with `main`; `create_bot` adds named bots from a description, rules, behavior, and optional nicknames (lowercased, with a short prefix auto-registering). `/bot` switches roles and resumes exactly where that bot left off; `@name` routes a single turn to another bot and files the question and answer into *both* histories, so the bot you're actually talking to stays aware a detour even happened.
|
||||
|
||||
Leading `@tags` also select the backend: `@provider` pins a provider (`muse`, `opencode`, `ollama`, ...), `@model-id` pins a model, and tags combine freely (`@opencode @coder fix this`). A bare tag with no prompt just switches (`@coder` moves to that bot). Every selection is saved as the default for the current directory in the vault (`dir_defaults` table) and re-applied on the next boot there; `@auto` clears the pins. `@path` anywhere in the text references a file by absolute path for the agent to read. All of it Tab-completes in the REPL and in TUI boxes. Typed input is kept in the vault too (`input_history` table, sealed, 1000 entries per profile): the REPL loads it into readline and the `--tui` composer recalls it with Up/Down, so both front ends share one history.
|
||||
Leading `@tags` also select the backend: `@provider` pins a provider (`muse`, `opencode`, `ollama`, ...), `@model-id` pins a model, and tags combine freely (`@opencode @coder fix this`). A bare tag with no prompt just switches (`@coder` moves to that bot). Every selection is saved as the default for the current directory in the vault (`dir_defaults` table) and re-applied on the next boot there; `@auto` clears the pins. `@path` anywhere in the text references a file by absolute path for the agent to read. All of it Tab-completes in the REPL and in TUI boxes. Typed input is kept in the vault too (`input_history` table, sealed, 1000 entries per profile): the REPL loads it into readline and the `--tui-local` composer recalls it with Up/Down, so both front ends share one history.
|
||||
|
||||
/profile show current profile
|
||||
/profile [name] switch profile, creating it when missing
|
||||
@@ -340,7 +340,7 @@ File changes render as a unified diff right inside the call box: line numbers, g
|
||||
|
||||
## TUI framework
|
||||
|
||||
The interactive UI toolkit lives in `tai.py` itself (stdlib-only, no dependencies): a retained, DOM-like panel tree with rows/cols flow, percent/`fr`/`auto` sizing, docking, named z-layers, a lazy pipeline (watched state, per-panel cell cache, incremental layout, front-to-back occlusion, damage rects, terminal scroll, an event-driven loop that sleeps while nothing changes; an unchanged frame writes nothing), SGR mouse, bubbling events, Tab/Shift+Tab focus with modal trap, tick animations, and live pty sub-terminals behind a reimplemented VT screen. `/demo` (or `./tai.py --demo`) tours every widget; `./tai.py --demo-shot` prints one composed demo frame as plain text. `./tai.py --tui` runs the fullscreen Telegram-style chat UI (bots sidebar, transcript, input, status bar) with REPL parity; `./tai.py --theme NAME` picks a theme (`dark`, `light`, `nord`, `gruvbox`, `ansi`, or `TAI_THEME`). Full reference: [tui.md](tui.md); design background: [research/tui-framework-design/conclusions/retained-dom-compositor-tui.md](research/tui-framework-design/conclusions/retained-dom-compositor-tui.md), [chat layout](research/tui-framework-design/conclusions/messaging-layout-chat-scene.md), [theming](research/tui-framework-design/conclusions/palette-role-theming.md).
|
||||
The interactive UI toolkit lives in `tai.py` itself (stdlib-only, no dependencies): a retained, DOM-like panel tree with rows/cols flow, percent/`fr`/`auto` sizing, docking, named z-layers, a lazy pipeline (watched state, per-panel cell cache, incremental layout, front-to-back occlusion, damage rects, terminal scroll, an event-driven loop that sleeps while nothing changes; an unchanged frame writes nothing), SGR mouse, bubbling events, Tab/Shift+Tab focus with modal trap, tick animations, and live pty sub-terminals behind a reimplemented VT screen. `/demo` (or `./tai.py --demo`) tours every widget; `./tai.py --demo-shot` prints one composed demo frame as plain text. `./tai.py --tui` runs the grr app (bots, chats, automations, library, projects, settings, approvals, bot computers) as a fullscreen client of the grr server, specified in [grrtui.md](grrtui.md); `./tai.py --tui-local` runs the Telegram-style chat UI on the local vault (bots sidebar, transcript, input, status bar) with REPL parity; `./tai.py --theme NAME` picks a theme (`dark`, `light`, `nord`, `gruvbox`, `ansi`, or `TAI_THEME`). Full reference: [tui.md](tui.md); design background: [research/tui-framework-design/conclusions/retained-dom-compositor-tui.md](research/tui-framework-design/conclusions/retained-dom-compositor-tui.md), [chat layout](research/tui-framework-design/conclusions/messaging-layout-chat-scene.md), [theming](research/tui-framework-design/conclusions/palette-role-theming.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -386,5 +386,6 @@ Unit tests live at the top level (`test_tai.py`, `test_seal.py`); integration te
|
||||
tai_test.py integration-test framework (process spawn, pty, cleanup)
|
||||
tests/ integration tests, one file per topic (see tests/README.md)
|
||||
tui.md TUI framework reference (see "TUI framework" above)
|
||||
grrtui.md the grr terminal client (`--tui`): screens, keys, caps, gaps
|
||||
research/ archived design research, registry at research/INDEX.md
|
||||
AGENTS.md contributor conventions (tests, research rule)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# grr in the terminal: `tai.py --tui`
|
||||
|
||||
`./tai.py --tui` is the grr app (the phone-first web client in the sibling `grr` project) as a fullscreen terminal
|
||||
program: same bots, chats, automations, library, projects, settings, approvals and computers, same words, same
|
||||
colors, from the same server. It is a client. Nothing is stored in tai's vault; the bots, chats and files live in the
|
||||
grr server, so the web app and the terminal show the same state at the same moment.
|
||||
|
||||
Standard library only, in `tai.py` like everything else, built on the retained TUI framework in [tui.md](tui.md).
|
||||
The Telegram-style chat scene on the local vault is still there as `./tai.py --tui-local`.
|
||||
|
||||
`GrrSpecTests` in `test_tai.py` fails when the tables below drift from the code.
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
grr-serve # the server, http://127.0.0.1:8765 (or: python3 -m grr serve)
|
||||
./tai.py --tui # the client
|
||||
GRR_URL=http://host:8765 ./tai.py --tui
|
||||
```
|
||||
|
||||
First start shows the sign-in form (DevPlace username or email and password, or a pasted DevPlace key, or the key of
|
||||
the server's own session when the server says it allows that). The grr session token, not the DevPlace key, is kept in
|
||||
`<TAI_HOME>/grr.json` (mode 0600) together with the server address and every signed-in account. `GRR_URL` beats the
|
||||
saved address. `--theme` and `TAI_THEME` belong to the other scenes: here the theme is the account's (Appearance). When the server cannot be reached the screen says so, shows the address and offers Try again and Change
|
||||
address. `Ctrl+C` always quits.
|
||||
|
||||
## What it takes from the server
|
||||
|
||||
One source of truth, nothing copied into tai:
|
||||
|
||||
- `GET /api/spec`: caps, modes, tiers, shapes, themes, variants, languages, schedule kinds, rule actions, pane tabs,
|
||||
palette. The TUI reads the palette from here (night, day, Black and Blue variants), so a brand change on the server
|
||||
is a brand change here.
|
||||
- `GET /locales.json`: the string table, in the account's language (`system` follows `LC_ALL`, `LC_MESSAGES`, `LANG`).
|
||||
Every word on screen is one of these keys. Four sort labels the server does not serve (`library.sort_*`) have a
|
||||
built-in English fallback (`GRR_STRING_FALLBACK`); any other missing key shows as the key.
|
||||
- `GET /api/events` (server-sent events) on a thread of its own: `message.new`, `message.update`, `status` and the
|
||||
other events listed under Events. It reconnects every `GRR_SSE_RETRY` seconds and re-reads bots and chats when the
|
||||
link comes back, like the web app does.
|
||||
- Every other screen is one or two REST calls. The same error codes the web app words (`apierr.*`) are worded here.
|
||||
|
||||
## Layout and responsiveness
|
||||
|
||||
Terminals are small phones too. One pane below `GRR_SIDE_MIN_COLS` columns (the drawer is the home screen, every other
|
||||
screen replaces it, Esc goes back); two panes from there on (the drawer `GRR_SIDE_W` columns wide, the screen beside
|
||||
it, a welcome mark when nothing is open). Tabs that do not fit fold into one dropdown chip, the mode bar of a general
|
||||
chat folds into a dropdown, tiles reflow into fewer columns, texts wrap. Nothing is wider than the terminal at 30
|
||||
columns (a test draws every screen at 30, 56, 100 and 160 columns and fails on any overflow).
|
||||
|
||||
The side pane of a bot chat (Preview, Files, Deploy) sits beside the chat from `GRR_PANE_MIN_COLS` columns and over it
|
||||
below that. Cards are rounded with half-block edges and quarter-block corners, so they look rounded in any font. Colors
|
||||
are the grr palette as truecolor values; `COLORFGBG` decides what `system` means (night unless it says light).
|
||||
|
||||
## Screens
|
||||
|
||||
Every screen is one of these routes, the same patterns as the web app's.
|
||||
|
||||
| Route | View | The screen |
|
||||
|---|---|---|
|
||||
| `/bots` | `GrrBotsView` | Bots and group chats, the avatar strip, new bot and new group chat |
|
||||
| `/bot/:id` | `GrrProfileView` | Profile: name and description, Info (character shape and color, instructions, memories, skills, routines, voice, notifications, auto-approve, share as template), Documents, Computer, Links |
|
||||
| `/bot/:id/instructions` | `GrrInstructionsView` | Instruction, persona and rules, each with reset to default |
|
||||
| `/bot/:id/memories` | `GrrMemoriesView` | Memories list and add |
|
||||
| `/memory/:id` | `GrrMemoryView` | One memory, edit and delete |
|
||||
| `/bot/:id/skills` | `GrrSkillsView` | Skills list and add |
|
||||
| `/skill/:id` | `GrrSkillView` | One skill, edit and delete |
|
||||
| `/bot/:id/computer` | `GrrComputerView` | Take control: the bot computer's live shell |
|
||||
| `/bot/:id/screen` | `GrrScreenView` | The graphical screen: start, stop and the address to open it |
|
||||
| `/chat/:id` | `GrrChatView` | Bot, group, general and private chats; `new` makes a general chat on the first send |
|
||||
| `/automations` | `GrrAutomationsView` | Routines as cards, run now, pause, delete |
|
||||
| `/automations/new` | `GrrNewAutomationView` | Name, schedule or trigger, instruction, bot, tier, notifications |
|
||||
| `/automation/:id` | `GrrAutomationView` | One routine: active, instruction, schedule, bot, notifications, last run |
|
||||
| `/library` | `GrrLibraryView` | All, Media, Apps, Files; sort, filter, upload; the file viewer |
|
||||
| `/projects` | `GrrProjectsView` | Projects, filter and sort, new project |
|
||||
| `/project/:id` | `GrrProjectView` | A project's chats, rename, star, instructions, delete |
|
||||
| `/search` | `GrrSearchView` | Messages, bots, group chats, files, routines |
|
||||
| `/settings` | `GrrSettingsView` | The settings tree |
|
||||
| `/settings/account` | `GrrAccountView` | Accounts, switch, add, sign out, delete account |
|
||||
| `/settings/usage` | `GrrUsageView` | Weekly usage, days, gateway, model health, counters, change limit |
|
||||
| `/settings/rules` | `GrrRulesView` | Auto-review rules |
|
||||
| `/settings/rule/:id` | `GrrRuleView` | One rule (`new` adds), allow or ask first |
|
||||
| `/settings/appearance` | `GrrAppearanceView` | System, Day, Night, Black, Blue, size |
|
||||
| `/settings/language` | `GrrLanguageView` | Language |
|
||||
| `/settings/haptics` | `GrrHapticsView` | The haptics switch (kept in the account; a terminal cannot vibrate) |
|
||||
| `/settings/widget` | `GrrWidgetView` | The widget page: switch, open, copy the address |
|
||||
| `/settings/computer` | `GrrComputersView` | Each bot computer: disk, update, reset |
|
||||
| `/settings/connectors` | `GrrConnectorsView` | Tool groups on and off |
|
||||
| `/settings/advanced` | `GrrAdvancedView` | Default tier, replace the key, export, server health |
|
||||
| `/settings/builder` | `GrrBuilderView` | Builder state and settings (admins) |
|
||||
| `/settings/feedback` | `GrrFeedbackView` | Send feedback |
|
||||
| `/settings/timezone` | `GrrTimezoneView` | Time zone list with filter |
|
||||
| `/template/:id` | `GrrTemplateView` | A bot template: details, publish, unpublish, copy link |
|
||||
| `/template/:id/:part` | `GrrTemplatePartView` | Instructions, memories or skills of a template |
|
||||
| `/public/:slug` | `GrrPublicTemplateView` | A shared template and Add to my bots |
|
||||
|
||||
## The chat
|
||||
|
||||
Messages are items in a page that follows the newest row while you are at the bottom (End jumps there, a hint shows
|
||||
when something arrived above). Your messages are right-aligned bubbles, a bot's are left, rendered with the markdown
|
||||
renderer of the chat scene (headings, bold, inline and fenced code, lists, quotes). A divider names the time when more
|
||||
than 30 minutes passed, `NEW` marks what you have not read. Under the newest bot message, and under any message you
|
||||
select, sit copy, good, bad and try again; steps fold open with Enter.
|
||||
|
||||
Cards: approvals (Allow once, Deny, Always allow in browser for the browser tool, then the state), questions (lettered
|
||||
options and Skip), files (Enter opens the viewer: text with line numbers, anything else is saved and opened by the
|
||||
desktop), several files, templates (publish, copy link, details), and live process cards. A running command's card
|
||||
follows the server's byte stream into a VT screen (8 rows, 24 expanded) with `^C`, Stop, Type (a full terminal in a
|
||||
dialog, Esc leaves) and Expand; a finished one shows the stored transcript.
|
||||
|
||||
The composer is a multiline box (Enter sends, Shift+Enter or Alt+Enter adds a line, `@name` completes bots, PageUp and
|
||||
PageDown scroll the conversation). The button on its right is Stop while the bot works, Send when there is text and
|
||||
the voice mark otherwise. Plus attaches a file by path, inserts a skill or opens Connectors. A general chat has the
|
||||
Ask, Imagine and Build tabs, the tier chip and the private-chat switch; a private chat is deleted when you leave it.
|
||||
Unsent text is kept per chat while the program runs (`GRR_DRAFT_KEEP` chats).
|
||||
|
||||
## Keys
|
||||
|
||||
Mouse works everywhere: click rows, buttons, tabs, chips and tiles, wheel scrolls.
|
||||
|
||||
| Key | What it does |
|
||||
|---|---|
|
||||
| `Esc` | back, or close the open dialog or menu |
|
||||
| `Up Down` | move the selection |
|
||||
| `Left Right` | move inside tabs, chips, tiles and card buttons |
|
||||
| `Enter, Space` | open the selected row, press the selected button, flip the selected switch |
|
||||
| `PageUp PageDown` | scroll the page or the conversation |
|
||||
| `Home End` | first or last row, End jumps to the newest message |
|
||||
| `Tab, Shift+Tab` | next or previous area |
|
||||
| `Ctrl+N` | new chat |
|
||||
| `Ctrl+F` | search |
|
||||
| `Ctrl+G` | settings |
|
||||
| `Ctrl+O, F10` | the More menu of the screen |
|
||||
| `Ctrl+P` | the side pane of a bot chat |
|
||||
| `Ctrl+]` | leave the terminal of a bot computer |
|
||||
| `F1` | key help |
|
||||
| `Ctrl+Q` | quit |
|
||||
|
||||
In the terminal of a bot computer every other key goes to the shell. Tab still moves focus (the framework's rule for
|
||||
every terminal widget, see [tui.md](tui.md)), so shell tab completion is not available there.
|
||||
|
||||
## Events
|
||||
|
||||
The server sends `bot.update`, `bot.delete`, `chat.update` and `chat.delete` (the lists are read again), `status`
|
||||
(who is working on what, drives the working row, the busy dots and the stop button), `toast` (a banner with the bot
|
||||
and a line, click opens the chat, not shown for the chat you are looking at) and these, handed to the open screen:
|
||||
`message.new`, `message.update`, `routine.update`, `automation.update`, `library.update`, `usage.update`,
|
||||
`builder.update`, `files.changed`, `port.opened`, `port.closed`, `preview.ready`, `preview.failed`, `chat.draft`,
|
||||
`connector.update`.
|
||||
|
||||
## Character shapes
|
||||
|
||||
A bot's mark is two rows of seven cells (five when small, four rows as a hero) with two eyes, one template per shape:
|
||||
`blob`, `cloud`, `tilt`, `plus`, `flower`, `leaf`, `pebble`, `oval`, `play`, `gem`, `cog`, `hex`, in the bot's own color.
|
||||
|
||||
## Caps
|
||||
|
||||
Every limit is a constant in `tai.py`.
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `GRR_HTTP_TIMEOUT` | 30 | seconds for one REST call |
|
||||
| `GRR_SSE_TIMEOUT` | 60 | seconds of silence before the event stream reconnects (the server beats every 15) |
|
||||
| `GRR_SSE_RETRY` | 2.0 | seconds between reconnects |
|
||||
| `GRR_SSE_LINE_MAX` | 1048576 | bytes of one event line; longer ends the connection, it is counted and never parsed |
|
||||
| `GRR_BODY_MAX` | 33554432 | bytes of one answer or download |
|
||||
| `GRR_SIDE_W` | 34 | columns of the drawer in two-pane mode |
|
||||
| `GRR_SIDE_MIN_COLS` | 100 | columns from which two panes show |
|
||||
| `GRR_PANE_MIN_COLS` | 150 | columns from which the side pane sits beside the chat |
|
||||
| `GRR_PANE_MIN_W` | 40 | narrowest side pane |
|
||||
| `GRR_PANE_MAX_W` | 90 | widest side pane |
|
||||
| `GRR_MESSAGE_KEEP` | 400 | messages a chat keeps in memory (older ones load again on demand) |
|
||||
| `GRR_TOAST_SECS` | 3.2 | seconds a toast stays |
|
||||
| `GRR_BANNER_SECS` | 5.2 | seconds a bot banner stays |
|
||||
| `GRR_TOAST_MAX` | 4 | toasts at once |
|
||||
| `GRR_COMPOSER_MAX_ROWS` | 6 | rows the composer grows to |
|
||||
| `GRR_VIEWER_MAX_BYTES` | 2097152 | bytes the file viewer reads |
|
||||
| `GRR_DRAFT_KEEP` | 64 | chats with a kept draft |
|
||||
| `GRR_LIST_MORE` | 12 | rows Show more adds |
|
||||
| `GRR_MENU_MAX_H` | 14 | rows of a menu before it scrolls |
|
||||
|
||||
## Measured
|
||||
|
||||
One machine (Linux 7.0, Python 3.14), headless harness with the real server on loopback, no terminal in the loop, median of
|
||||
50 runs of "change, lay out, build the frame bytes":
|
||||
|
||||
| What | Result |
|
||||
|---|---|
|
||||
| boot to first frame (spec, locales, me, bots and chats over REST) | 29 ms |
|
||||
| repaint of a 400 message chat, every row cold | 44 ms |
|
||||
| scroll by 3 rows in that chat | 1.1 ms |
|
||||
| one typed character in the composer | 0.12 ms |
|
||||
| one new message appended | 2.0 ms |
|
||||
| the settings page repainted | 0.8 ms |
|
||||
| peak memory of that process | 44 MB |
|
||||
|
||||
Through the real binary (`tests/grr_tui.py`, a real grr server on a throwaway home, the real gateway behind it): typing a
|
||||
message in the TUI, the server storing it, a real model answering and the answer reaching the screen took 11 s once; that
|
||||
case is probabilistic by design (a free, rotating backend), the other three cases are deterministic and take 5 to 7 s each.
|
||||
|
||||
## Gaps, next to what closes them
|
||||
|
||||
- **Voice conversation and dictation** (the wave and mic buttons): no audio capture in a terminal client. The button
|
||||
says so. Closes in `GrrChatView.send_action` with `tai.py`'s own `listen` and the server's `/api/voice/*` routes.
|
||||
- **The graphical bot screen** (VNC) and **camera**: a terminal cannot draw either. `GrrScreenView` starts and stops
|
||||
the screen and opens it in the desktop browser; the bot's shell is `GrrComputerView`.
|
||||
- **Images** (link thumbnails, the image viewer, library thumbnails) are not drawn: they show as named files and open
|
||||
in the desktop viewer. Closes in `GrrFileViewer` and `GrrMessageItem` with a pixel protocol (kitty or sixel).
|
||||
- **Preview frame**: a terminal cannot frame a page, `GrrPanePreview` shows the address and opens it in the browser.
|
||||
- **File highlighting** in the Files tab: line numbers only, no syntax colors (the web app uses the rule sets in the
|
||||
server's spec). Closes in `GrrPaneFiles.view_file`.
|
||||
- **Dates and relative times** use the system locale's day and month names and English words for "ago".
|
||||
- **Settings that only a screen has**: the size slider is kept in the account but does not change the terminal; haptics
|
||||
likewise.
|
||||
- **Server-side chat drafts** (`chat.draft`) are not read; drafts are kept per run in memory.
|
||||
- **Wide characters** count as one column (see tui.md), so CJK names in the language list can misalign by a cell.
|
||||
- **Not measured**: frame cost of the grr screens on real terminals, memory over a long session, behavior on terminals
|
||||
without truecolor (colors are sent as 24-bit values).
|
||||
+1027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tests/grr_tui.py - integration tests for `tai.py --tui`, the terminal client of the grr server.
|
||||
|
||||
Built on tai_test.py (see tests/README.md). Each case starts a real grr server
|
||||
from the sibling `grr` checkout on a throwaway GRR_HOME and port, signs in with
|
||||
the DevPlace key of this machine (DEVPLACE_API_KEY, the same key the real server
|
||||
uses to reach the gateway), and drives the real `tai.py --tui` over a pty with
|
||||
real key bytes and real mouse reports. Nothing is mocked: the bot that answers is
|
||||
a real model behind the real gateway, so the LLM-driven case is probabilistic
|
||||
(a free, rotating backend) and says so in its failure text.
|
||||
|
||||
What is checked is state, not rendering: the server's own API answers whether the
|
||||
message was stored, whether the reply exists, whether the setting changed. The
|
||||
screen is only read for the one thing a screen is for, that the user can see it
|
||||
(the typed words and the reply appear in the output).
|
||||
|
||||
Skipped loudly (never silently) when the grr checkout, DEVPLACE_API_KEY or a free
|
||||
port is missing.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from tai_test import TestCase, TestAssertionFailed, TestTimeout, main_cli, strip_ansi # noqa: E402
|
||||
|
||||
GRR_DIR = Path(os.environ.get("GRR_DIR") or Path(__file__).resolve().parent.parent.parent / "grr")
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
class GrrServer:
|
||||
"""A throwaway grr server: own home, own ports, killed with its process group."""
|
||||
|
||||
def __init__(self):
|
||||
self.home = tempfile.mkdtemp(prefix="grr-it-")
|
||||
self.port = free_port()
|
||||
self.url = "http://127.0.0.1:%d" % self.port
|
||||
self.proc = None
|
||||
self.token = ""
|
||||
|
||||
def start(self):
|
||||
env = dict(os.environ, GRR_HOME=self.home, GRR_APPS_PORT=str(free_port()), PYTHONPATH=str(GRR_DIR))
|
||||
self.proc = subprocess.Popen([sys.executable, "-m", "grr", "serve", "--host", "127.0.0.1", "--port", str(self.port), "--no-reload"],
|
||||
cwd=str(GRR_DIR), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
self.call("GET", "/api/health")
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise TestAssertionFailed("the grr server did not answer within 30 s")
|
||||
login = self.call("POST", "/api/auth/login", {"use_env": True}, token="")
|
||||
self.token = login["token"]
|
||||
|
||||
def call(self, method, path, body=None, token=None):
|
||||
req = urllib.request.Request(self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={"Authorization": "Bearer " + (self.token if token is None else token), "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as res:
|
||||
return json.load(res)
|
||||
|
||||
def stop(self):
|
||||
if self.proc is not None:
|
||||
try:
|
||||
os.killpg(self.proc.pid, 15)
|
||||
self.proc.wait(10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
try:
|
||||
os.killpg(self.proc.pid, 9)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(self.home, ignore_errors=True)
|
||||
|
||||
|
||||
class GrrTuiCase(TestCase):
|
||||
mode = "pty"
|
||||
timeout = 240.0
|
||||
extra_args = ("--tui",)
|
||||
|
||||
async def setup(self):
|
||||
if not (GRR_DIR / "grr").is_dir():
|
||||
raise TestAssertionFailed("SKIPPED: no grr checkout at %s (set GRR_DIR)" % GRR_DIR)
|
||||
if not os.environ.get("DEVPLACE_API_KEY"):
|
||||
raise TestAssertionFailed("SKIPPED: DEVPLACE_API_KEY is not set")
|
||||
self.server = GrrServer()
|
||||
self.server.start()
|
||||
self.home = tempfile.mkdtemp(prefix="tai-it-")
|
||||
with open(os.path.join(self.home, "grr.json"), "w") as fh:
|
||||
json.dump({"url": self.server.url, "accounts": [{"token": self.server.token, "username": "it", "email": ""}], "active": self.server.token}, fh)
|
||||
os.chmod(os.path.join(self.home, "grr.json"), 0o600)
|
||||
self.env_overrides.update({"TAI_HOME": self.home, "TAI_PASSPHRASE": "", "GRR_URL": self.server.url, "TERM": "xterm-256color"})
|
||||
|
||||
async def teardown(self):
|
||||
self.server.stop()
|
||||
shutil.rmtree(self.home, ignore_errors=True)
|
||||
|
||||
async def start(self, rows=40, cols=130):
|
||||
"""The real binary on a pty of a known size (set before the app reads it)."""
|
||||
import fcntl
|
||||
import struct
|
||||
import termios
|
||||
proc = await self.spawn(mode="pty")
|
||||
fcntl.ioctl(proc._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
return proc
|
||||
|
||||
async def tui(self):
|
||||
proc = await self.start()
|
||||
await proc.wait_for_pattern(re.compile(r"Automations"), timeout=40)
|
||||
return proc
|
||||
|
||||
def type(self, proc, text):
|
||||
os.write(proc._master_fd, text.encode())
|
||||
|
||||
async def settle(self, proc, seconds=1.0):
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(proc._read_chunk(), timeout=0.2)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
if chunk:
|
||||
proc._record("stdout", chunk.decode("utf-8", "replace"))
|
||||
|
||||
def screen_text(self, proc, rows=40, cols=130):
|
||||
"""What the terminal shows now: the bytes so far replayed through tai's own VT emulator."""
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import tai
|
||||
raw = "".join(l.text for l in proc.transcript if l.stream == "stdout")
|
||||
vt = tai.TuiVtScreen(rows, cols)
|
||||
vt.feed(raw.encode("utf-8", "replace"))
|
||||
lines = ["".join((c[0] if c else " ") for c in vt.grid[r]).rstrip() for r in range(rows)]
|
||||
if os.environ.get("GRR_IT_DEBUG"):
|
||||
print("\n".join(l for l in lines if l), flush=True)
|
||||
print("-" * 40, flush=True)
|
||||
return "\n".join(lines)
|
||||
|
||||
def bot_chat(self, name):
|
||||
for bot in self.server.call("GET", "/api/bots")["bots"]:
|
||||
if bot["name"] == name:
|
||||
return bot, bot["chat"]["id"]
|
||||
raise TestAssertionFailed("no bot named %s" % name)
|
||||
|
||||
|
||||
class DrawerAndNavigationTest(GrrTuiCase):
|
||||
"""Deterministic: no model. The drawer lists the seed bots, Enter opens a
|
||||
chat, Esc goes back, the settings route opens by its shortcut and a switch
|
||||
flipped with Enter is saved by the server."""
|
||||
|
||||
name = "grr-tui-drawer-and-settings"
|
||||
|
||||
async def run(self):
|
||||
proc = await self.tui()
|
||||
await self.settle(proc, 1.5)
|
||||
text_before = "\n".join(l.text for l in proc.transcript if l.stream == "stdout")
|
||||
# the drawer names the three seed bots
|
||||
seen = strip_ansi(text_before)
|
||||
shown = {name: name in seen for name in ("Grr", "Smile", "Tai")}
|
||||
self.type(proc, "\x07") # Ctrl+G: settings
|
||||
await proc.wait_for_pattern(re.compile(r"Auto-review"), timeout=15)
|
||||
# account, usage, connect apps, then the Auto-review switch: three steps down, then flip it
|
||||
before = self.server.call("GET", "/api/me")["settings"]["auto_review"]
|
||||
for _ in range(3):
|
||||
self.type(proc, "\x1b[B")
|
||||
await self.settle(proc, 0.3)
|
||||
self.type(proc, "\r")
|
||||
await self.settle(proc, 1.5)
|
||||
after = self.server.call("GET", "/api/me")["settings"]["auto_review"]
|
||||
self.type(proc, "\x03")
|
||||
return {"shown": shown, "before": before, "after": after}
|
||||
|
||||
async def verify(self, result):
|
||||
missing = [name for name, ok in result["shown"].items() if not ok]
|
||||
if missing:
|
||||
raise TestAssertionFailed("the drawer did not show %s" % ", ".join(missing))
|
||||
flipped = [k for k in ("auto_review",) if result["before"] != result["after"]]
|
||||
if not flipped:
|
||||
raise TestAssertionFailed("the switch pressed with Enter did not change the server's setting (before=%r after=%r)" % (result["before"], result["after"]))
|
||||
|
||||
|
||||
class ChatRoundTripTest(GrrTuiCase):
|
||||
"""LLM-driven (a free, rotating backend: the exact model differs per run, so
|
||||
the reply text is not asserted, only that a real reply exists). Types a
|
||||
message to a bot in the TUI and checks, in the server's own message list,
|
||||
that the user message was stored, a bot reply followed, and that the reply
|
||||
reached the screen."""
|
||||
|
||||
name = "grr-tui-chat-round-trip"
|
||||
|
||||
async def run(self):
|
||||
bot, chat_id = self.bot_chat("Tai")
|
||||
proc = await self.tui()
|
||||
# open the Tai chat from the drawer by clicking its row, then type a message
|
||||
self.type(proc, "\x1b[B" * 5)
|
||||
await self.settle(proc, 0.6)
|
||||
self.type(proc, "\r")
|
||||
await proc.wait_for_pattern(re.compile(r"Ask Tai"), timeout=20)
|
||||
token = "pineapple%d" % (int(time.time()) % 1000)
|
||||
self.type(proc, "Reply with just the word %s and nothing else" % token)
|
||||
await self.settle(proc, 0.4)
|
||||
self.type(proc, "\r")
|
||||
reply = ""
|
||||
deadline = time.monotonic() + 150
|
||||
while time.monotonic() < deadline:
|
||||
await self.settle(proc, 2.0)
|
||||
messages = self.server.call("GET", "/api/chats/%s/messages" % chat_id)["messages"]
|
||||
replies = [m for m in messages if m["role"] == "assistant" and m["kind"] == "text"]
|
||||
if replies:
|
||||
reply = replies[-1]["content"]
|
||||
break
|
||||
await self.settle(proc, 2.0)
|
||||
screen = strip_ansi("\n".join(l.text for l in proc.transcript if l.stream == "stdout"))
|
||||
self.type(proc, "\x03")
|
||||
messages = self.server.call("GET", "/api/chats/%s/messages" % chat_id)["messages"]
|
||||
return {"token": token, "messages": messages, "reply": reply, "screen": screen}
|
||||
|
||||
async def verify(self, result):
|
||||
mine = [m for m in result["messages"] if m["role"] == "user" and result["token"] in m["content"]]
|
||||
if not mine:
|
||||
raise TestAssertionFailed("the server never stored the message typed in the TUI")
|
||||
if not result["reply"]:
|
||||
raise TestAssertionFailed("no bot reply within 150 s (probabilistic: the free backend may have been down or slow)")
|
||||
if result["token"] not in result["screen"]:
|
||||
raise TestAssertionFailed("the typed message never appeared on the screen")
|
||||
if result["reply"][:12] not in result["screen"]:
|
||||
raise TestAssertionFailed("the reply %r exists on the server but never reached the screen" % result["reply"][:40])
|
||||
|
||||
|
||||
class NewBotAndEditTest(GrrTuiCase):
|
||||
"""Deterministic: no model. Creates a bot from the drawer's plus menu and
|
||||
renames it through the profile's editor dialog; the server must hold the new
|
||||
name."""
|
||||
|
||||
name = "grr-tui-new-bot-and-rename"
|
||||
|
||||
async def run(self):
|
||||
before = {b["id"] for b in self.server.call("GET", "/api/bots")["bots"]}
|
||||
proc = await self.tui()
|
||||
# the drawer: me, three nav rows, then the Bots header with its plus
|
||||
self.type(proc, "\x1b[B" * 4)
|
||||
await self.settle(proc, 0.5)
|
||||
self.screen_text(proc)
|
||||
self.type(proc, "\r") # opens the menu
|
||||
await self.settle(proc, 0.8)
|
||||
self.screen_text(proc)
|
||||
self.type(proc, "\r") # New Bot
|
||||
await proc.wait_for_pattern(re.compile(r"Documents"), timeout=20)
|
||||
await self.settle(proc, 1.0)
|
||||
# a new bot opens its profile with the name editor already open
|
||||
self.type(proc, "\x7f" * 20 + "Rocket")
|
||||
await self.settle(proc, 0.4)
|
||||
self.type(proc, "\r")
|
||||
await self.settle(proc, 2.0)
|
||||
self.type(proc, "\x03")
|
||||
after = self.server.call("GET", "/api/bots")["bots"]
|
||||
return {"new": [b for b in after if b["id"] not in before]}
|
||||
|
||||
async def verify(self, result):
|
||||
if len(result["new"]) != 1:
|
||||
raise TestAssertionFailed("expected exactly one new bot, found %d" % len(result["new"]))
|
||||
if result["new"][0]["name"] != "Rocket":
|
||||
raise TestAssertionFailed("the bot was not renamed in the profile editor: its name is %r" % result["new"][0]["name"])
|
||||
|
||||
|
||||
class NarrowPhoneTest(GrrTuiCase):
|
||||
"""Deterministic: a 40 column terminal shows the drawer alone, Enter opens a
|
||||
chat full width, Esc returns, and no output line is wider than the terminal."""
|
||||
|
||||
name = "grr-tui-narrow-terminal"
|
||||
|
||||
async def run(self):
|
||||
proc = await self.start(30, 40)
|
||||
await proc.wait_for_pattern(re.compile(r"Automations"), timeout=40)
|
||||
await self.settle(proc, 1.0)
|
||||
self.type(proc, "\x1b[B" * 5)
|
||||
await self.settle(proc, 0.5)
|
||||
self.type(proc, "\r")
|
||||
await proc.wait_for_pattern(re.compile(r"Ask "), timeout=20)
|
||||
await self.settle(proc, 0.5)
|
||||
self.type(proc, "\x1b")
|
||||
await self.settle(proc, 1.0)
|
||||
screen = strip_ansi("\n".join(l.text for l in proc.transcript if l.stream == "stdout"))
|
||||
self.type(proc, "\x03")
|
||||
return {"screen": screen}
|
||||
|
||||
async def verify(self, result):
|
||||
if "Ask " not in result["screen"]:
|
||||
raise TestAssertionFailed("the chat did not open on a 40 column terminal")
|
||||
if result["screen"].count("Automations") < 2:
|
||||
raise TestAssertionFailed("Esc did not bring the drawer back")
|
||||
|
||||
|
||||
ALL_CASES = [DrawerAndNavigationTest(), NewBotAndEditTest(), NarrowPhoneTest(), ChatRoundTripTest()]
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))
|
||||
@@ -405,9 +405,10 @@ not change; without the invalidation only cells whose text changed got the
|
||||
new colors and old-theme speckles stayed on spaces. F4 cycles registry order in the chat scene. To change a color, edit
|
||||
one value in `_tui_builtin_themes()` or register a custom theme.
|
||||
|
||||
## Chat scene (`--tui`)
|
||||
## Chat scene (`--tui-local`)
|
||||
|
||||
`./tai.py --tui` runs the fullscreen chat UI with REPL parity: a
|
||||
`./tai.py --tui-local` runs the fullscreen chat UI on the local vault with REPL parity (`./tai.py --tui` is the grr
|
||||
terminal client, specified in [grrtui.md](grrtui.md), built on the same framework): a
|
||||
borderless, colored layout. A `panel`-tinted bots sidebar (colored
|
||||
avatar chip per bot, accent bar on the selected chat, green dot while a
|
||||
turn runs, red `Draft:` hint), a header with avatar, name and an
|
||||
|
||||
Reference in New Issue
Block a user