From 690264013cef6346a66fbacaf50425b1ca3977d8 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 17 Oct 2026 19:48:30 +0200 Subject: [PATCH] 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 --- AGENTS.md | 1 + README.md | 5 +- grrtui.md | 217 ++ tai.py | 6603 +++++++++++++++++++++++++++++++++++++++++++++- test_tai.py | 1027 +++++++ tests/grr_tui.py | 310 +++ tui.md | 5 +- 7 files changed, 8161 insertions(+), 7 deletions(-) create mode 100644 grrtui.md create mode 100644 tests/grr_tui.py diff --git a/AGENTS.md b/AGENTS.md index 278f7fa..d27506c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..` (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/.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 diff --git a/README.md b/README.md index 7bebd54..48c6cf3 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/grrtui.md b/grrtui.md new file mode 100644 index 0000000..d79acab --- /dev/null +++ b/grrtui.md @@ -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 +`/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). diff --git a/tai.py b/tai.py index 0918401..a9ec28d 100755 --- a/tai.py +++ b/tai.py @@ -13,6 +13,7 @@ import hashlib import importlib.util import hmac import html +import http.client import io import itertools import json @@ -100,7 +101,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.36.1" +VERSION = "1.37.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -23506,6 +23507,6599 @@ def tui_chat(agent, theme_name): owner.store.save_session(owner.profile, owner.messages, owner.bot, ctx=getattr(owner, "ctx_id", "")) +# --------------------------------------------------------------------------- +# Grr TUI: the terminal client of the grr server. Specified in grrtui.md. +# Part A: constants, palette, strings, formatting, avatars, HTTP and event +# stream client, saved accounts, store, router. +# --------------------------------------------------------------------------- + +GRR_DEFAULT_URL = "http://127.0.0.1:8765" +GRR_HTTP_TIMEOUT = 30 +GRR_SSE_TIMEOUT = 60 +GRR_SSE_RETRY = 2.0 +GRR_SSE_LINE_MAX = 1 << 20 +GRR_BODY_MAX = 32 << 20 +GRR_SIDE_W = 34 +GRR_SIDE_MIN_COLS = 100 +GRR_PANE_MIN_COLS = 150 +GRR_PANE_MIN_W = 40 +GRR_PANE_MAX_W = 90 +GRR_MESSAGE_KEEP = 400 +GRR_TOAST_SECS = 3.2 +GRR_BANNER_SECS = 5.2 +GRR_TOAST_MAX = 4 +GRR_COMPOSER_MAX_ROWS = 6 +GRR_VIEWER_MAX_BYTES = 2 << 20 +GRR_DRAFT_KEEP = 64 +GRR_LIST_MORE = 12 +GRR_MENU_MAX_H = 14 + +GRR_PALETTE_FALLBACK = { + "night": {"bg": "#0d0c0a", "raised": "#15130f", "sunken": "#090807", "card": "#1c1a15", "ink": "#ece6da", "soft": "#cfc8ba", + "muted": "#8c857a", "faint": "#5b564e", "rule": "#2a2722", "rule-strong": "#3d3932", "accent": "#d4a020", + "accent-ink": "#120f08", "success": "#7fa36b", "danger": "#c8644f", "info": "#7f9eb0", "bubble-user": "#3d3932", + "bubble-bot": "#1c1a15", "code-bg": "#12110e"}, + "day": {"bg": "#f6f1e6", "raised": "#fffaf0", "sunken": "#ece5d6", "card": "#fffaf0", "ink": "#1b1812", "soft": "#3d392f", + "muted": "#6c675e", "faint": "#a49d8e", "rule": "#e0d8c6", "rule-strong": "#c9bfa8", "accent": "#b07d0a", + "accent-ink": "#fffaf0", "success": "#4f7a3a", "danger": "#b0432e", "info": "#41697f", "bubble-user": "#e5dcc6", + "bubble-bot": "#fffaf0", "code-bg": "#efe7d4"}, + "variants": {"black": {"night": None, "day": None}, + "blue": {"night": {"accent": "#7f9eb0", "accent-ink": "#0a1015"}, "day": {"accent": "#41697f", "accent-ink": "#f6f1e6"}}}, + "bot_colors": ["#ffffff", "#9a6a3a", "#e5484d", "#f76b15", "#d4a020", "#30a46c", "#12a594", "#3e63dd", "#8e4ec6", "#e93d82", "#8c857a"], +} + +GRR_SHAPES = ("blob", "cloud", "tilt", "plus", "flower", "leaf", "pebble", "oval", "play", "gem", "cog", "hex") + +# Two rows of seven cells per shape; "E" marks an eye cell, drawn in the dark eye color. +GRR_AVATAR_ROWS = { + "blob": ("▗█E█E█▖", "▝█E█E█▘"), + "cloud": ("▟█E█E█▙", "▜█E█E█▛"), + "tilt": (" ▟E█E█▘", "▗▜E█E█ "), + "plus": ("▄▟E█E█▄", "▀▜E█E█▀"), + "flower": ("▗▜E█E█▖", "▝▟E█E█▘"), + "leaf": ("▗█E█E█▛", "▜█E█E█▖"), + "pebble": ("▗█E█E█▖", "▝█E█E█▘"), + "oval": (" ▟E█E▙ ", " ▜E█E▛ "), + "play": ("▛█E█E█▙", "▙█E█E█▛"), + "gem": ("▗▟E█E█▖", " ▜E█E█▘"), + "cog": ("▟▀E▀E▀▙", "▜▄E▄E▄▛"), + "hex": ("▟█E█E█▙", "▜█E█E█▛"), +} + +GRR_ICONS = { + "back": "‹", "close": "✕", "plus": "+", "search": "⌕", "gear": "⚙", "compose": "✎", "more": "⋯", "moreV": "⋮", "share": "⇧", + "check": "✓", "right": "›", "down": "▾", "up": "▴", "trash": "✗", "copy": "❐", "mic": "●", "wave": "≈", "send": "↑", "stop": "■", + "camera": "◘", "image": "▨", "file": "▤", "skills": "▦", "plug": "◈", "incognito": "◐", "keyboard": "▭", "clipboard": "▥", + "help": "?", "bell": "♪", "sun": "☼", "moon": "☾", "contrast": "◑", "globe": "◍", "key": "⌘", "user": "☺", "folder": "▣", + "box": "▢", "automation": "◷", "star": "★", "download": "⇩", "forward": "↪", "tune": "≡", "filter": "▽", "terminal": "❯", + "link": "∞", "external": "↗", "sparkle": "✦", "thumbup": "▲", "thumbdown": "▼", "speaker": "♫", "refresh": "↻", "hash": "#", + "group": "☻", "pause": "‖", "play": "▶", "clock": "◷", "pin": "⚑", "edit": "✎", "eye": "◉", "inbox": "▥", "rocket": "➤", + "panel": "◨", "phone": "▯", "code": "‹", "expand": "⇱", "shrink": "⇲", "bolt": "ϟ", "monitor": "▭", "list": "≣", "cpu": "▦", + "warn": "!", "dot": "●", +} + + +class GrrError(Exception): + """A failed call to the grr server: HTTP status (0 for no answer), the + stable error code, the words for the user and an optional hint.""" + + def __init__(self, status, code, message, hint="", vars=None): + super().__init__(message) + self.status = status + self.code = code + self.hint = hint + self.vars = vars or {} + + def text(self): + return (self.args[0] + (" " + self.hint if self.hint else "")).strip() + + +GRR_STRING_FALLBACK = {"library.sort_new": "Newest first", "library.sort_old": "Oldest first", "library.sort_name": "Name", "library.sort_size": "Largest first"} + + +class GrrStrings: + """The server's own string table (`/locales.json`), so the TUI says the + same words as the web app in every language, from one source.""" + + ALIAS = {"bad_value": "invalid", "bad_kind": "invalid", "bad_mode": "invalid", "bad_shape": "invalid", "bad_color": "invalid", + "bad_action": "invalid", "bad_decision": "invalid", "unknown_setting": "invalid", "bot_required": "invalid", + "bad_schedule": "invalid", "bad_json": "invalid"} + + def __init__(self, table=None, lang="en"): + self.table = table or {} + self.lang = lang if lang in self.table else "en" + + def has(self, key): + return key in self.table.get(self.lang, {}) or key in self.table.get("en", {}) + + def t(self, key, **vars): + text = self.table.get(self.lang, {}).get(key) + if text is None: + text = self.table.get("en", {}).get(key) + if text is None: + text = GRR_STRING_FALLBACK.get(key, key) + for name, value in vars.items(): + text = text.replace("{" + name + "}", str(value)) + return text + + def use(self, setting, languages=(), env=None): + self.lang = GrrStrings.resolve(setting, languages, env if env is not None else os.environ) + if self.lang not in self.table: + self.lang = "en" + return self.lang + + @staticmethod + def resolve(setting, languages, env): + if setting and setting != "system": + return setting + raw = (env.get("LC_ALL") or env.get("LC_MESSAGES") or env.get("LANG") or "en").split(".")[0].replace("_", "-") + low = raw.lower() + for known in languages: + if known.lower() == low: + return known + if low in ("zh-tw", "zh-hk"): + return "zh-TW" + base = low.split("-")[0] + for known in languages: + if known.lower() == base: + return known + return "en" + + def explain(self, status, body): + """Words for an error body: the localized `apierr.` when there is + one, else the server's own message.""" + err = (body or {}).get("error") or {} if isinstance(body, dict) else {} + code = err.get("code") or "http" + vars = err.get("vars") or {} + alias = GrrStrings.ALIAS.get(code) or ("missing" if code.startswith("no_") else "") + key = "apierr." + code if self.has("apierr." + code) else ("apierr." + alias if alias and self.has("apierr." + alias) else "") + if key: + hint = self.t(key + "_hint") if self.has(key + "_hint") else "" + return GrrError(status, code, self.t(key, **vars), hint, vars) + return GrrError(status, code, err.get("message") or ("HTTP %d" % status), err.get("hint") or "", vars) + + +class GrrPalette: + """The grr colors for one mode (night or day) and variant, read from the + server's spec so the TUI never carries its own copy of the brand.""" + + def __init__(self, spec=None, theme="system", variant="black"): + palette = (spec or {}).get("palette") or GRR_PALETTE_FALLBACK + self.mode = GrrPalette.mode_of(theme) + base = dict(GRR_PALETTE_FALLBACK[self.mode]) + base.update(palette.get(self.mode) or {}) + change = ((palette.get("variants") or {}).get(variant) or {}).get(self.mode) + if change: + base.update(change) + self.colors = base + self.variant = variant + self.bot_colors = list(palette.get("bot_colors") or GRR_PALETTE_FALLBACK["bot_colors"]) + self.name = "grr-%s-%s" % (self.mode, variant) + + @staticmethod + def mode_of(theme): + if theme in ("day", "night"): + return theme + hint = os.environ.get("COLORFGBG", "") + if hint and hint.split(";")[-1].isdigit() and int(hint.split(";")[-1]) in (7, 15): + return "day" + return "night" + + def c(self, name): + return self.colors.get(name) or self.colors["ink"] + + def tui_theme(self): + c = self.c + roles = {"bg": c("bg"), "surface": c("card"), "fg": c("ink"), "muted": c("muted"), "border": c("rule-strong"), + "accent": c("accent"), "on_accent": c("accent-ink"), "selection": c("rule-strong"), "success": c("success"), + "warning": c("accent"), "error": c("danger"), "info": c("info"), "bubble_in": c("bubble-bot"), + "bubble_out": c("bubble-user"), "bubble_fg": c("ink"), "meta_in": c("muted"), "meta_out": c("soft"), + "tick_read": c("info"), "service_bg": c("rule"), "service_fg": c("muted"), "unread": c("accent"), + "unread_muted": c("faint"), "panel": c("raised"), "accent2": c("info"), "accent3": c("accent"), + "code_bg": c("code-bg"), "tool_bg": c("sunken")} + return TuiTheme(self.name, self.mode == "night", roles) + + def apply(self): + tui_register_theme(self.tui_theme()) + tui_set_theme(self.name) + + @staticmethod + def light(color): + try: + n = int(color.lstrip("#"), 16) + except ValueError: + return False + return (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000 > 150 + + +class GrrUi: + """What every drawing routine needs: palette, strings, formatting, style + cache. One instance per app, replaced as a whole on a theme change.""" + + def __init__(self, palette, strings, spec=None): + self.palette = palette + self.strings = strings + self.spec = spec or {} + self.version = 1 + self._styles = {} + self.fmt = GrrFormat(strings) + + def t(self, key, **vars): + return self.strings.t(key, **vars) + + def st(self, fg="ink", bg="bg", bold=False, dim=False, underline=False, reverse=False): + key = (fg, bg, bold, dim, underline, reverse) + style = self._styles.get(key) + if style is None: + pal = self.palette + style = self._styles[key] = TuiStyle(fg=pal.c(fg) if not fg.startswith("#") else fg, + bg=pal.c(bg) if not bg.startswith("#") else bg, + bold=bold, dim=dim, underline=underline, reverse=reverse) + return style + + def cap(self, name, default): + return ((self.spec.get("caps") or {}).get(name)) or default + + def icon(self, name): + return GRR_ICONS.get(name, "?") + + +class GrrFormat: + """Times, sizes and schedules in the words of the server's string table.""" + + def __init__(self, strings): + self.s = strings + + @staticmethod + def when(ts): + return datetime.fromtimestamp(float(ts)) + + def clock(self, ts): + return GrrFormat.when(ts).strftime("%H:%M") + + def list_time(self, ts): + if not ts: + return "" + d, now = GrrFormat.when(ts), datetime.now() + if d.date() == now.date(): + return self.clock(ts) + if d.date() == (now - timedelta(days=1)).date(): + return self.s.t("time.yesterday") + if (now - d).days < 6: + return d.strftime("%A") + return "%d %s" % (d.day, d.strftime("%b")) + + def stamp(self, ts): + d, now = GrrFormat.when(ts), datetime.now() + if d.date() == now.date(): + return self.s.t("time.today") + " " + self.clock(ts) + if d.date() == (now - timedelta(days=1)).date(): + return self.s.t("time.yesterday") + " " + self.clock(ts) + if (now - d).days < 6: + return d.strftime("%A") + " " + self.clock(ts) + return "%d %s %s" % (d.day, d.strftime("%b"), self.clock(ts)) + + def short_date(self, ts): + d = GrrFormat.when(ts) + return "%d %s" % (d.day, d.strftime("%b")) + + def ago(self, ts): + secs = int(float(ts) - time.time()) + for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)): + if abs(secs) >= size: + n = int(round(secs / float(size))) + if unit == "day" and n == -1: + return "yesterday" + word = "%d %s%s" % (abs(n), unit, "" if abs(n) == 1 else "s") + return word + " ago" if n < 0 else "in " + word + return "now" + + @staticmethod + def bytes(n): + n = int(n or 0) + if n < 1024: + return "%d B" % n + if n < 1048576: + return ("%.1f kB" if n < 10240 else "%.0f kB") % (n / 1024.0) + if n < 1073741824: + return "%.1f MB" % (n / 1048576.0) + return "%.2f GB" % (n / 1073741824.0) + + @staticmethod + def num(n): + return "{:,}".format(int(n or 0)) + + @staticmethod + def weekday(i): + return ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")[int(i) % 7] + + @staticmethod + def clock_label(hhmm): + return hhmm + + def schedule(self, s): + if not s: + return "" + if s.get("kind") == "interval": + sec = int(s.get("every_seconds") or 0) + if sec and sec % 86400 == 0: + return self.s.t("sched.every_days", n=sec // 86400) + if sec and sec % 3600 == 0: + return self.s.t("sched.every_hours", n=sec // 3600) + if sec and sec % 60 == 0: + return self.s.t("sched.every_minutes", n=sec // 60) + return self.s.t("sched.every_seconds", n=sec) + if s.get("kind") == "daily": + return self.s.t("sched.daily", time=s.get("time", "")) + return self.s.t("sched.weekly", day=GrrFormat.weekday(s.get("day", 0)), time=s.get("time", "")) + + @staticmethod + def next_run(ts): + return GrrFormat.when(ts).strftime("%a %d %b %H:%M") if ts else "-" + + @staticmethod + def domain(url): + try: + host = urllib.parse.urlsplit(url).hostname or url + except ValueError: + return url + return host[4:] if host.startswith("www.") else host + + +class GrrAvatar: + """A bot's mark in cells: the character shape with two eyes. `rows` + draws it 2 rows tall (5 wide when `small`, 7 wide otherwise; `hero` + makes it 4 rows), `chip` is one row, `chip_rows` a tiny 2x2 body.""" + + @staticmethod + def eye_color(color): + return "#1b1812" if GrrPalette.light(color) else "#120f08" + + @staticmethod + def rows(ui, bot, bg="bg", small=False, hero=False): + shape = (bot or {}).get("shape") or "blob" + color = (bot or {}).get("color") or ui.palette.c("accent") + body = TuiStyle(fg=color, bg=ui.palette.c(bg)) + dark = TuiStyle(fg=GrrAvatar.eye_color(color), bg=color) + top, bottom = GRR_AVATAR_ROWS.get(shape, GRR_AVATAR_ROWS["blob"]) + if small: + top = top[0] + "E█E" + top[-1] + bottom = bottom[0] + "E█E" + bottom[-1] + templates = [top, bottom] + if hero: + mid = "█" + top[1:-1] + "█" + templates = [top, mid, mid, bottom] + out = [] + for template in templates: + spans = [] + for ch in template: + spans.append(TuiSpan("▌", dark) if ch == "E" else TuiSpan(ch, body)) + out.append(tui_merge_spans(spans)) + return out + + @staticmethod + def chip(ui, bot, bg="bg"): + color = (bot or {}).get("color") or ui.palette.c("accent") + return [TuiSpan(" ▮ ▮ ", TuiStyle(fg=GrrAvatar.eye_color(color), bg=color))] + + @staticmethod + def chip_rows(ui, bot, bg="bg"): + color = (bot or {}).get("color") or ui.palette.c("accent") + body = TuiStyle(fg=color, bg=ui.palette.c(bg)) + return [[TuiSpan("▟▙", body)], [TuiSpan("▜▛", body)]] + + +def tui_merge_spans(spans): + """Join neighbouring spans that share a style into one.""" + out = [] + for span in spans: + if out and out[-1].style is span.style: + out[-1] = TuiSpan(out[-1].text + span.text, span.style) + else: + out.append(span) + return out + + +class GrrClient: + """Blocking JSON client of the grr REST API on the standard library. + Each call opens its own connection, so any thread may use it. Call it + from a worker (`app.offload`), never from the loop.""" + + def __init__(self, base=GRR_DEFAULT_URL, token="", strings=None, timeout=GRR_HTTP_TIMEOUT): + self.base = (base or GRR_DEFAULT_URL).rstrip("/") + self.token = token + self.strings = strings or GrrStrings() + self.timeout = timeout + self.on_unauthorized = None + + def _conn(self, timeout=None): + parts = urllib.parse.urlsplit(self.base) + host = parts.hostname or "127.0.0.1" + cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection + return cls(host, parts.port or (443 if parts.scheme == "https" else 80), timeout=timeout or self.timeout) + + def request(self, method, path, body=None, raw=None, ctype="", timeout=None, quiet=False): + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = "Bearer " + self.token + payload = None + if raw is not None: + payload = raw + headers["Content-Type"] = ctype or "application/octet-stream" + elif body is not None: + payload = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + conn = self._conn(timeout) + try: + conn.request(method, path, body=payload, headers=headers) + res = conn.getresponse() + data = res.read(GRR_BODY_MAX + 1) + except (OSError, http.client.HTTPException) as exc: + raise GrrError(0, "offline", self.strings.t("err.offline"), "", {"reason": str(exc)}) + finally: + conn.close() + if len(data) > GRR_BODY_MAX: + raise GrrError(res.status, "too_big", "The answer was larger than %s." % GrrFormat.bytes(GRR_BODY_MAX)) + if res.status == 204: + return {} + try: + parsed = json.loads(data.decode("utf-8")) if data else {} + except ValueError: + parsed = None + if res.status >= 400: + if res.status == 401 and not quiet and callable(self.on_unauthorized): + self.on_unauthorized() + raise self.strings.explain(res.status, parsed) + return parsed if parsed is not None else {} + + def get(self, path, **kw): + return self.request("GET", path, **kw) + + def post(self, path, body=None, **kw): + return self.request("POST", path, {} if body is None else body, **kw) + + def patch(self, path, body, **kw): + return self.request("PATCH", path, body, **kw) + + def put(self, path, body, **kw): + return self.request("PUT", path, body, **kw) + + def delete(self, path, body=None, **kw): + return self.request("DELETE", path, body, **kw) + + def upload(self, name, data, chat="", bot=""): + query = urllib.parse.urlencode({k: v for k, v in (("name", name), ("chat", chat), ("bot", bot)) if v}) + return self.request("POST", "/api/uploads?" + query, raw=data) + + def url(self, path): + return self.base + path + ("&" if "?" in path else "?") + "token=" + urllib.parse.quote(self.token) + + def file_url(self, file_id, download=False): + return self.url("/api/files/%s%s" % (file_id, "?download=1" if download else "")) + + def fetch(self, path, limit=GRR_VIEWER_MAX_BYTES): + """The raw bytes of a file path, capped at limit, with the type.""" + headers = {"Authorization": "Bearer " + self.token} if self.token else {} + conn = self._conn() + try: + conn.request("GET", path, headers=headers) + res = conn.getresponse() + data = res.read(limit + 1) + if res.status >= 400: + raise GrrError(res.status, "http", "HTTP %d" % res.status) + return data[:limit], len(data) > limit, res.getheader("Content-Type") or "" + except (OSError, http.client.HTTPException) as exc: + raise GrrError(0, "offline", self.strings.t("err.offline"), "", {"reason": str(exc)}) + finally: + conn.close() + + +class GrrStream: + """Server-sent events on a thread of its own. `on_event(name, data)` and + `on_link(up)` are posted to the app loop through `post`. It reconnects + every GRR_SSE_RETRY seconds until stop(); a line over GRR_SSE_LINE_MAX + ends the connection (it is counted, never parsed).""" + + def __init__(self, client, path, on_event, post, on_link=None, reconnect=True): + self.client = client + self.path = path + self.on_event = on_event + self.on_link = on_link + self.post = post + self.reconnect = reconnect + self.wanted = False + self.thread = None + self.conn = None + self.oversize = 0 + self.sock_lock = threading.Lock() + + def start(self): + if self.thread is not None and self.thread.is_alive(): + return + self.wanted = True + self.thread = threading.Thread(target=self.run, name="grr-stream", daemon=True) + self.thread.start() + + def stop(self): + self.wanted = False + with self.sock_lock: + conn = self.conn + if conn is not None: + try: + if conn.sock is not None: + conn.sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + conn.close() + except OSError: + pass + + def run(self): + while self.wanted: + try: + self.pump() + except (OSError, http.client.HTTPException, ValueError): + pass + if self.on_link is not None: + self.post(lambda: self.on_link(False)) + if not self.reconnect: + return + waited = 0.0 + while self.wanted and waited < GRR_SSE_RETRY: + time.sleep(0.1) + waited += 0.1 + + def pump(self): + conn = self.client._conn(GRR_SSE_TIMEOUT) + with self.sock_lock: + self.conn = conn + try: + headers = {"Accept": "text/event-stream"} + if self.client.token: + headers["Authorization"] = "Bearer " + self.client.token + conn.request("GET", self.path, headers=headers) + res = conn.getresponse() + if res.status != 200: + return + if self.on_link is not None: + self.post(lambda: self.on_link(True)) + name, data = "message", [] + while self.wanted: + line = res.readline(GRR_SSE_LINE_MAX + 1) + if not line: + return + if len(line) > GRR_SSE_LINE_MAX: + self.oversize += 1 + return + text = line.decode("utf-8", "replace").rstrip("\r\n") + if not text: + if data: + self.emit(name, "\n".join(data)) + name, data = "message", [] + elif text.startswith(":"): + continue + elif text.startswith("event:"): + name = text[6:].strip() + elif text.startswith("data:"): + data.append(text[5:].lstrip(" ")) + finally: + conn.close() + + def emit(self, name, raw): + try: + data = json.loads(raw) + except ValueError: + data = {"raw": raw} + self.post(lambda: self.on_event(name, data)) + + +class GrrSession: + """The signed-in accounts and the server address, kept in + `/grr.json` (mode 0600). The token is the grr session token, + not the DevPlace key.""" + + def __init__(self, path=None): + home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") + self.path = path or os.path.join(home, "grr.json") + self.url = os.environ.get("GRR_URL") or GRR_DEFAULT_URL + self.accounts = [] + self.active = "" + self.load() + + def load(self): + try: + with open(self.path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return + if not os.environ.get("GRR_URL"): + self.url = data.get("url") or self.url + self.accounts = [a for a in data.get("accounts", []) if isinstance(a, dict) and a.get("token")] + self.active = data.get("active") or "" + + def save(self): + os.makedirs(os.path.dirname(self.path), exist_ok=True) + tmp = self.path + ".tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump({"url": self.url, "accounts": self.accounts, "active": self.active}, fh) + os.replace(tmp, self.path) + + @property + def token(self): + for account in self.accounts: + if account["token"] == self.active: + return self.active + return self.accounts[0]["token"] if self.accounts else "" + + def add(self, token, account): + self.accounts = [a for a in self.accounts if a.get("username") != account.get("username")] + self.accounts.append({"token": token, "username": account.get("username", ""), "email": account.get("email", "")}) + self.active = token + self.save() + + def switch(self, token): + self.active = token + self.save() + + def drop(self, token): + self.accounts = [a for a in self.accounts if a["token"] != token] + if self.active == token: + self.active = self.accounts[0]["token"] if self.accounts else "" + self.save() + + +class GrrEmitter: + """Tiny publish and subscribe. A handler that raises is counted in + `errors` and never stops the others.""" + + def __init__(self): + self.handlers = {} + self.errors = 0 + + def on(self, kind, fn): + self.handlers.setdefault(kind, []).append(fn) + return lambda: self.off(kind, fn) + + def off(self, kind, fn): + if fn in self.handlers.get(kind, []): + self.handlers[kind].remove(fn) + + def emit(self, kind, data=None): + for fn in list(self.handlers.get(kind, [])): + try: + fn(data) + except Exception: + self.errors += 1 + + +class GrrStore(GrrEmitter): + """Bots, chats and live working status, kept fresh by the event stream. + All state is touched on the loop thread only.""" + + def __init__(self, app, client): + super().__init__() + self.app = app + self.client = client + self.bots = [] + self.chats = [] + self.status = {} + self.turn = 0 + self.pending = None + + def refresh(self, done=None): + self.turn += 1 + turn = self.turn + + def work(): + return self.client.get("/api/bots"), self.client.get("/api/chats") + + def landed(result, error): + if error is None and turn == self.turn: + self.bots = result[0].get("bots", []) + self.chats = result[1].get("chats", []) + self.emit("changed") + if done is not None: + done(error) + self.app.offload(work, landed) + + def soon(self): + if self.pending is not None: + return + self.pending = self.app.after(0.15, self._fire) + + def _fire(self): + self.pending = None + self.refresh() + + def bot(self, bot_id): + for bot in self.bots: + if bot["id"] == bot_id: + return bot + return None + + def chat(self, chat_id): + for chat in self.chats: + if chat["id"] == chat_id: + return chat + return None + + def status_of(self, chat_id, bot_id=""): + return self.status.get("%s:%s" % (chat_id, bot_id)) + + def set_status(self, data): + key = "%s:%s" % (data.get("chat_id"), data.get("bot_id") or "") + if data.get("state") == "idle": + self.status.pop(key, None) + else: + self.status[key] = data + self.emit("status", data) + + def is_working(self, bot_id): + for key, value in self.status.items(): + if key.endswith(":" + bot_id) and value.get("state") != "idle": + return value + return None + + def active_in(self, chat_id): + return [v for v in self.status.values() if v.get("chat_id") == chat_id and v.get("state") != "idle"] + + +class GrrRoute: + """A path pattern like `/bot/:id/skills` and the view factory it opens.""" + + def __init__(self, pattern, factory): + self.pattern = pattern + self.factory = factory + self.names = re.findall(r":([a-z_]+)", pattern) + self.rx = re.compile("^" + re.sub(r":[a-z_]+", "([^/]+)", pattern) + "$") + + +class GrrRouter: + """Hash-style routes with a back stack, like the web app's router. The + stack is capped at 60. `on_route(route, params, query, raw)` fires on + every change.""" + + STACK_MAX = 60 + + def __init__(self, on_route): + self.routes = [] + self.stack = [] + self.on_route = on_route + + def add(self, pattern, factory): + self.routes.append(GrrRoute(pattern, factory)) + + @staticmethod + def parse(raw): + raw = (raw or "/").lstrip("#") or "/" + path, _, qs = raw.partition("?") + query = {k: v[-1] for k, v in urllib.parse.parse_qs(qs).items()} + return path or "/", query + + def match(self, path): + for route in self.routes: + m = route.rx.match(path) + if m: + return route, {n: urllib.parse.unquote(m.group(i + 1)) for i, n in enumerate(route.names)} + return None, {} + + def go(self, raw, replace=False): + raw = raw or "/" + if replace and self.stack: + self.stack[-1] = raw + elif len(self.stack) >= 2 and self.stack[-2] == raw: + self.stack.pop() + elif not self.stack or self.stack[-1] != raw: + self.stack.append(raw) + if len(self.stack) > GrrRouter.STACK_MAX: + del self.stack[0] + self.fire(raw) + + def fire(self, raw): + path, query = GrrRouter.parse(raw) + route, params = self.match(path) + if route is None: + route, params = self.match("/") + self.on_route(route, params, query, raw) + + def back(self, fallback="/"): + if len(self.stack) > 1: + self.stack.pop() + self.fire(self.stack[-1]) + else: + self.go(fallback, replace=True) + + @property + def current(self): + return self.stack[-1] if self.stack else "/" + + +# --------------------------------------------------------------------------- +# Part B: the page engine. A screen body is a list of items (rows in cards, +# conversation rows, buttons, tabs, tiles ...). The page scrolls them, moves a +# selection with the keyboard and routes clicks to the item under the pointer. +# --------------------------------------------------------------------------- + + +def grr_clip(text, width): + text = str(text).replace("\n", " ") + if width <= 0: + return "" + return text if len(text) <= width else text[:max(0, width - 1)] + "…" + + +def grr_wrap(text, width): + lines = [] + for para in str(text).split("\n"): + lines.extend(textwrap.wrap(para, max(1, width), replace_whitespace=False, drop_whitespace=True, break_long_words=True) or [""]) + return lines + + +def grr_line(spans, width, fill): + return tui_slice_spans(list(spans), 0, width, fill) + + +def grr_margin(width): + return 1 if width < 48 else 2 + + +class GrrCard: + """The rounded card every grouped row sits in. Top and bottom edges are + half-height rows with quarter-block corners, the sides are half blocks, + so the card reads as rounded in any terminal font.""" + + @staticmethod + def inner(width): + return max(1, width - 2 * grr_margin(width) - 2) + + @staticmethod + def top(ui, width, bg="card"): + m = grr_margin(width) + cw = max(2, width - 2 * m) + return grr_line([TuiSpan(" " * m + "▗" + "▄" * (cw - 2) + "▖", ui.st(bg, "bg"))], width, ui.st("ink", "bg")) + + @staticmethod + def bottom(ui, width, bg="card"): + m = grr_margin(width) + cw = max(2, width - 2 * m) + return grr_line([TuiSpan(" " * m + "▝" + "▀" * (cw - 2) + "▘", ui.st(bg, "bg"))], width, ui.st("ink", "bg")) + + @staticmethod + def line(ui, width, spans, bg="card"): + m = grr_margin(width) + page = ui.st("ink", "bg") + edge = ui.st(bg, "bg") + body = grr_line(list(spans), GrrCard.inner(width), ui.st("ink", bg)) + return [TuiSpan(" " * m, page), TuiSpan("▐", edge)] + body + [TuiSpan("▌", edge), TuiSpan(" " * m, page)] + + +class GrrItem: + """One block of a page. `build(ui, width, selected)` returns span rows + that are exactly `width` cells wide. Rows are cached until the item's + version, the width, the selection or the palette changes. `hit(...)` + registers clickable regions during build: (row, x0, x1, callback).""" + + selectable = False + key = None + + def __init__(self): + self.page = None + self.version = 0 + self.stop = 0 + self.stops = 1 + self._cache = None + self._hits = [] + + def touch(self): + self.version += 1 + if self.page is not None: + self.page.item_changed(self) + + def rows(self, ui, width, selected): + key = (ui.version, width, selected, self.version, self.stop) + if self._cache is not None and self._cache[0] == key: + return self._cache[1] + self._hits = [] + rows = self.build(ui, width, selected) + self._cache = (key, rows, self._hits) + return rows + + def build(self, ui, width, selected): + return [] + + def hit(self, row, x0, x1, fn): + self._hits.append((row, x0, x1, fn)) + + def hit_at(self, ui, width, row, x): + self.rows(ui, width, False) + for hit_row, x0, x1, fn in self._cache[2]: + if hit_row == row and x0 <= x < x1: + return fn + return None + + def ident(self): + """What identifies this item when a page is rebuilt: its key, or its + kind and words, so the selection stays where the user put it.""" + if self.key is not None: + return self.key + return (type(self).__name__, str(getattr(self, "title", None) or getattr(self, "text", None) or getattr(self, "label", None) or "")) + + def activate(self): + action = getattr(self, "action", None) + if callable(action): + action() + + def nav(self, direction): + return False + + +class GrrSpacer(GrrItem): + def __init__(self, n=1): + super().__init__() + self.n = n + + def build(self, ui, width, selected): + return [[TuiSpan(" " * width, ui.st())] for _ in range(self.n)] + + +class GrrLabel(GrrItem): + """A small muted caption above a group, like Character or Planning.""" + + def __init__(self, text): + super().__init__() + self.text = text + + def build(self, ui, width, selected): + m = grr_margin(width) + 1 + return [grr_line([TuiSpan(" " * m + grr_clip(self.text, width - m * 2), ui.st("muted"))], width, ui.st())] + + +class GrrHeading(GrrItem): + def __init__(self, text, center=False): + super().__init__() + self.text = text + self.center = center + + def build(self, ui, width, selected): + m = grr_margin(width) + text = grr_clip(self.text, width - 2 * m) + pad = (width - len(text)) // 2 if self.center else m + return [grr_line([TuiSpan(" " * pad + text, ui.st("ink", bold=True))], width, ui.st())] + + +class GrrNote(GrrItem): + def __init__(self, text, center=False, tone="faint"): + super().__init__() + self.text = text + self.center = center + self.tone = tone + + def build(self, ui, width, selected): + m = grr_margin(width) + 1 + out = [] + for line in grr_wrap(self.text, width - 2 * m): + pad = (width - len(line)) // 2 if self.center else m + out.append(grr_line([TuiSpan(" " * pad + line, ui.st(self.tone))], width, ui.st())) + return out + + +class GrrRowItem(GrrItem): + """A row inside a card group: icon or lead, title, sub, value and a + switch, check or chevron on the right. Activate runs `action`, or flips + the switch when `toggle` is given.""" + + def __init__(self, title, sub="", icon="", lead=None, value="", chevron=False, check=False, toggle=None, on_toggle=None, + action=None, danger=False, link=False, wrap=False, pos="only", key=None, tone=""): + super().__init__() + self.title = title + self.sub = sub + self.icon = icon + self.lead = lead + self.value = value + self.chevron = chevron + self.check = check + self.toggle = toggle + self.on_toggle = on_toggle + self.action = action + self.danger = danger + self.link = link + self.wrap = wrap + self.pos = pos + self.key = key + self.tone = tone + self.selectable = action is not None or toggle is not None + + def set_toggle(self, on): + self.toggle = on + self.touch() + + def activate(self): + if self.toggle is not None and self.on_toggle is not None: + nxt = not self.toggle + self.toggle = nxt + self.touch() + self.on_toggle(nxt) + elif callable(self.action): + self.action() + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + bg = "rule-strong" if selected else "card" + base = ui.st("ink", bg) + title_fg = "danger" if self.danger else ("accent" if self.link else "ink") + lead = list(self.lead(ui, bg)) if callable(self.lead) else [] + lead_w = tui_span_width(lead) + icon = (ui.icon(self.icon) + " ") if self.icon else "" + right = [] + if self.value: + right.append(TuiSpan(grr_clip(self.value, max(6, inner // 2)) + " ", ui.st("muted", bg))) + if self.toggle is not None: + on = bool(self.toggle) + edge = ui.st("accent" if on else "rule-strong", bg) + pill = TuiStyle(fg=ui.palette.c("accent-ink" if on else "muted"), bg=ui.palette.c("accent" if on else "rule-strong")) + right += [TuiSpan("▐", edge), TuiSpan(" ●" if on else "● ", pill), TuiSpan("▌", edge), TuiSpan(" ", base)] + if self.check: + right.append(TuiSpan(ui.icon("check") + " ", ui.st("ink", bg, bold=True))) + if self.chevron: + right.append(TuiSpan(ui.icon("external" if self.chevron == "ext" else "right") + " ", ui.st("muted", bg))) + right_w = tui_span_width(right) + lead_cols = len(icon) + lead_w + title_w = max(4, inner - 1 - lead_cols - right_w) + lines = grr_wrap(self.title, title_w)[:3] if self.wrap else [grr_clip(self.title, title_w)] + subs = grr_wrap(self.sub, inner - 1 - lead_cols - 1)[:2] if self.sub else [] + body = [] + for pos, text in enumerate(lines): + spans = [TuiSpan(" ", base)] + if pos == 0: + if icon: + spans.append(TuiSpan(icon, ui.st("soft", bg))) + spans.extend(lead) + else: + spans.append(TuiSpan(" " * lead_cols, base)) + spans.append(TuiSpan(text, ui.st(title_fg, bg, bold=selected and not self.danger))) + body.append(grr_line(spans, inner - right_w, base) + (right if pos == 0 else [TuiSpan(" " * right_w, base)])) + for text in subs: + body.append(grr_line([TuiSpan(" " + " " * lead_cols + text, ui.st(self.tone or "muted", bg))], inner, base)) + out = [] + if self.pos in ("first", "only"): + out.append(GrrCard.top(ui, width)) + for row in body: + out.append(GrrCard.line(ui, width, row, bg)) + if self.pos in ("last", "only"): + out.append(GrrCard.bottom(ui, width)) + return out + + +def grr_group(rows): + """Mark the rows of one card as first, middle and last, return them.""" + rows = [r for r in rows if r is not None] + for pos, row in enumerate(rows): + row.pos = "only" if len(rows) == 1 else ("first" if pos == 0 else ("last" if pos == len(rows) - 1 else "mid")) + return rows + + +class GrrConvItem(GrrItem): + """A conversation row: avatar, name, time, preview, unread dot.""" + + selectable = True + + def __init__(self, avatar, title, time_text="", preview="", dot=False, tag="", current=False, action=None, menu=None, key=None, busy=False): + super().__init__() + self.avatar = avatar + self.title = title + self.time_text = time_text + self.preview = preview + self.dot = dot + self.tag = tag + self.current = current + self.action = action + self.menu = menu + self.key = key + self.busy = busy + + def build(self, ui, width, selected): + m = grr_margin(width) + bg = "rule-strong" if selected else ("card" if self.current else "bg") + base = ui.st("ink", bg) + art = self.avatar(ui, bg) if callable(self.avatar) else [[TuiSpan(" ", base)], [TuiSpan(" ", base)]] + art_w = tui_span_width(art[0]) + text_w = width - 2 * m - art_w - 2 + stamp = self.time_text + tag = (" " + self.tag + " ") if self.tag else "" + name_w = max(4, text_w - len(stamp) - 1 - len(tag)) + top = [TuiSpan(grr_clip(self.title, name_w), ui.st("ink", bg, bold=True))] + if tag: + top.append(TuiSpan(tag, ui.st("muted", "rule", ))) + top = grr_line(top, text_w - len(stamp), base) + [TuiSpan(stamp, ui.st("muted", bg))] + preview = grr_clip(self.preview, text_w - (2 if self.dot or self.busy else 0)) + sub = [TuiSpan(preview, ui.st("accent" if self.busy else "muted", bg))] + sub = grr_line(sub, text_w - 2, base) + [TuiSpan(" ", base), TuiSpan("●" if (self.dot or self.busy) else " ", ui.st("accent" if self.dot else "success", bg))] + out = [] + for pos, art_row in enumerate(art[:2]): + text = top if pos == 0 else sub + row = [TuiSpan(" " * m, ui.st("ink", "bg"))] + list(art_row) + [TuiSpan(" ", base)] + text + [TuiSpan(" " * m, ui.st("ink", "bg"))] + out.append(grr_line(row, width, ui.st("ink", "bg"))) + if self.menu is not None: + self.hit(0, width - m - 2, width, self.menu) + return out + + +class GrrSectionHead(GrrItem): + def __init__(self, text, icon="", action=None, aria=""): + super().__init__() + self.text = text + self.icon = icon + self.action = action + self.selectable = action is not None + + def build(self, ui, width, selected): + m = grr_margin(width) + bg = "rule-strong" if selected else "bg" + right = (" " + ui.icon(self.icon) + " ") if self.icon else "" + row = grr_line([TuiSpan(" " * m + self.text, ui.st("muted", bg, bold=True))], width - len(right) - m, ui.st("ink", bg)) + row += [TuiSpan(right, ui.st("soft", bg)), TuiSpan(" " * m, ui.st("ink", bg))] + if right and self.action is not None: + self.hit(0, width - m - len(right), width, self.action) + return [row] + + +class GrrButtonItem(GrrItem): + """A pill button: three rows (cap, label, cap) so the ends look round.""" + + def __init__(self, label, action, kind="primary", inline=False, icon="", disabled=False): + super().__init__() + self.label = label + self.action = action + self.kind = kind + self.inline = inline + self.icon = icon + self.disabled = disabled + self.selectable = not disabled + + def build(self, ui, width, selected): + m = grr_margin(width) + text = ((ui.icon(self.icon) + " ") if self.icon else "") + self.label + if self.kind == "primary": + fg, bg = "accent-ink", "accent" + elif self.kind == "danger": + fg, bg = "danger", "card" + elif self.kind == "ghost": + fg, bg = "muted", "bg" + else: + fg, bg = "ink", "card" + if selected: + bg = "ink" if self.kind in ("primary",) else "rule-strong" + fg = "bg" if self.kind in ("primary",) else fg + cw = min(width - 2 * m, len(text) + 8) if self.inline else width - 2 * m + lead = (width - cw) // 2 + page = ui.st("ink", "bg") + edge = ui.st(bg, "bg") + body = ui.st(fg, bg, bold=True, dim=self.disabled) + out = [grr_line([TuiSpan(" " * lead + "▗" + "▄" * (cw - 2) + "▖", edge)], width, page), + grr_line([TuiSpan(" " * lead, page), TuiSpan(text.center(cw)[:cw], body)], width, page), + grr_line([TuiSpan(" " * lead + "▝" + "▀" * (cw - 2) + "▘", edge)], width, page)] + if not self.disabled: + for pos in range(3): + self.hit(pos, lead, lead + cw, self.action) + return out + + +class GrrTextCard(GrrItem): + """Text in a card, wrapped. Selectable when it has an action (edit).""" + + def __init__(self, text, action=None, placeholder="", mono=False, label=""): + super().__init__() + self.text = text + self.action = action + self.placeholder = placeholder + self.mono = mono + self.label = label + self.selectable = action is not None + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + bg = "rule-strong" if selected else "card" + text = self.text if self.text else self.placeholder + tone = "ink" if self.text else "muted" + lines = grr_wrap(text, inner - 2) + if len(lines) > 24: + lines = lines[:24] + ["…"] + out = [GrrCard.top(ui, width, bg)] + for line in lines: + out.append(GrrCard.line(ui, width, [TuiSpan(" " + line, ui.st(tone, bg))], bg)) + out.append(GrrCard.bottom(ui, width, bg)) + return out + + +class GrrTabsItem(GrrItem): + """Tabs along the top of a body. When the labels do not fit the width they + fold into a single dropdown chip, like the web app does on a phone.""" + + selectable = True + + def __init__(self, tabs, value, on_pick): + super().__init__() + self.tabs = list(tabs) + self.value = value + self.on_pick = on_pick + self.stops = len(self.tabs) + + def pick(self, tab_id): + self.value = tab_id + self.touch() + self.on_pick(tab_id) + + def index(self): + for pos, tab in enumerate(self.tabs): + if tab["id"] == self.value: + return pos + return 0 + + def nav(self, direction): + if direction in ("left", "right"): + pos = max(0, min(len(self.tabs) - 1, self.index() + (1 if direction == "right" else -1))) + if self.tabs[pos]["id"] != self.value: + self.pick(self.tabs[pos]["id"]) + return True + return False + + def menu(self): + app = self.page.app if self.page is not None else None + if app is not None and getattr(app, "grr", None) is not None: + app.grr.menu([{"label": t["label"], "checked": t["id"] == self.value, "action": (lambda i=t["id"]: self.pick(i))} for t in self.tabs]) + + def activate(self): + if self._folded: + self.menu() + + _folded = False + + def build(self, ui, width, selected): + m = grr_margin(width) + need = sum(len(t["label"]) + 4 for t in self.tabs) + page = ui.st("ink", "bg") + self._folded = need > width - 2 * m + if self._folded: + label = next((t["label"] for t in self.tabs if t["id"] == self.value), "") + text = " %s %s " % (label, ui.icon("down")) + row = [TuiSpan(" " * m, page), TuiSpan(text, ui.st("ink", "rule-strong" if selected else "card", bold=True))] + self.hit(0, m, m + len(text), self.menu) + return [grr_line(row, width, page), [TuiSpan(" " * width, page)]] + spans, under, x = [TuiSpan(" " * m, page)], [TuiSpan(" " * m, page)], m + for tab in self.tabs: + on = tab["id"] == self.value + text = " %s " % tab["label"] + focus = selected and on + spans.append(TuiSpan(text + " ", ui.st("ink" if on else "muted", "rule-strong" if focus else "bg", bold=on))) + under.append(TuiSpan(("▀" * len(text)) if on else (" " * len(text)), ui.st("accent", "bg"))) + under.append(TuiSpan(" ", page)) + self.hit(0, x, x + len(text), (lambda i=tab["id"]: self.pick(i))) + x += len(text) + 2 + return [grr_line(spans, width, page), grr_line(under, width, page)] + + +class GrrSegmentedItem(GrrItem): + """A pill group of exclusive choices (Every, Daily, Weekly).""" + + selectable = True + + def __init__(self, items, value, on_pick): + super().__init__() + self.items = list(items) + self.value = value + self.on_pick = on_pick + + def pick(self, item_id): + self.value = item_id + self.touch() + self.on_pick(item_id) + + def nav(self, direction): + if direction in ("left", "right"): + ids = [i["id"] for i in self.items] + pos = max(0, min(len(ids) - 1, ids.index(self.value) + (1 if direction == "right" else -1))) if self.value in ids else 0 + self.pick(ids[pos]) + return True + return False + + def build(self, ui, width, selected): + m = grr_margin(width) + page = ui.st("ink", "bg") + each = max(6, (width - 2 * m) // max(1, len(self.items))) + spans, x = [TuiSpan(" " * m, page)], m + for item in self.items: + on = item["id"] == self.value + spans.append(TuiSpan(grr_clip(item["label"], each - 2).center(each), ui.st("accent-ink" if on else "ink", "accent" if on else "card", bold=on))) + self.hit(1, x, x + each, (lambda i=item["id"]: self.pick(i))) + x += each + edge_up = [TuiSpan(" " * m, page)] + [TuiSpan("▄" * each, ui.st("accent" if i["id"] == self.value else "card", "bg")) for i in self.items] + edge_dn = [TuiSpan(" " * m, page)] + [TuiSpan("▀" * each, ui.st("accent" if i["id"] == self.value else "card", "bg")) for i in self.items] + return [grr_line(edge_up, width, page), grr_line(spans, width, page), grr_line(edge_dn, width, page)] + + +class GrrChipsItem(GrrItem): + """A row of chips; each opens a menu or runs an action. Used for mode, + tier, sort and filter dropdowns.""" + + selectable = True + + def __init__(self, chips): + super().__init__() + self.chips = list(chips) + self.stops = len(self.chips) + + def nav(self, direction): + if direction in ("left", "right"): + nxt = max(0, min(len(self.chips) - 1, self.stop + (1 if direction == "right" else -1))) + if nxt != self.stop: + self.stop = nxt + self.touch() + return True + return False + + def activate(self): + chip = self.chips[min(self.stop, len(self.chips) - 1)] + chip["action"]() + + def build(self, ui, width, selected): + m = grr_margin(width) + page = ui.st("ink", "bg") + spans, x = [TuiSpan(" " * m, page)], m + for pos, chip in enumerate(self.chips): + label = ((ui.icon(chip["icon"]) + " ") if chip.get("icon") else "") + chip["label"] + (" " + ui.icon("down") if chip.get("caret", True) else "") + text = " %s " % label + focus = selected and pos == self.stop + spans.append(TuiSpan(text, ui.st("ink", "rule-strong" if focus else "card", bold=focus))) + spans.append(TuiSpan(" ", page)) + self.hit(0, x, x + len(text), chip["action"]) + x += len(text) + 1 + return [grr_line(spans, width, page)] + + +class GrrDividerItem(GrrItem): + def __init__(self, text, accent=False): + super().__init__() + self.text = text + self.accent = accent + + def build(self, ui, width, selected): + label = " %s " % self.text + side = max(0, (width - len(label)) // 2) + tone = "accent" if self.accent else "muted" + spans = [TuiSpan("─" * side if self.accent else " " * side, ui.st("accent", "bg")), TuiSpan(label, ui.st(tone, "bg", bold=self.accent))] + spans.append(TuiSpan("─" * (width - side - len(label)) if self.accent else "", ui.st("accent", "bg"))) + return [grr_line(spans, width, ui.st("ink", "bg"))] + + +class GrrEmptyItem(GrrItem): + def __init__(self, icon, text, sub=""): + super().__init__() + self.icon = icon + self.text = text + self.sub = sub + + def build(self, ui, width, selected): + page = ui.st("ink", "bg") + out = [[TuiSpan(" " * width, page)] for _ in range(2)] + out.append(grr_line([TuiSpan((" %s " % ui.icon(self.icon)).center(width), ui.st("muted", "bg", bold=True))], width, page)) + out.append([TuiSpan(" " * width, page)]) + for line in grr_wrap(self.text, width - 6): + out.append(grr_line([TuiSpan(line.center(width), ui.st("ink", "bg", bold=True))], width, page)) + for line in (grr_wrap(self.sub, width - 8) if self.sub else []): + out.append(grr_line([TuiSpan(line.center(width), ui.st("muted", "bg"))], width, page)) + return out + + +class GrrKvItem(GrrItem): + """Key and value rows in one card (usage details, server health).""" + + def __init__(self, pairs, tone=None): + super().__init__() + self.pairs = list(pairs) + self.tone = tone or {} + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + out = [GrrCard.top(ui, width)] + for key, value in self.pairs: + value = str(value) + left = grr_clip(key, max(4, inner - len(value) - 4)) + row = [TuiSpan(" " + left, ui.st("ink", "card")), TuiSpan(" " * max(1, inner - 2 - len(left) - len(value)), ui.st("ink", "card")), TuiSpan(value + " ", ui.st(self.tone.get(key, "muted"), "card"))] + out.append(GrrCard.line(ui, width, row)) + out.append(GrrCard.bottom(ui, width)) + return out + + +class GrrProgressItem(GrrItem): + def __init__(self, label, pct, right="", foot="", level=""): + super().__init__() + self.label = label + self.pct = max(0.0, float(pct or 0)) + self.right = right + self.foot = foot + self.level = level + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + base = ui.st("ink", "card") + tone = "danger" if self.pct >= 100 else ("accent" if self.pct >= 80 else "info") + bar_w = max(4, inner - 4) + filled = int(round(min(100.0, self.pct) / 100.0 * bar_w)) + head = grr_line([TuiSpan(" " + self.label, ui.st("ink", "card", bold=True))], inner - len(self.right) - 1, base) + [TuiSpan(self.right + " ", ui.st("muted", "card"))] + bar = [TuiSpan(" ", base), TuiSpan("█" * filled, ui.st(tone, "card")), TuiSpan("▒" * (bar_w - filled), ui.st("rule-strong", "card")), TuiSpan(" ", base)] + rows = [head, bar] + if self.foot: + rows.append([TuiSpan(" " + self.foot, ui.st("muted", "card"))]) + out = [GrrCard.top(ui, width)] + for row in rows: + out.append(GrrCard.line(ui, width, row)) + out.append(GrrCard.bottom(ui, width)) + return out + + +class GrrBarsItem(GrrItem): + """A small column chart of the last days of usage.""" + + def __init__(self, days): + super().__init__() + self.days = list(days) + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + peak = max([d.get("tokens", 0) for d in self.days] + [1]) + levels = " ▁▂▃▄▅▆▇█" + cell = max(3, min(8, (inner - 2) // max(1, len(self.days)))) + top = [TuiSpan(" ", ui.st("ink", "card"))] + [TuiSpan((levels[min(8, max(1, int(round(d.get("tokens", 0) / float(peak) * 8))))] * (cell - 1)) + " ", ui.st("accent", "card")) for d in self.days] + low = [TuiSpan(" ", ui.st("ink", "card"))] + [TuiSpan(str(d.get("day", ""))[5:].ljust(cell), ui.st("muted", "card")) for d in self.days] + return [GrrCard.top(ui, width), GrrCard.line(ui, width, top), GrrCard.line(ui, width, low), GrrCard.bottom(ui, width)] + + +class GrrTileGridItem(GrrItem): + """Tiles in columns (library, themes, shapes): a box with an icon, a name + and a sub line. Arrows move between tiles.""" + + selectable = True + + def __init__(self, tiles, cols=0, tile_h=4, min_w=22, max_cols=4, on_pick=None): + super().__init__() + self.tiles = list(tiles) + self.cols_wanted = cols + self.tile_h = tile_h + self.min_w = min_w + self.max_cols = max_cols + self.on_pick = on_pick + self.stops = max(1, len(self.tiles)) + self.cols = 1 + + def nav(self, direction): + step = {"left": -1, "right": 1, "up": -self.cols, "down": self.cols}.get(direction) + if step is None: + return False + nxt = self.stop + step + if 0 <= nxt < len(self.tiles): + self.stop = nxt + self.touch() + return True + return False + + def activate(self): + if self.tiles: + tile = self.tiles[min(self.stop, len(self.tiles) - 1)] + (tile.get("action") or (lambda: None))() + + def build(self, ui, width, selected): + m = grr_margin(width) + gap = 1 + cols = self.cols_wanted or max(1, min(self.max_cols, (width - 2 * m + gap) // (self.min_w + gap))) + self.cols = cols + tw = (width - 2 * m - gap * (cols - 1)) // cols + page = ui.st("ink", "bg") + out = [] + for start in range(0, len(self.tiles), cols): + chunk = self.tiles[start:start + cols] + lines = [[TuiSpan(" " * m, page)] for _ in range(self.tile_h)] + for off, tile in enumerate(chunk): + focus = selected and start + off == self.stop + on = tile.get("on") + bg = "rule-strong" if focus else "card" + edge = ui.st("accent" if on else bg, "bg") + body = ui.st("ink", bg) + art = tile.get("art") + shown = art(ui, bg) if callable(art) else [[TuiSpan(ui.icon(tile.get("icon", "file")).center(tw), ui.st("soft", bg, bold=True))]] + art_rows = [] + for part in shown: + pad = max(0, (tw - tui_span_width(part)) // 2) + art_rows.append(grr_line([TuiSpan(" " * pad, body)] + list(part), tw, body)) + rows = [[TuiSpan("▗" + "▄" * (tw - 2) + "▖", edge)]] + art_rows + if tile.get("label"): + rows.append(grr_line([TuiSpan(grr_clip(tile["label"], tw - 2).center(tw), ui.st("ink", bg, bold=on))], tw, body)) + if tile.get("sub") and self.tile_h >= 6: + rows.append(grr_line([TuiSpan(grr_clip(tile["sub"], tw - 2).center(tw), ui.st("muted", bg))], tw, body)) + rows.append([TuiSpan("▝" + "▀" * (tw - 2) + "▘", edge)]) + for pos in range(self.tile_h): + cell = rows[pos] if pos < len(rows) else [TuiSpan(" " * tw, page)] + lines[pos].extend(cell) + lines[pos].append(TuiSpan(" " * gap, page)) + self.hit(len(out) + pos, m + off * (tw + gap), m + off * (tw + gap) + tw, tile.get("action") or (lambda: None)) + for line in lines: + out.append(grr_line(line, width, page)) + return out + + +class GrrPage(TuiPanel): + """A scrolling list of items with a keyboard selection. Up and Down move + the selection (an item may take Left and Right first), Enter or Space + activates, the wheel and PageUp/PageDown scroll, a click activates the + item under the pointer or the region it registered. `stick` keeps the + view on the newest row while the user is at the bottom.""" + + def __init__(self, ui, stick=False, auto_select=True, **kwargs): + kwargs.setdefault("focusable", True) + super().__init__(**kwargs) + self.ui = ui + self._items = [] + self._sel = -1 + self._top = 0 + self._stick = stick + self._follow = True + self._auto = auto_select + self._starts = [] + self._total = 0 + self._width = -1 + self._dirty = True + self._visible = [] + self._reveal = False + self.on_bottom = None + + @property + def items(self): + return self._items + + def set_items(self, items): + keep = self._items[self._sel].ident() if 0 <= self._sel < len(self._items) else None + for item in self._items: + item.page = None + self._items = list(items) + for item in self._items: + item.page = self + self._sel = -1 + if keep is not None: + for pos, item in enumerate(self._items): + if item.selectable and item.ident() == keep: + self._sel = pos + break + if self._sel < 0 and self._auto: + self._sel = self._first_selectable() + self._dirty = True + self.touch() + + def append(self, item): + item.page = self + self._items.append(item) + self._dirty = True + self.touch() + + def item_changed(self, item): + self._dirty = True + self.touch() + + def _first_selectable(self): + for pos, item in enumerate(self._items): + if item.selectable: + return pos + return -1 + + def selectables(self): + return [pos for pos, item in enumerate(self._items) if item.selectable] + + def _layout(self, width): + if not self._dirty and width == self._width: + return + self._width = width + self._dirty = False + self._starts = [] + row = 0 + for item in self._items: + self._starts.append(row) + row += len(item.rows(self.ui, width, False)) + self._total = row + + def total_rows(self): + self._layout(self._content_w()) + return self._total + + def scroll_to_end(self): + self._follow = True + self._top = max(0, self.total_rows() - self._content_h()) + self.touch() + + def at_bottom(self): + return self._top >= max(0, self._total - self._content_h()) + + def scroll_by(self, delta): + height = self._content_h() + self._layout(self._content_w()) + self._top = max(0, min(max(0, self._total - height), self._top + delta)) + self._follow = self._top >= max(0, self._total - height) + self.touch() + if self.on_bottom is not None: + self.on_bottom(self._follow) + + def content_rows(self): + width, height = self._content_w(), self._content_h() + self._layout(width) + ui = self.ui + focused = self.app is not None and self.app.focused is self + if self._stick and self._follow: + self._top = max(0, self._total - height) + if self._reveal and 0 <= self._sel < len(self._items): + start = self._starts[self._sel] + end = start + len(self._items[self._sel].rows(ui, width, False)) + if start < self._top: + self._top = start + elif end > self._top + height: + self._top = min(start, end - height) + self._reveal = False + self._top = max(0, min(self._top, max(0, self._total - height))) + rows, visible = [], [] + pos = self._first_at(self._top) + blank = ui.st("ink", "bg") + while pos < len(self._items) and len(rows) < height: + item = self._items[pos] + sel = focused and pos == self._sel + built = item.rows(ui, width, sel) + skip = max(0, self._top - self._starts[pos]) if not rows else 0 + take = built[skip:skip + (height - len(rows))] + visible.append((len(rows), pos, skip, len(take))) + rows.extend(take) + pos += 1 + self._visible = visible + while len(rows) < height: + rows.append([TuiSpan(" " * width, blank)]) + return rows + + def _first_at(self, row): + lo, hi = 0, len(self._starts) + while lo < hi: + mid = (lo + hi) // 2 + if self._starts[mid] + len(self._items[mid].rows(self.ui, self._width, False)) <= row: + lo = mid + 1 + else: + hi = mid + return lo + + def _select(self, pos): + if pos != self._sel: + self._sel = pos + self._reveal = True + self.touch() + + def move(self, delta): + if 0 <= self._sel < len(self._items) and self._items[self._sel].nav("down" if delta > 0 else "up"): + self._reveal = True + self.touch() + return True + order = self.selectables() + if not order: + self.scroll_by(delta * 3) + return False + if self._sel not in order: + self._select(order[0] if delta > 0 else order[-1]) + return True + at = order.index(self._sel) + delta + if 0 <= at < len(order): + self._select(order[at]) + return True + return False + + def focus_last(self): + order = self.selectables() + if order and self._sel < 0: + self._select(order[-1]) + + def on_key(self, event): + key = event.key or "" + char = getattr(event, "char", "") or "" + if key == "up": + event.stop() + self.move(-1) + elif key == "down": + event.stop() + self.move(1) + elif key in ("left", "right") and 0 <= self._sel < len(self._items): + if self._items[self._sel].nav(key): + event.stop() + self._reveal = True + self.touch() + elif key in ("enter", "space") or (char == " " and not key.startswith("ctrl")): + if 0 <= self._sel < len(self._items): + event.stop() + self._items[self._sel].activate() + elif key == "pageup": + event.stop() + self.scroll_by(-max(1, self._content_h() - 1)) + elif key == "pagedown": + event.stop() + self.scroll_by(max(1, self._content_h() - 1)) + elif key == "home": + event.stop() + order = self.selectables() + if order: + self._select(order[0]) + self._top = 0 + self._follow = False + self.touch() + elif key == "end": + event.stop() + order = self.selectables() + if order: + self._select(order[-1]) + self.scroll_to_end() + + def on_scroll(self, event): + event.stop() + self.scroll_by(-3 if event.direction == "up" else 3) + + def on_click(self, event): + if not event.pressed: + return + event.stop() + local_y = event.y - self.ay + local_x = event.x - self.ax + for start, pos, skip, count in self._visible: + if start <= local_y < start + count: + item = self._items[pos] + row = skip + local_y - start + fn = item.hit_at(self.ui, self._width, row, local_x) + if fn is not None: + if item.selectable: + self._sel = pos + fn() + return + if item.selectable: + self._sel = pos + self._reveal = False + self.touch() + item.activate() + return + + +# --------------------------------------------------------------------------- +# Part C: bars, overlays and the shell (boot, login, layout, routes, events). +# --------------------------------------------------------------------------- + + +GRR_KEYS = ( + ("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"), +) + + +class GrrBar(TuiPanel): + """One row of clickable parts placed left to right. A part is a dict: + text or spans, fn, fg, bg, bold, fill (takes the leftover width) and + align ("center" for a title). Page headers and the bottom bar use it.""" + + def __init__(self, ui, **kwargs): + kwargs.setdefault("h", 1) + super().__init__(**kwargs) + self.ui = ui + self._parts = [] + self._bg = "bg" + self._regions = [] + + def set(self, parts, bg="bg"): + self._parts = list(parts) + self._bg = bg + self.touch() + + def content_rows(self): + width = self._content_w() + ui = self.ui + base = ui.st("ink", self._bg) + self._regions = [] + sized = [] + for part in self._parts: + spans = part.get("spans") + if spans is None and not part.get("fill"): + spans = [TuiSpan(part.get("text", ""), ui.st(part.get("fg", "ink"), part.get("bg", self._bg), bold=part.get("bold", False)))] + sized.append((part, spans, tui_span_width(spans) if spans is not None else 0)) + fixed = sum(w + 1 for part, spans, w in sized if not part.get("fill")) + fills = [part for part, spans, w in sized if part.get("fill")] + room = max(0, width - 1 - fixed - (1 if fills else 0)) + each = room // len(fills) if fills else 0 + row, x = [TuiSpan(" ", base)], 1 + for part, spans, w in sized: + if part.get("fill"): + style = ui.st(part.get("fg", "ink"), part.get("bg", self._bg), bold=part.get("bold", False)) + text = grr_clip(part.get("text", ""), each) + spans = [TuiSpan(text.center(each) if part.get("align") == "center" else text.ljust(each), style)] + w = each + row.extend(spans) + row.append(TuiSpan(" ", base)) + if part.get("fn"): + self._regions.append((x, x + w, part["fn"])) + x += w + 1 + return [grr_line(row, width, base)] + + def last_fn(self): + """The action of the right-most clickable part (the More menu).""" + return self._regions[-1][2] if self._regions else None + + def on_click(self, event): + if not event.pressed: + return + event.stop() + x = event.x - self.ax + for x0, x1, fn in self._regions: + if x0 <= x < x1: + fn() + return + + +class GrrTuiApp(TuiApp): + """The stock app plus two hooks the grr scene needs: where the last + click landed (menus open there) and a click outside a modal popup, + which closes it.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.last_click = (0, 0, 0.0) + + def _dispatch_click(self, event): + if event.pressed: + self.last_click = (event.x, event.y, time.time()) + modal = self.top_modal() + if modal is not None and event.pressed: + hit = self.hitmap.at(event.x, event.y) + if hit is None or self._modal_blocks(hit): + outside = getattr(modal, "on_outside", None) + if callable(outside): + outside(event) + return + super()._dispatch_click(event) + + +class GrrSecretBox(TuiTextBox): + """A one-line box that shows bullets instead of what is typed.""" + + def content_rows(self): + width, height = self._content_w(), self._content_h() + self._clamp() + left = max(0, self.cx - width + 1) + focused = self.app is not None and self.app.focused is self and self.enabled + hint = self.placeholder if (self.placeholder and not self.value) else "" + rows = [] + for pos in range(height): + raw = self.lines[pos] if pos < len(self.lines) else "" + line = ("•" * len(raw))[left:left + width] + style = self.style + if hint and pos == 0: + line, style = hint, self.style.merged(TuiStyle(fg="role:muted", dim=True)) + line = line.ljust(width) + if focused and pos == self.cy: + col = self.cx - left + rows.append([TuiSpan(line[:col], style), TuiSpan(line[col:col + 1] or " ", style.merged(TuiStyle(reverse=True))), TuiSpan(line[col + 1:], style)]) + else: + rows.append([TuiSpan(line[:width], style)]) + return rows + + +class GrrMenu(TuiPanel): + """A popup list of actions near the click that opened it. Up and Down + move, Enter runs, Esc or a click outside closes.""" + + def __init__(self, shell, items, anchor=None, align_left=False): + ui = shell.ui + self.shell = shell + self.entries = list(items) + labels = [e["label"] for e in self.entries if e != "-"] + icons = any(e != "-" and e.get("icon") for e in self.entries) + width = min(shell.app.cols - 2, max([len(s) for s in labels] + [10]) + 6 + (3 if icons else 0)) + height = min(GRR_MENU_MAX_H, len(self.entries)) + cols, rows = shell.app.cols, shell.app.rows + if anchor is None: + x, y = max(0, (cols - width) // 2), max(0, (rows - height) // 3) + else: + ax, ay = anchor + x = ax if align_left else ax - width + 1 + x = max(1, min(x, cols - width - 1)) + y = ay + 1 + if y + height > rows - 1: + y = max(0, ay - height) + super().__init__(layer="dialog", modal=True, focusable=True, x=x, y=y, w=width, h=height, style=ui.st("ink", "card")) + self.ui = ui + self.sel = next((i for i, e in enumerate(self.entries) if e != "-"), 0) + self.top = 0 + self.icons = icons + + def content_rows(self): + width, height = self._content_w(), self._content_h() + ui = self.ui + if self.sel < self.top: + self.top = self.sel + elif self.sel >= self.top + height: + self.top = self.sel - height + 1 + rows = [] + for pos in range(height): + index = self.top + pos + entry = self.entries[index] if index < len(self.entries) else None + if entry is None: + rows.append([TuiSpan(" " * width, ui.st("ink", "card"))]) + elif entry == "-": + rows.append([TuiSpan("─" * width, ui.st("rule-strong", "card"))]) + else: + on = index == self.sel + bg = "rule-strong" if on else "card" + tone = "danger" if entry.get("danger") else "ink" + lead = (ui.icon(entry["icon"]) + " ") if entry.get("icon") else (" " if self.icons else "") + mark = ui.icon("check") if entry.get("checked") else " " + text = (" " + lead + entry["label"]).ljust(width - 2)[:width - 2] + mark + " " + rows.append([TuiSpan(text, ui.st(tone, bg, bold=on))]) + return rows + + def run(self, index): + entry = self.entries[index] + if entry == "-": + return + self.shell.close_menu() + action = entry.get("action") + if callable(action): + action() + + def step(self, delta): + pos = self.sel + while True: + pos += delta + if pos < 0 or pos >= len(self.entries): + return + if self.entries[pos] != "-": + self.sel = pos + self.touch() + return + + def on_key(self, event): + key = event.key or "" + event.stop() + if key == "escape": + self.shell.close_menu() + elif key == "up": + self.step(-1) + elif key == "down": + self.step(1) + elif key == "home": + self.sel = next((i for i, e in enumerate(self.entries) if e != "-"), 0) + self.touch() + elif key == "end": + self.sel = max(i for i, e in enumerate(self.entries) if e != "-") + self.touch() + elif key in ("enter", "space"): + self.run(self.sel) + + def on_click(self, event): + if not event.pressed: + return + event.stop() + index = self.top + (event.y - self.ay) + if 0 <= index < len(self.entries): + self.run(index) + + def on_outside(self, event): + self.shell.close_menu() + + def on_scroll(self, event): + event.stop() + self.step(-1 if event.direction == "up" else 1) + + +class GrrToast(TuiRich): + """A short message over the screen. Clicking a banner runs its action.""" + + def __init__(self, rows, w, y, click=None, **kwargs): + super().__init__(rows, layer="toast", x="center", y=y, w=w, h=len(rows) if rows and isinstance(rows[0], list) else 1, **kwargs) + self.click = click + + def on_click(self, event): + if event.pressed and self.click is not None: + event.stop() + self.click() + + +class GrrWelcome(TuiPanel): + """The empty main pane of the two-pane layout: the mark, the name and + the tagline, centered.""" + + def __init__(self, shell): + super().__init__(w="1fr", h="100%", style=shell.ui.st("ink", "bg")) + self.shell = shell + + def content_rows(self): + shell = self.shell + ui, width, height = shell.ui, self._content_w(), self._content_h() + art = GrrAvatar.rows(ui, {"shape": "blob", "color": ui.palette.c("accent")}, "bg", hero=True) + lines = [grr_line([TuiSpan(" " * ((width - tui_span_width(r)) // 2), ui.st())] + r, width, ui.st()) for r in art] + lines.append([TuiSpan(" " * width, ui.st())]) + lines.append([TuiSpan("grr".center(width), ui.st("ink", "bg", bold=True))]) + for line in grr_wrap(shell.t("app.tagline"), max(10, min(width - 4, 46))): + lines.append([TuiSpan(line.center(width), ui.st("muted", "bg"))]) + top = max(0, (height - len(lines)) // 2) + blank = [TuiSpan(" " * width, ui.st())] + rows = [blank] * top + lines + return (rows + [blank] * height)[:height] + + +class GrrScreen: + """The standard screen: header bar, scrolling page, optional foot page. + `right` is a list of bar parts, `back` a route (False for none) and + `close` draws a close mark instead of a back arrow.""" + + def __init__(self, shell, title="", back="/", close=False, right=(), on_back=None, foot=None, stick=False, titleless=False, auto_select=True): + ui = shell.ui + self.shell = shell + self.title = title + self.back = back + self.close = close + self.on_back = on_back + self.right = list(right) + self.titleless = titleless + self.center = None + self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) + self.bar = GrrBar(ui, style=ui.st("ink", "bg")) + self.page = GrrPage(ui, stick=stick, auto_select=auto_select, style=ui.st("ink", "bg"), h="1fr") + self.foot_page = None + self.root.add(self.bar, self.page) + if foot is not None: + self.foot_page = GrrPage(ui, style=ui.st("ink", "bg"), h=3) + self.foot_page.set_items(foot) + self.root.add(self.foot_page) + self.refresh() + + def go_back(self): + if self.on_back is not None: + self.on_back() + else: + self.shell.router.back(self.back or "/") + + def refresh(self): + ui = self.shell.ui + parts = [] + if self.back is not False or self.close: + parts.append({"text": " %s " % ui.icon("close" if self.close else "back"), "fn": self.go_back, "bg": "card", "bold": True}) + if self.center is not None: + parts.append(self.center) + else: + parts.append({"text": self.title, "fill": True, "align": "center", "bold": True}) + parts.extend(self.right) + self.bar.set(parts) + + def set_title(self, title): + self.title = title + self.refresh() + + def chip(self, icon, fn): + return {"text": " %s " % self.shell.ui.icon(icon), "fn": fn, "bg": "card", "bold": True} + + def items(self, items): + self.page.set_items(items) + + +class GrrView: + """A screen of the app. `open()` builds panels and calls `show`. Server + calls go through `get`, `post` ... which drop their answer when the user + has already left the screen.""" + + fallback = "/" + + def __init__(self, shell, params=None, query=None): + self.g = shell + self.ui = shell.ui + self.params = params or {} + self.query = query or {} + self.closed = False + self.screen = None + self.subs = [] + self.timers = [] + + def open(self): + pass + + def destroy(self): + self.closed = True + for off in self.subs: + off() + self.subs = [] + for handle in self.timers: + self.g.app.cancel_timer(handle) + self.timers = [] + + def sub(self, emitter, kind, fn): + self.subs.append(emitter.on(kind, fn)) + + def every(self, seconds, fn): + handle = self.g.app.every(seconds, fn) + self.timers.append(handle) + return handle + + def show(self, panel, focus=None): + self.g.show_main(panel, focus) + + def guard(self, fn): + def run(*args): + if not self.closed: + fn(*args) + return run + + def call(self, method, path, body=None, then=None, fail=None): + self.g.call(method, path, body, self.guard(then) if then else None, fail, view=self) + + def get(self, path, then, fail=None): + self.call("GET", path, None, then, fail) + + def batch(self, calls, then, fail=None): + self.g.batch(calls, self.guard(then), fail, view=self) + + def on_escape(self): + if self.screen is not None: + self.screen.go_back() + return True + return False + + def hotkey(self, key): + """Keys the shell offers the open screen before it uses them itself. + Returns True when handled.""" + return False + + def header_action(self): + bar = getattr(self.screen, "bar", None) or getattr(self, "bar", None) + fn = bar.last_fn() if bar is not None else None + if fn is None: + return False + fn() + return True + + def standard(self, title="", **kwargs): + self.screen = GrrScreen(self.g, title, **kwargs) + self.show(self.screen.root, self.screen.page) + return self.screen + + def fail_page(self, err, back="/"): + screen = self.screen or self.standard("", back=back) + screen.items([GrrEmptyItem("warn", err.text() if isinstance(err, GrrError) else str(err))]) + + +class GrrShell: + """The grr scene: boots against the server, owns the session, the store, + the router and the two panes, and turns server events into UI changes.""" + + def __init__(self, app, session=None): + self.app = app + self.session = session or GrrSession() + self.strings = GrrStrings() + self.client = GrrClient(self.session.url, self.session.token, self.strings) + self.client.on_unauthorized = lambda: self.app.run_on_loop(self.expired) + self.spec = {} + self.options = {} + self.me = None + self.settings = {"theme": "system", "variant": "black", "language": "system", "tier": "auto"} + self.palette = GrrPalette() + self.ui = GrrUi(self.palette, self.strings) + self.store = GrrStore(app, self.client) + self.events = GrrEmitter() + self.router = GrrRouter(self.on_route) + self.view = None + self.home = None + self.stream = None + self.link_up = False + self.menu_panel = None + self.dialogs = [] + self.toasts = [] + self.signed_in = False + self.drafts = {} + self.pane_state = {} + self.root = TuiCols(style=self.ui.st("ink", "bg"), id="grr") + self.side = TuiRows(w=GRR_SIDE_W, h="100%", style=self.ui.st("ink", "bg"), id="side") + self.main = TuiRows(w="1fr", h="100%", style=self.ui.st("ink", "bg"), id="main") + self.root.add(self.side, self.main) + self.side.visible = False + self.root.on_key = self.on_root_key + self.root.on_resize = lambda event: self.layout_sync() + app.grr = self + self.register_routes() + + # ---- routes + def register_routes(self): + r = self.router + r.add("/", None) + for pattern, name in GRR_ROUTES: + r.add(pattern, name) + + def factory(self, name): + return globals()[name] + + # ---- server calls + def call(self, method, path, body=None, then=None, fail=None, view=None): + def work(): + return self.client.request(method, path, body) + + def landed(result, error): + if view is not None and view.closed: + return + if error is not None: + if fail is not None: + fail(error) + else: + self.error(error) + elif then is not None: + then(result) + self.app.offload(work, landed) + + def batch(self, calls, then, fail=None, view=None): + def work(): + return [self.client.request(m, p, b) for (m, p, b) in calls] + + def landed(result, error): + if view is not None and view.closed: + return + if error is not None: + (fail or self.error)(error) + else: + then(result) + self.app.offload(work, landed) + + def error(self, err): + if isinstance(err, GrrError): + self.toast(err.text(), "bad") + else: + self.toast(str(err), "bad") + + def t(self, key, **vars): + return self.strings.t(key, **vars) + + # ---- boot + def boot(self): + self.show_main(self.message_panel("grr ...")) + + def work(): + return self.client.get("/api/spec"), self.client.get("/locales.json"), self.client.get("/api/auth/options") + + def landed(result, error): + if error is not None: + self.show_offline(error) + return + self.spec, locales, self.options = result + self.strings.table = locales + self.settings["language"] = "system" + self.strings.use("system", self.spec.get("languages", [])) + self.apply_settings() + if self.session.token: + self.enter() + else: + self.show_login() + self.app.offload(work, landed) + + def message_panel(self, text): + ui = self.ui + return TuiRich([TuiSpan(text.center(60), ui.st("muted", "bg"))], h="100%", valign="middle", style=ui.st("muted", "bg")) + + def show_offline(self, err): + ui = self.ui + screen = GrrScreen(self, "grr", back=False) + reason = (err.vars or {}).get("reason", "") if isinstance(err, GrrError) else str(err) + screen.items([GrrEmptyItem("warn", "grr cannot reach its server", "%s\n%s" % (self.session.url, reason)), + GrrNote("Start it with: grr-serve (or python3 -m grr serve), or point TAI at another one with GRR_URL.", center=True), + GrrButtonItem("Try again", self.boot, inline=True), GrrButtonItem("Change address", self.change_url, "secondary", inline=True)]) + self.layout_single(screen.root) + self.app.set_focus(screen.page) + + def change_url(self): + def done(value, values): + if value == "ok" and values[0].strip(): + self.session.url = values[0].strip().rstrip("/") + self.session.save() + self.client.base = self.session.url + self.boot() + self.dialog(title="grr server", text="Address of the grr server", fields=[{"value": self.session.url, "label": ""}], + actions=[{"label": self.t("ui.cancel"), "value": "cancel"}, {"label": self.t("ui.save"), "value": "ok", "primary": True}], done=done) + + def show_login(self, error=""): + self.signed_in = False + self.stop_stream() + view = GrrLoginView(self, {}, {"error": error}) + self.set_view(view) + view.open() + + def apply_settings(self): + s = self.settings + self.strings.use(s.get("language", "system"), self.spec.get("languages", [])) + self.palette = GrrPalette(self.spec, s.get("theme", "system"), s.get("variant", "black")) + self.palette.apply() + self.ui = GrrUi(self.palette, self.strings, self.spec) + self.restyle() + + def restyle(self): + """Apply a new palette: containers get the new background, the drawer + and the open screen are built again so every item reads the new ui.""" + ui = self.ui + for node in (self.root, self.side, self.main): + node.style = ui.st("ink", "bg") + for node in self.root.walk(): + if hasattr(node, "ui"): + node.ui = ui + if self.signed_in and self.home is not None: + self.home.destroy() + for child in list(self.side.children): + self.side.remove(child) + self.home = GrrHome(self) + self.side.add(self.home.root) + self.router.fire(self.router.current) + self.app.dirty_paint() + + def enter(self): + def landed(result, error): + if error is not None: + if isinstance(error, GrrError) and error.status == 401: + self.session.drop(self.session.token) + self.client.token = self.session.token + self.show_login() + else: + self.show_offline(error) + return + self.me = result + self.settings.update(result.get("settings") or {}) + self.apply_settings() + self.store.refresh(self.entered) + self.app.offload(lambda: self.client.get("/api/me"), landed) + + def entered(self, error): + if error is not None: + self.show_offline(error) + return + self.signed_in = True + self.sync_timezone() + self.start_stream() + self.home = GrrHome(self) + self.layout_dual() + self.router.go("/") + + def sync_timezone(self): + try: + zone = os.environ.get("TZ") or "" + if not zone and os.path.exists("/etc/timezone"): + with open("/etc/timezone", "r", encoding="utf-8") as fh: + zone = fh.read().strip() + except OSError: + zone = "" + if zone and self.settings.get("auto_tz") and self.settings.get("timezone") != zone: + self.call("PATCH", "/api/me/settings", {"timezone": zone}, then=lambda me: self.merge_me(me)) + + def merge_me(self, me): + self.me = me + self.settings.update(me.get("settings") or {}) + + def save_settings(self, patch, then=None): + def landed(me): + self.merge_me(me) + self.apply_settings() + if then is not None: + then(me) + self.call("PATCH", "/api/me/settings", patch, then=landed) + + def expired(self): + if self.signed_in: + self.stop_stream() + self.session.drop(self.client.token) + self.client.token = self.session.token + self.show_login() + + def sign_out(self): + self.call("POST", "/api/auth/logout", {}, then=lambda _r: self.after_sign_out(), fail=lambda _e: self.after_sign_out()) + + def after_sign_out(self): + self.stop_stream() + self.session.drop(self.client.token) + self.client.token = self.session.token + self.me = None + self.signed_in = False + if self.client.token: + self.enter() + else: + self.show_login() + + def switch_account(self, token): + self.stop_stream() + self.session.switch(token) + self.client.token = token + self.signed_in = False + self.enter() + + # ---- live events + def start_stream(self): + self.stop_stream() + path = "/api/events?token=" + urllib.parse.quote(self.client.token) + self.stream = GrrStream(self.client, path, self.on_event, self.app.run_on_loop, on_link=self.on_link) + self.stream.start() + + def stop_stream(self): + if self.stream is not None: + self.stream.stop() + self.stream = None + + def on_link(self, up): + was = self.link_up + self.link_up = up + if up and not was: + self.store.soon() + self.events.emit("link", up) + + PASS = ("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") + + def on_event(self, name, data): + if name in ("bot.update", "bot.delete", "chat.update", "chat.delete"): + self.store.soon() + self.events.emit(name, data) + elif name in GrrShell.PASS: + self.events.emit(name, data) + elif name == "status": + self.store.set_status(data) + self.events.emit("status", data) + elif name == "toast": + self.banner_event(data) + + def banner_event(self, data): + viewing = isinstance(self.view, GrrChatView) and self.view.chat and self.view.chat.get("id") == data.get("chat_id") + if viewing: + return + bot = next((b for b in self.store.bots if b.get("name") == data.get("bot")), None) + chat_id = data.get("chat_id") + self.banner(bot, data.get("text", ""), (lambda: self.router.go("/chat/" + chat_id)) if chat_id else None) + + # ---- layout + def set_view(self, view): + if self.view is not None: + self.view.destroy() + self.view = view + + def show_main(self, panel, focus=None): + for child in list(self.main.children): + self.main.remove(child) + self.main.add(panel) + self.layout_sync() + if focus is not None: + self.app.set_focus(focus) + else: + self.app.ensure_focus() + + def layout_single(self, panel): + self.signed_in = False + self.side.visible = False + self.show_main(panel) + + def layout_dual(self): + self.layout_sync() + + def on_route(self, route, params, query, raw): + self.close_layers() + path, _q = GrrRouter.parse(raw) + if not self.signed_in: + return + self.set_view(None) + if route is None or route.factory is None: + self.view = None + self.show_main(self.welcome_panel()) + else: + view = self.factory(route.factory)(self, params, query) + self.view = view + view.open() + if self.home is not None: + self.home.select(path) + self.layout_sync() + + def welcome_panel(self): + return GrrWelcome(self) + + def layout_sync(self): + cols = self.app.cols + wide = cols >= GRR_SIDE_MIN_COLS + at_home = self.router.current == "/" + if not self.signed_in or self.home is None: + self.side.visible = False + self.main.visible = True + return + if self.side.children == []: + self.side.add(self.home.root) + if wide: + self.side.visible = True + self.side.w = GRR_SIDE_W + self.main.visible = True + else: + self.side.visible = at_home + self.side.w = "100%" + self.main.visible = not at_home + self.app.ensure_focus() + + def on_root_key(self, event): + key = event.key or "" + if key == "ctrl+q": + event.stop() + self.app.quit() + elif key == "escape" and self.signed_in: + event.stop() + if self.view is not None and self.view.on_escape(): + return + if self.router.current != "/": + self.router.go("/") + elif key == "f1" and self.signed_in: + event.stop() + self.help() + elif key in ("f10", "ctrl+o") and self.signed_in and self.view is not None: + if self.view.header_action(): + event.stop() + elif self.signed_in and self.view is not None and self.view.hotkey(key): + event.stop() + elif key == "ctrl+n" and self.signed_in: + event.stop() + self.router.go("/chat/new") + elif key == "ctrl+f" and self.signed_in: + event.stop() + self.router.go("/search") + elif key == "ctrl+g" and self.signed_in: + event.stop() + self.router.go("/settings") + elif key == "f6" and self.signed_in: + event.stop() + self.cycle_panes() + + def cycle_panes(self): + pages = [n for n in self.root.walk() if isinstance(n, (GrrPage, TuiTextBox)) and n.is_shown()] + if pages: + self.app.focus_next() + + def help(self): + text = "\n".join("%-16s %s" % row for row in GRR_KEYS) + self.dialog(title=self.t("settings.help"), text=text, w=min(self.app.cols - 2, 96), actions=[{"label": self.t("ui.ok"), "value": True, "primary": True}]) + + # ---- overlays + def close_layers(self): + self.close_menu() + for dlg in list(self.dialogs): + self.close_dialog(dlg, None, quiet=True) + + def menu(self, items, anchor=None, align_left=False): + self.close_menu() + if anchor is None: + x, y, at = self.app.last_click + if time.time() - at < 1.0: + anchor = (x, y) + panel = GrrMenu(self, items, anchor, align_left) + self.menu_panel = panel + self.root.add(panel) + self.app.remember_focus() + self.app.set_focus(panel) + + def close_menu(self): + panel = self.menu_panel + if panel is not None: + self.menu_panel = None + self.app.close_panel(panel) + + def toast(self, text, kind=""): + ui = self.ui + text = " %s " % grr_clip(text, self.app.cols - 8) + style = ui.st("ink" if kind != "bad" else "danger", "rule-strong", bold=True) + panel = GrrToast([TuiSpan(text, style)], len(text), 1 + len(self.toasts)) + self.toasts.append(panel) + self.root.add(panel) + if len(self.toasts) > GRR_TOAST_MAX: + self.drop_toast(self.toasts[0]) + self.app.after(GRR_TOAST_SECS, lambda: self.drop_toast(panel)) + + def drop_toast(self, panel): + if panel in self.toasts: + self.toasts.remove(panel) + if panel.parent is not None: + panel.parent.remove(panel) + for pos, other in enumerate(self.toasts): + other.y = 1 + pos + + def banner(self, bot, text, click=None): + ui = self.ui + name = (bot or {}).get("name", "grr") + width = min(self.app.cols - 4, 60) + chip = GrrAvatar.chip(ui, bot) if bot else [TuiSpan(" ♪ ", ui.st("accent", "card"))] + rows = [chip + [TuiSpan(" " + grr_clip(name, width - 8), ui.st("ink", "card", bold=True))], + [TuiSpan(" " + grr_clip(text, width - 2), ui.st("muted", "card"))]] + rows = [grr_line(r, width, ui.st("ink", "card")) for r in rows] + panel = GrrToast(rows, width, 1 + len(self.toasts) * 2, click=(lambda: (self.drop_toast(panel), click and click()))) + self.toasts.append(panel) + self.root.add(panel) + self.app.after(GRR_BANNER_SECS, lambda: self.drop_toast(panel)) + + def dialog(self, title="", text="", fields=(), actions=None, done=None, w=None, body=None, rows=None, page_h=0): + """Open a modal dialog. fields: dicts with value, label, multiline, + rows, max, placeholder, secret. actions: dicts with label, value, + primary, danger. done(value, texts) runs once with the pressed + action's value (None for Esc). `rows` puts a page of items in the + dialog (pickers).""" + ui = self.ui + width = min(self.app.cols - 2, w or 64) + dlg = TuiDialog(w=width, layer="dialog", modal=True, border=True, rounded=True, padding=(0, 2), gap=0, + style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), title_style=ui.st("ink", "bg", bold=True)) + dlg.grr_done = done + dlg.grr_fields = [] + dlg.grr_actions = actions or [{"label": self.t("ui.ok"), "value": True, "primary": True}] + dlg.grr_open = True + if title: + dlg.add(TuiText(title, wrap=True, style=ui.st("ink", "card", bold=True))) + dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) + if text: + dlg.add(TuiText(text, wrap=True, style=ui.st("soft", "card"))) + dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) + primary = next((a for a in dlg.grr_actions if a.get("primary")), dlg.grr_actions[-1]) + for spec in fields: + if spec.get("label"): + dlg.add(TuiText(spec["label"], style=ui.st("muted", "card"))) + kind = GrrSecretBox if spec.get("secret") else TuiTextBox + box = kind(spec.get("value", ""), h=spec.get("rows", 1), border=True, rounded=True, padding=(0, 1), + max_chars=spec.get("max", 0), placeholder=spec.get("placeholder", ""), style=ui.st("ink", "bg"), + border_style=ui.st("rule-strong", "card"), focus_border_style=ui.st("accent", "card")) + if spec.get("rows", 1) <= 1: + box.send_on_enter = True + box.on_send = lambda _v, d=dlg, p=primary: self.dialog_pick(d, p) + dlg.grr_fields.append(box) + dlg.add(box) + if rows is not None: + page = GrrPage(ui, style=ui.st("ink", "card"), h=page_h or min(len(rows) * 2 + 2, max(4, self.app.rows - 12))) + dlg.grr_page = page + dlg.add(page) + page.set_items(rows) + if body is not None: + dlg.add(body) + if title or text or fields or rows is not None or body is not None: + dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) + bar = TuiCols(gap=2, style=ui.st("ink", "card"), align="right") + for act in dlg.grr_actions: + tone = "accent" if act.get("primary") else ("danger" if act.get("danger") else "ink") + bg = "accent" if act.get("primary") and not act.get("danger") else "rule-strong" + fg = "accent-ink" if act.get("primary") and not act.get("danger") else tone + btn = TuiButton(act["label"], brackets=False, border=False, style=ui.st(fg, bg, bold=True), + on_press=lambda _e, d=dlg, a=act: self.dialog_pick(d, a)) + btn.label = " %s " % act["label"] + bar.add(btn) + dlg.add(bar) + dlg.on_close = lambda _event, d=dlg: self.close_dialog(d, None) + dlg.on_key_extra = None + orig = dlg.on_key + + def on_key(event, d=dlg, original=orig, p=primary): + if event.key == "ctrl+s": + event.stop() + self.dialog_pick(d, p) + return + original(event) + dlg.on_key = on_key + self.dialogs.append(dlg) + self.root.add(dlg) + return dlg + + def dialog_pick(self, dlg, action): + self.close_dialog(dlg, action.get("value")) + + def close_dialog(self, dlg, value, quiet=False): + if not getattr(dlg, "grr_open", False): + return + dlg.grr_open = False + texts = [box.value for box in dlg.grr_fields] + if dlg in self.dialogs: + self.dialogs.remove(dlg) + if dlg.parent is not None: + self.app.close_panel(dlg) + done = dlg.grr_done + if done is not None and not quiet: + done(value, texts) + + def confirm(self, title, text, yes, then, danger=False): + self.dialog(title=title, text=text, actions=[{"label": self.t("ui.cancel"), "value": False}, {"label": yes, "value": True, "primary": True, "danger": danger}], + done=lambda value, _t: then() if value else None) + + def alert(self, title, text): + self.dialog(title=title, text=text) + + def edit(self, title, value, then, multiline=False, rows=6, max_chars=0, text=""): + def done(action, texts): + if action == "save": + then(texts[0]) + self.dialog(title=title, text=text, w=72 if multiline else 56, fields=[{"value": value, "rows": rows if multiline else 1, "multiline": multiline, "max": max_chars}], + actions=[{"label": self.t("ui.cancel"), "value": "cancel"}, {"label": self.t("ui.save"), "value": "save", "primary": True}], done=done) + + def pick(self, title, rows, text=""): + """A picker: rows are GrrRowItem with their own actions; the dialog + closes before an action runs.""" + holder = {} + for row in rows: + original = row.action + + def wrapped(row=row, original=original): + self.close_dialog(holder["dlg"], None, quiet=True) + if original: + original() + row.action = wrapped + row.selectable = True + holder["dlg"] = self.dialog(title=title, text=text, rows=grr_group(rows), actions=[{"label": self.t("ui.close"), "value": None}]) + return holder["dlg"] + + +# --------------------------------------------------------------------------- +# Part D: the sidebar, login, and the screens of the app (bots, profile, +# automations, library, projects, search, settings, templates). +# --------------------------------------------------------------------------- + +GRR_LANG_NAMES = {"zh-CN": "简体中文", "zh-TW": "繁體中文", "en": "English", "hi": "हिन्दी", "pl": "Polski", "es": "Español", "pt": "Português", + "ja": "日本語", "fr": "Français", "ko": "한국어", "de": "Deutsch", "nl": "Nederlands"} +GRR_TEMPLATE_ASK = "Create a template of yourself that I can share with somebody else." + + +def grr_bot_avatar(bot): + return lambda ui, bg: GrrAvatar.rows(ui, bot, bg, small=True) + + +def grr_user_avatar(account): + def art(ui, bg): + letter = ((account or {}).get("username") or "?")[:1].upper() + style = ui.st("ink", "rule-strong", bold=True) + return [[TuiSpan((" %s " % letter), style)], [TuiSpan(" ", style)]] + return art + + +def grr_group_avatar(bots): + def art(ui, bg): + rows = [[], []] + for bot in bots[:3]: + for pos, row in enumerate(GrrAvatar.chip_rows(ui, bot, bg)): + rows[pos].extend(row) + width = tui_span_width(rows[0]) + for pos in range(2): + rows[pos] = grr_line(rows[pos], 5, ui.st("ink", bg)) + return rows + return art + + +class GrrHome: + """The drawer: account, shortcuts, bots, pinned chats, bottom bar.""" + + def __init__(self, shell): + self.g = shell + ui = shell.ui + self.root = TuiRows(w="100%", h="100%", style=ui.st("ink", "bg")) + self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") + self.bar = GrrBar(ui, style=ui.st("ink", "bg")) + self.root.add(self.page, self.bar) + self.path = "" + self.pinned_open = True + self.offs = [shell.store.on("changed", lambda _d: self.paint()), shell.store.on("status", lambda _d: self.paint())] + self.paint() + + def destroy(self): + for off in self.offs: + off() + + def select(self, path): + self.path = path + self.paint() + + def latest(self): + g = self.g + chats = sorted([c for c in g.store.chats if c.get("kind") != "private"], key=lambda c: -c.get("updated", 0)) + g.router.go("/chat/" + chats[0]["id"] if chats else "/chat/new") + + def paint(self): + g = self.g + ui, t, store = g.ui, g.t, g.store + go = g.router.go + bots = sorted(store.bots, key=lambda b: -(((b.get("chat") or {}).get("updated")) or b.get("updated", 0))) + pinned = [c for c in store.chats if c.get("pinned") and c.get("kind") in ("general", "group")] + groups = [c for c in store.chats if c.get("kind") == "group" and not c.get("pinned")] + me = g.me or {} + items = [GrrConvItem(grr_user_avatar(me), me.get("username", ""), "", "grr " + str(me.get("version", "")), action=self.latest, key="me"), + GrrSpacer(1)] + items += grr_group([GrrRowItem(t("home.automations"), icon="automation", action=lambda: go("/automations"), key="n-auto"), + GrrRowItem(t("home.library"), icon="box", action=lambda: go("/library"), key="n-lib"), + GrrRowItem(t("home.projects"), icon="folder", action=lambda: go("/projects"), key="n-proj")]) + items += [GrrSpacer(1), GrrSectionHead(t("home.bots"), "plus", self.plus_menu)] + for bot in bots[:3]: + chat = bot.get("chat") or {} + working = store.is_working(bot["id"]) + items.append(GrrConvItem(grr_bot_avatar(bot), bot["name"], ui.fmt.list_time(chat.get("updated")), + (working.get("label") or t("chat.working")) if working else (chat.get("preview") or bot.get("description", "")), + dot=bool(chat.get("unread")), current=self.path == "/chat/" + chat.get("id", "-"), busy=bool(working), + action=(lambda c=chat: go("/chat/" + c["id"])), key="bot-" + bot["id"])) + for chat in groups[:2]: + members = [store.bot(i) for i in chat.get("bot_ids", [])] + items.append(GrrConvItem(grr_group_avatar([m for m in members if m]), chat.get("title", ""), ui.fmt.list_time(chat.get("updated")), + chat.get("preview") or t("bots.group"), dot=bool(chat.get("unread")), current=self.path == "/chat/" + chat["id"], + action=(lambda c=chat: go("/chat/" + c["id"])), key="grp-" + chat["id"])) + if len(bots) + len(groups) > 3: + items.append(GrrRowItem(t("home.show_more"), link=True, action=lambda: go("/bots"), key="more", pos="only")) + if not bots: + items.append(GrrNote(t("home.no_bots"))) + if pinned: + items += [GrrSpacer(1), GrrSectionHead(t("home.pinned"), "up" if self.pinned_open else "down", self.toggle_pinned)] + if self.pinned_open: + for chat in pinned: + items.append(GrrConvItem(lambda ui_, bg_: [[TuiSpan(" ", ui_.st("ink", bg_))], [TuiSpan(" ", ui_.st("ink", bg_))]], + chat.get("title") or t("chat.untitled"), "", ui.fmt.short_date(chat.get("updated", 0)), + current=self.path == "/chat/" + chat["id"], action=(lambda c=chat: go("/chat/" + c["id"])), + menu=(lambda c=chat: self.chat_menu(c)), key="pin-" + chat["id"])) + self.page.set_items(items) + self.bar.set([{"text": " %s %s" % (ui.icon("search"), t("home.search")), "fn": lambda: go("/search"), "bg": "card", "fill": True}, + {"text": " %s " % ui.icon("gear"), "fn": lambda: go("/settings"), "bg": "card"}, + {"text": " %s " % ui.icon("compose"), "fn": lambda: go("/chat/new"), "bg": "card"}]) + + def toggle_pinned(self): + self.pinned_open = not self.pinned_open + self.paint() + + def plus_menu(self): + g, t = self.g, self.g.t + g.menu([{"icon": "plus", "label": t("bots.new_bot"), "action": lambda: GrrBots.new_bot(g)}, + {"icon": "group", "label": t("bots.new_group"), "action": lambda: g.router.go("/bots?group=1")}]) + + def chat_menu(self, chat): + g, t = self.g, self.g.t + + def rename(): + g.edit(t("chat.rename"), chat.get("title", ""), lambda v: g.call("PATCH", "/api/chats/" + chat["id"], {"title": v})) + + def delete(): + def go(): + g.call("DELETE", "/api/chats/" + chat["id"], then=lambda _r: g.router.go("/") if chat["id"] in g.router.current else None) + g.confirm(t("chat.delete_title"), t("chat.delete_text"), t("ui.delete"), go, True) + g.menu([{"icon": "pin", "label": t("chat.unpin") if chat.get("pinned") else t("chat.pin"), + "action": lambda: g.call("PATCH", "/api/chats/" + chat["id"], {"pinned": not chat.get("pinned")})}, + {"icon": "edit", "label": t("chat.rename"), "action": rename}, "-", + {"icon": "trash", "label": t("ui.delete"), "danger": True, "action": delete}]) + + +class GrrBots: + """Actions shared by the bot list, the drawer and the chat menu.""" + + @staticmethod + def new_bot(g): + def landed(bot): + g.store.refresh(lambda _e: g.router.go("/bot/%s?edit=1" % bot["id"])) + g.call("POST", "/api/bots", {"name": g.t("bots.default_name")}, then=landed) + + @staticmethod + def new_group(g): + t = g.t + bots = g.store.bots + if len(bots) < 2: + g.toast(t("bots.group_need_two"), "bad") + return + cap = g.ui.cap("GROUP_MAX_BOTS", 6) + picked = [] + rows = [] + + def toggle(bot, on): + if on and len(picked) >= cap: + row = next(r for r in rows if r.key == bot["id"]) + row.set_toggle(False) + return + if on: + picked.append(bot["id"]) + elif bot["id"] in picked: + picked.remove(bot["id"]) + for bot in bots: + rows.append(GrrRowItem(bot["name"], lead=(lambda ui, bg, b=bot: GrrAvatar.chip(ui, b, bg)), toggle=False, + on_toggle=(lambda on, b=bot: toggle(b, on)), key=bot["id"])) + + def done(value, texts): + if value != "create": + return + if len(picked) < 2: + g.toast(t("bots.group_need_two"), "bad") + return + g.call("POST", "/api/chats", {"kind": "group", "bot_ids": list(picked), "title": texts[0]}, + then=lambda chat: g.store.refresh(lambda _e: g.router.go("/chat/" + chat["id"]))) + g.dialog(title=t("bots.new_group"), text=t("bots.group_hint", n=cap), fields=[{"value": "", "placeholder": t("bots.group_name"), "max": 60}], + rows=grr_group(rows), actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("bots.group_create"), "value": "create", "primary": True}], done=done) + + +class GrrLoginView(GrrView): + """Sign in with a DevPlace username or email and password, or a key.""" + + def open(self): + g, ui, t = self.g, self.ui, self.g.t + self.mode = "password" + self.busy = False + width = max(24, min(g.app.cols - 4, 50)) + self.width = width + self.root = TuiRows(w="1fr", h="100%", valign="middle", align="center", style=ui.st("ink", "bg")) + art = GrrAvatar.rows(ui, {"shape": "blob", "color": ui.palette.c("accent")}, "bg") + head = [[TuiSpan(" " * ((width - 7) // 2), ui.st()), *row] for row in art] + head += [[TuiSpan(" " * width, ui.st())], [TuiSpan("grr".center(width), ui.st("ink", "bg", bold=True))]] + for line in grr_wrap(t("app.tagline"), width - 2): + head.append([TuiSpan(line.center(width), ui.st("muted", "bg"))]) + self.root.add(TuiRich([grr_line(r, width, ui.st()) for r in head], w=width, style=ui.st("ink", "bg"))) + self.fields = TuiRows(w=width, style=ui.st("ink", "bg")) + self.root.add(TuiText(" ", h=1, w=width, style=ui.st("ink", "bg")), self.fields) + self.err = TuiText("", w=width, wrap=True, style=ui.st("danger", "bg")) + self.root.add(self.err) + self.submit = TuiButton(t("login.submit"), brackets=False, border=False, w=width, style=ui.st("accent-ink", "accent", bold=True), on_press=lambda _e: self.go(False)) + self.root.add(self.submit) + if (g.options or {}).get("env_key"): + self.root.add(TuiButton(t("login.use_session"), brackets=False, border=False, w=width, style=ui.st("ink", "card", bold=True), on_press=lambda _e: self.go(True))) + self.other = TuiButton("", brackets=False, border=False, w=width, style=ui.st("muted", "bg"), on_press=lambda _e: self.swap()) + self.root.add(self.other) + for line in grr_wrap(t("login.hint"), width - 2)[:6]: + self.root.add(TuiText(line, w=width, style=ui.st("faint", "bg"))) + if self.query.get("error"): + self.err.text = self.query["error"] + self.draw() + self.show(self.root, self.user) + + def box(self, placeholder, secret=False): + ui = self.ui + kind = GrrSecretBox if secret else TuiTextBox + box = kind("", h=1, w=self.width - 4, border=True, rounded=True, padding=(0, 1), placeholder=placeholder, style=ui.st("ink", "card"), + border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, + on_send=lambda _v: self.go(False)) + return box + + def draw(self): + t = self.g.t + for child in list(self.fields.children): + self.fields.remove(child) + self.err.text = "" + if self.mode == "key": + self.key = self.box(t("login.key_placeholder"), True) + self.fields.add(self.key) + self.user = self.key + self.other.label = t("login.use_password") + else: + self.user = self.box(t("login.username")) + self.passw = self.box(t("login.password"), True) + self.fields.add(self.user, self.passw) + self.other.label = t("login.use_key") + self.other.label = " %s " % self.other.label + self.g.app.set_focus(self.user) + + def swap(self): + self.mode = "key" if self.mode == "password" else "password" + self.draw() + + def go(self, use_env): + if self.busy: + return + g = self.g + if use_env: + body = {"use_env": True} + elif self.mode == "key": + body = {"api_key": self.key.value} + else: + body = {"username": self.user.value, "password": self.passw.value} + self.busy = True + self.err.text = "" + + def work(): + return g.client.request("POST", "/api/auth/login", body, quiet=True) + + def landed(result, error): + self.busy = False + if self.closed: + return + if error is not None: + self.err.text = error.text() if isinstance(error, GrrError) else str(error) + return + g.session.add(result["token"], result["account"]) + g.client.token = result["token"] + g.enter() + g.app.offload(work, landed) + + def on_escape(self): + return True + + +class GrrStripItem(GrrItem): + """The bots as big avatars in a row, names below. Left and Right walk + along it, Enter opens the chat. Wraps onto more lines on a narrow screen.""" + + selectable = True + + def __init__(self, bots, on_pick): + super().__init__() + self.bots = list(bots) + self.on_pick = on_pick + self.stops = max(1, len(self.bots)) + self.cols = 1 + + def nav(self, direction): + step = {"left": -1, "right": 1, "up": -self.cols, "down": self.cols}.get(direction) + if step is None: + return False + nxt = self.stop + step + if 0 <= nxt < len(self.bots): + self.stop = nxt + self.touch() + return True + return False + + def activate(self): + if self.bots: + self.on_pick(self.bots[min(self.stop, len(self.bots) - 1)]) + + def build(self, ui, width, selected): + m = grr_margin(width) + cell = 11 + self.cols = max(1, (width - 2 * m) // cell) + page = ui.st("ink", "bg") + out = [] + for start in range(0, len(self.bots), self.cols): + lines = [[TuiSpan(" " * m, page)] for _ in range(3)] + for off, bot in enumerate(self.bots[start:start + self.cols]): + focus = selected and start + off == self.stop + bg = "rule-strong" if focus else "bg" + art = GrrAvatar.rows(ui, bot, bg) + pad = (cell - 7) // 2 + for pos in range(2): + lines[pos].extend([TuiSpan(" " * pad, ui.st("ink", bg))] + list(art[pos]) + [TuiSpan(" " * (cell - 7 - pad), ui.st("ink", bg))]) + lines[2].append(TuiSpan(grr_clip(bot["name"], cell - 1).center(cell), ui.st("ink", bg, bold=focus))) + for pos in range(3): + self.hit(len(out) + pos, m + off * cell, m + (off + 1) * cell, (lambda b=bot: self.on_pick(b))) + for line in lines: + out.append(grr_line(line, width, page)) + out.append([TuiSpan(" " * width, page)]) + return out + + +class GrrBotsView(GrrView): + fallback = "/" + + def open(self): + g, t, ui = self.g, self.g.t, self.ui + right = [{"text": " %s " % ui.icon("search"), "fn": lambda: g.router.go("/search"), "bg": "card"}, + {"text": " %s " % ui.icon("plus"), "fn": self.plus, "bg": "card"}] + self.standard(t("bots.title"), back="/", right=right) + self.sub(g.store, "changed", lambda _d: self.paint()) + self.sub(g.store, "status", lambda _d: self.paint()) + self.paint() + if self.query.get("group"): + g.app.after(0.05, lambda: GrrBots.new_group(g)) + + def plus(self): + g, t = self.g, self.g.t + g.menu([{"icon": "plus", "label": t("bots.new_bot"), "action": lambda: GrrBots.new_bot(g)}, + {"icon": "group", "label": t("bots.new_group"), "action": lambda: GrrBots.new_group(g)}]) + + def paint(self): + g, ui, t, store = self.g, self.ui, self.g.t, self.g.store + rows = [] + for bot in store.bots: + chat = bot.get("chat") or {} + working = store.is_working(bot["id"]) + rows.append((chat.get("updated") or 0, GrrConvItem(grr_bot_avatar(bot), bot["name"], ui.fmt.list_time(chat.get("updated")), + (working.get("label") or t("chat.working")) if working else (chat.get("preview") or bot.get("description", "")), + dot=bool(chat.get("unread")), busy=bool(working), action=(lambda c=chat: g.router.go("/chat/" + c["id"])), key="b" + bot["id"]))) + for chat in [c for c in store.chats if c.get("kind") == "group"]: + members = [store.bot(i) for i in chat.get("bot_ids", [])] + rows.append((chat.get("updated", 0), GrrConvItem(grr_group_avatar([m for m in members if m]), chat.get("title", ""), ui.fmt.list_time(chat.get("updated")), + chat.get("preview", ""), dot=bool(chat.get("unread")), tag=t("bots.group"), action=(lambda c=chat: g.router.go("/chat/" + c["id"])), key="g" + chat["id"]))) + rows.sort(key=lambda r: -r[0]) + items = [r[1] for r in rows] or [GrrEmptyItem("hash", t("bots.empty"), t("bots.empty_sub"))] + if store.bots: + items.insert(0, GrrStripItem(store.bots, lambda b: g.router.go("/chat/" + (b.get("chat") or {}).get("id", "new")))) + self.screen.items(items) + + +class GrrProfileView(GrrView): + fallback = "/bots" + + def open(self): + g, t, ui = self.g, self.g.t, self.ui + self.tab = self.query.get("tab") or "info" + self.gen = 0 + self.shown = GRR_LIST_MORE + self.standard("", back="/bots") + self.screen.items([GrrEmptyItem("hash", "...")]) + self.get("/api/bots/" + self.params["id"], self.loaded) + + def loaded(self, bot): + g, t = self.g, self.g.t + self.bot = bot + self.screen.right = [self.screen.chip("share", self.share), self.screen.chip("more", self.more)] + self.screen.refresh() + self.sub(g.events, "bot.update", lambda d: self.refetch(d)) + self.paint() + if self.query.get("edit"): + g.app.after(0.05, self.rename) + + def refetch(self, data): + if data and data.get("id") == self.bot["id"]: + self.get("/api/bots/" + self.bot["id"], lambda bot: (setattr(self, "bot", bot), self.paint())) + + def more(self): + g, t, bot = self.g, self.g.t, self.bot + g.menu([{"icon": "copy", "label": t("profile.copy_id"), "action": lambda: g.copy_text(bot["id"])}, + {"icon": "trash", "label": t("profile.delete_bot"), "danger": True, "action": self.remove}]) + + def remove(self): + g, t, bot = self.g, self.g.t, self.bot + + def go(): + g.call("DELETE", "/api/bots/" + bot["id"], then=lambda _r: g.store.refresh(lambda _e: g.router.go("/bots", True))) + g.confirm(t("profile.delete_bot"), t("profile.delete_text", name=bot["name"]), t("ui.delete"), go, True) + + def share(self): + g, t, bot = self.g, self.g.t, self.bot + + def go(): + chat = bot["chat"] + g.call("POST", "/api/chats/%s/messages" % chat["id"], {"text": GRR_TEMPLATE_ASK}, then=lambda _r: g.router.go("/chat/" + chat["id"])) + g.confirm(t("profile.share_title"), t("profile.share_text"), t("ui.continue"), go) + + def patch(self, body, repaint=True): + def landed(res): + self.bot.update(res) + self.g.store.soon() + if repaint: + self.paint() + self.call("PATCH", "/api/bots/" + self.bot["id"], body, landed) + + def rename(self): + t = self.g.t + self.g.edit(t("profile.name"), self.bot["name"], lambda v: self.patch({"name": v}) if v.strip() else None, max_chars=60) + + def describe(self): + t = self.g.t + self.g.edit(t("profile.description"), self.bot.get("description", ""), lambda v: self.patch({"description": v}), True, 4, 400) + + def hero_items(self): + t, ui = self.g.t, self.ui + return [GrrHeroItem(self.bot), GrrNameCard(self.bot["name"], self.bot.get("description", ""), t("profile.description_ph"), self.rename, self.describe)] + + def paint(self): + t = self.g.t + tabs = GrrTabsItem([{"id": "info", "label": t("profile.info")}, {"id": "documents", "label": t("profile.documents")}, + {"id": "computer", "label": t("profile.computer")}, {"id": "links", "label": t("profile.links")}], self.tab, self.pick_tab) + tabs.key = "tabs" + self.tabs_item = tabs + self.screen.items(self.hero_items() + [tabs] + self.content()) + + def pick_tab(self, tab): + self.tab = tab + self.gen += 1 + self.screen.items(self.hero_items() + [self.tabs_item] + self.content()) + + def content(self): + for handle in self.timers: + self.g.app.cancel_timer(handle) + self.timers = [] + return {"info": self.info, "documents": self.documents, "computer": self.computer, "links": self.links}.get(self.tab, self.info)() + + def info(self): + g, t, ui, bot = self.g, self.g.t, self.ui, self.bot + go = g.router.go + defaults = {"shape": "blob", "color": (ui.palette.bot_colors + ["#d4a020"])[4]} + shapes = (self.g.spec.get("shapes") or list(GRR_SHAPES)) + tiles = [{"label": "", "on": bot.get("shape") == s, "art": (lambda ui_, bg_, s=s: GrrAvatar.rows(ui_, {"shape": s, "color": bot.get("color")}, bg_, small=True)), + "action": (lambda s=s: self.patch({"shape": s}))} for s in shapes] + swatches = GrrSwatchItem(ui.palette.bot_colors, bot.get("color"), lambda c: self.patch({"color": c})) + items = [GrrLabel(t("profile.character")), GrrTileGridItem(tiles, tile_h=4, min_w=9, max_cols=6), swatches, + GrrRowItem(t("profile.reset_default"), action=lambda: self.patch(defaults), tone="ink"), + GrrNote(t("profile.character_note")), GrrSpacer(1)] + items += grr_group([GrrRowItem(t("profile.instructions"), icon="file", chevron=True, action=lambda: go("/bot/%s/instructions" % bot["id"])), + GrrRowItem(t("profile.memories"), icon="inbox", value=str(bot.get("memories", 0)), chevron=True, action=lambda: go("/bot/%s/memories" % bot["id"])), + GrrRowItem(t("profile.skills"), icon="skills", value=str(bot.get("skills", 0)), chevron=True, action=lambda: go("/bot/%s/skills" % bot["id"]))]) + routines = bot.get("routines") or [] + rr = [GrrRowItem(r["name"], icon="clock", sub=ui.fmt.schedule(r.get("schedule")) + ("" if r.get("active") else " · " + t("automations.paused")), + chevron=True, action=(lambda r=r: go("/automation/" + r["id"]))) for r in routines] + rr.append(GrrRowItem(t("profile.add_routine"), icon="plus", link=True, action=lambda: go("/automations/new?bot=" + bot["id"]))) + items += [GrrLabel(t("profile.routines"))] + grr_group(rr) + engine = bot.get("voice") or (g.spec.get("voice") or {}).get("default_engine", "browser") + vr = [GrrRowItem(t("profile.voice_" + e), sub=t("profile.voice_" + e + "_sub"), icon="sparkle" if e == "neural" else "speaker", check=engine == e, + action=(lambda e=e: None if engine == e else self.patch({"voice": e}))) for e in (g.spec.get("voice") or {}).get("engines", ["browser", "neural"])] + items += [GrrLabel(t("profile.voice"))] + grr_group(vr) + [GrrNote(t("profile.voice_note"))] + items += grr_group([GrrRowItem(t("profile.notifications"), toggle=bool(bot.get("notifications")), + on_toggle=lambda v: self.patch({"notifications": v}, False))]) + [GrrNote(t("profile.notifications_note"))] + items += grr_group([GrrRowItem(t("profile.auto_approve"), sub=t("profile.auto_approve_note"), toggle=bool(bot.get("auto_approve")), + on_toggle=lambda v: self.patch({"auto_approve": v}, False))]) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("profile.share_template"), icon="share", chevron=True, action=self.share)]) + return items + + def documents(self): + t, ui = self.g.t, self.ui + gen = self.gen + + def landed(res): + if gen != self.gen: + return + files = res.get("files", []) + if not files: + rows = [GrrEmptyItem("file", t("profile.no_docs"), t("profile.no_docs_sub"))] + else: + rows = grr_group([GrrRowItem(f["name"], icon="image" if f.get("kind") == "media" else ("terminal" if f.get("kind") == "app" else "file"), + sub=ui.fmt.ago(f.get("created", 0)) + " · " + GrrFormat.bytes(f.get("size", 0)), chevron=True, + action=(lambda f=f: self.g.open_file(f))) for f in files[:self.shown]]) + if len(files) > self.shown: + rows.append(GrrButtonItem(t("home.show_more"), lambda: self.more_docs(), "ghost", inline=True)) + self.screen.items(self.hero_items() + [self.tabs_item] + rows) + self.get("/api/library?bot=" + self.bot["id"], landed) + return [GrrEmptyItem("file", "...")] + + def more_docs(self): + self.shown += GRR_LIST_MORE + self.pick_tab("documents") + + def computer(self): + g, t, ui, bot = self.g, self.g.t, self.ui, self.bot + meta = GrrNote("", center=True) + + def refresh(): + def landed(c): + meta.text = t("computer.disk") + ": " + t("computer.disk_" + c["disk"]["state"]) + (" · " + t("computer.busy") if c.get("busy") else "") + meta.touch() + self.call("GET", "/api/bots/%s/computer" % bot["id"], None, landed, lambda _e: None) + refresh() + self.every(4.0, refresh) + return [GrrScreenPreview(t("profile.screen_of", name=bot["name"])), + GrrButtonItem(t("profile.take_control"), lambda: g.router.go("/bot/%s/screen" % bot["id"]), inline=True), + GrrButtonItem(t("profile.open_terminal"), lambda: g.router.go("/bot/%s/computer" % bot["id"]), "secondary", inline=True, icon="terminal"), meta] + + def links(self): + t, ui = self.g.t, self.ui + gen = self.gen + + def landed(res): + if gen != self.gen: + return + links = res.get("links", []) + rows = [GrrEmptyItem("link", t("profile.no_links"), t("profile.no_links_sub"))] if not links else grr_group([ + GrrRowItem(l.get("title") or GrrFormat.domain(l["url"]), sub=l["url"], chevron="ext", + lead=(lambda ui_, bg, d=GrrFormat.domain(l["url"]): [TuiSpan(" %s " % ((d[:1] or "?").upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]), + action=(lambda l=l: self.g.open_url(l["url"]))) for l in links]) + self.screen.items(self.hero_items() + [self.tabs_item] + rows) + self.get("/api/bots/%s/links" % self.bot["id"], landed) + return [GrrEmptyItem("link", "...")] + + +class GrrHeroItem(GrrItem): + """A big avatar, centered.""" + + def __init__(self, bot): + super().__init__() + self.bot = bot + + def build(self, ui, width, selected): + art = GrrAvatar.rows(ui, self.bot, "bg", hero=True) + out = [[TuiSpan(" " * width, ui.st())]] + for row in art: + out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui.st())] + row, width, ui.st())) + return out + + +class GrrNameCard(GrrItem): + """The bot's name and description in one card; each opens its editor.""" + + selectable = True + + def __init__(self, name, desc, placeholder, on_name, on_desc): + super().__init__() + self.name = name + self.desc = desc + self.placeholder = placeholder + self.on_name = on_name + self.on_desc = on_desc + self.stops = 2 + + def nav(self, direction): + if direction in ("up", "down"): + nxt = self.stop + (1 if direction == "down" else -1) + if 0 <= nxt <= 1: + self.stop = nxt + self.touch() + return True + return False + + def activate(self): + (self.on_name if self.stop == 0 else self.on_desc)() + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + name_bg = "rule-strong" if selected and self.stop == 0 else "card" + desc_bg = "rule-strong" if selected and self.stop == 1 else "card" + rows = [GrrCard.top(ui, width)] + rows.append(GrrCard.line(ui, width, [TuiSpan(grr_clip(self.name, inner - 2).center(inner), ui.st("ink", name_bg, bold=True))], name_bg)) + self.hit(1, 0, width, self.on_name) + text = self.desc or self.placeholder + for line in grr_wrap(text, inner - 4)[:4]: + rows.append(GrrCard.line(ui, width, [TuiSpan(line.center(inner), ui.st("muted" if self.desc else "faint", desc_bg))], desc_bg)) + self.hit(len(rows) - 1, 0, width, self.on_desc) + rows.append(GrrCard.bottom(ui, width)) + return rows + + +class GrrSwatchItem(GrrItem): + selectable = True + + def __init__(self, colors, value, on_pick): + super().__init__() + self.colors = list(colors) + self.value = value + self.on_pick = on_pick + self.stops = len(self.colors) + if value in self.colors: + self.stop = self.colors.index(value) + + def nav(self, direction): + if direction in ("left", "right"): + nxt = max(0, min(len(self.colors) - 1, self.stop + (1 if direction == "right" else -1))) + if nxt != self.stop: + self.stop = nxt + self.touch() + return True + return False + + def activate(self): + self.on_pick(self.colors[self.stop]) + + def build(self, ui, width, selected): + m = grr_margin(width) + 1 + page = ui.st("ink", "bg") + spans, x = [TuiSpan(" " * m, page)], m + for pos, color in enumerate(self.colors): + on = color == self.value + focus = selected and pos == self.stop + mark = "◉" if on else "●" + spans.append(TuiSpan(" " + mark + " ", TuiStyle(fg=color, bg=ui.palette.c("rule-strong" if focus else "bg")))) + self.hit(0, x, x + 3, (lambda c=color: self.on_pick(c))) + x += 3 + return [grr_line(spans, width, page)] + + +class GrrScreenPreview(GrrItem): + """The framed placeholder of the bot's screen on the Computer tab.""" + + def __init__(self, caption): + super().__init__() + self.caption = caption + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + rows = [GrrCard.top(ui, width)] + for pos in range(3): + text = ui.icon("monitor") if pos == 1 else "" + rows.append(GrrCard.line(ui, width, [TuiSpan(text.center(inner), ui.st("muted", "card"))])) + rows.append(GrrCard.bottom(ui, width)) + rows.append(grr_line([TuiSpan(grr_clip(self.caption, inner).center(width), ui.st("muted", "bg"))], width, ui.st("ink", "bg"))) + return rows + + +class GrrInstructionsView(GrrView): + fallback = "/bots" + + def open(self): + t = self.g.t + self.standard(t("profile.instructions"), back="/bot/" + self.params["id"]) + self.get("/api/bots/" + self.params["id"], self.loaded) + + def loaded(self, bot): + self.bot = bot + self.paint() + + def paint(self): + g, t, bot = self.g, self.g.t, self.bot + cap = g.ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) + prompts = (g.spec.get("prompts") or {}) + + def editor(key, title, hint, default=""): + current = bot.get(key) or default + + def save(value): + body = {key: "" if (default and value.strip() == default.strip()) else value} + self.call("PATCH", "/api/bots/" + bot["id"], body, lambda res: (bot.update(res), self.paint(), g.toast(t("ui.saved")))) + items = [GrrLabel(title), GrrTextCard(current, action=lambda: g.edit(title, current, save, True, 10, cap), placeholder="")] + if default: + items.append(GrrRowItem(t("profile.reset_default"), action=lambda: save(default), tone="ink")) + items.append(GrrNote(hint)) + return items + items = editor("instructions", t("profile.instruction"), t("profile.instructions_hint")) + items += editor("persona", t("profile.persona"), t("profile.persona_hint", name="{name}"), prompts.get("persona", "")) + items += editor("rules", t("profile.rules"), t("profile.rules_hint"), prompts.get("rules", "")) + self.screen.items(items) + + +class GrrListView(GrrView): + """Memories and skills of a bot: list, add, open.""" + + path = "" + icon = "" + detail = "" + + def open(self): + self.standard(self.heading(), back="/bot/" + self.params["id"], right=[]) + self.sub(self.g.events, "bot.update", lambda _d: self.reload()) + self.get("/api/bots/" + self.params["id"], self.loaded) + + def loaded(self, bot): + self.bot = bot + self.screen.right = [self.screen.chip("plus", self.add)] + self.screen.refresh() + self.reload() + + def reload(self): + if getattr(self, "bot", None) is None: + return + self.get("/api/bots/%s/%s" % (self.bot["id"], self.path), self.paint) + + def paint(self, res): + items = res.get(self.path, []) + t = self.g.t + if not items: + self.screen.items(self.empty()) + return + self.screen.items(grr_group([GrrRowItem(self.title(it), sub=self.sub_of(it), chevron=True, wrap=True, + action=(lambda it=it: self.g.router.go("/%s/%s" % (self.detail, it["id"])))) for it in items])) + + def sub_of(self, item): + return "" + + +class GrrMemoriesView(GrrListView): + path, icon, detail = "memories", "inbox", "memory" + + def heading(self): + return self.g.t("profile.memories") + + def title(self, m): + return m["content"] + + def empty(self): + t = self.g.t + return [GrrEmptyItem("inbox", t("profile.no_memories"), t("profile.no_memories_sub"))] + + def add(self): + g, t = self.g, self.g.t + g.edit(t("profile.add_memory"), "", lambda v: self.call("POST", "/api/bots/%s/memories" % self.bot["id"], {"content": v}, lambda _r: self.reload()), True, 4, 1000) + + +class GrrSkillsView(GrrListView): + path, icon, detail = "skills", "skills", "skill" + + def heading(self): + return self.g.t("profile.skills") + + def title(self, s): + return s["name"] + + def sub_of(self, s): + return s.get("description", "") + + def empty(self): + t = self.g.t + return [GrrEmptyItem("skills", t("profile.no_skills"), t("profile.no_skills_sub"))] + + def add(self): + g, t = self.g, self.g.t + + def done(value, texts): + if value == "add": + self.call("POST", "/api/bots/%s/skills" % self.bot["id"], {"name": texts[0], "description": texts[1], "content": texts[2]}, lambda _r: self.reload()) + g.dialog(title=t("profile.add_skill"), w=72, fields=[{"value": "", "placeholder": t("profile.skill_name"), "max": 80}, + {"value": "", "placeholder": t("profile.skill_desc"), "max": 300}, + {"value": "", "placeholder": t("profile.skill_content"), "rows": 6, "max": 12000}], + actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.add"), "value": "add", "primary": True}], done=done) + + +class GrrMemoryView(GrrView): + def open(self): + self.standard(self.g.t("profile.memory"), back="/bots") + self.get("/api/memories/" + self.params["id"], self.loaded) + + def loaded(self, mem): + g, t = self.g, self.g.t + self.mem = mem + self.screen.back = "/bot/%s/memories" % mem["bot_id"] + self.screen.right = [self.screen.chip("trash", self.delete)] + self.screen.refresh() + self.paint() + + def paint(self): + g, t, mem = self.g, self.g.t, self.mem + + def save(value): + self.call("PATCH", "/api/memories/" + mem["id"], {"content": value}, lambda res: (mem.update(res), self.paint())) + self.screen.items([GrrLabel(t("profile.content")), GrrTextCard(mem["content"], action=lambda: g.edit(t("profile.memory"), mem["content"], save, True, 6, 1000))]) + + def delete(self): + g, t, mem = self.g, self.g.t, self.mem + g.confirm(t("ui.delete"), t("profile.delete_memory"), t("ui.delete"), lambda: self.call("DELETE", "/api/memories/" + mem["id"], None, lambda _r: g.router.back("/bot/%s/memories" % mem["bot_id"])), True) + + +class GrrSkillView(GrrView): + def open(self): + self.standard("", back="/bots") + self.get("/api/skills/" + self.params["id"], self.loaded) + + def loaded(self, skill): + self.skill = skill + self.screen.back = "/bot/%s/skills" % skill["bot_id"] + self.screen.title = skill["name"] + self.screen.right = [self.screen.chip("trash", self.delete)] + self.screen.refresh() + self.paint() + + def paint(self): + g, t, skill = self.g, self.g.t, self.skill + + def field(key, label, rows, cap): + def save(value): + self.call("PATCH", "/api/skills/" + skill["id"], {key: value}, lambda res: (skill.update(res), self.paint())) + return [GrrLabel(label), GrrTextCard(skill.get(key, ""), action=lambda: g.edit(label, skill.get(key, ""), save, True, rows, cap))] + self.screen.items(field("description", t("profile.description"), 3, 300) + field("content", t("profile.content"), 12, 12000)) + + def delete(self): + g, t, skill = self.g, self.g.t, self.skill + g.confirm(t("ui.delete"), t("profile.delete_skill"), t("ui.delete"), lambda: self.call("DELETE", "/api/skills/" + skill["id"], None, lambda _r: g.router.back("/bot/%s/skills" % skill["bot_id"])), True) + + +# ---- automations + +class GrrRoutineItem(GrrItem): + """A routine as a card: bot, name, prompt, schedule line.""" + + selectable = True + + def __init__(self, routine, on_open, on_menu): + super().__init__() + self.r = routine + self.action = on_open + self.on_menu = on_menu + self.key = routine["id"] + + def build(self, ui, width, selected): + inner = GrrCard.inner(width) + r = self.r + bg = "rule-strong" if selected else "card" + base = ui.st("ink", bg) + bot = r.get("bot") + chip = GrrAvatar.chip(ui, bot, bg) if bot else [TuiSpan(" %s " % ui.icon("automation"), ui.st("soft", bg))] + head = [TuiSpan(" ", base)] + chip + [TuiSpan(" " + grr_clip(r["name"], inner - 14), ui.st("ink" if r.get("active") else "muted", bg, bold=True))] + head = grr_line(head, inner - 4, base) + [TuiSpan(" %s " % ui.icon("moreV"), ui.st("soft", bg))] + lines = [head] + for line in grr_wrap(r.get("instruction", ""), inner - 3)[:3]: + lines.append([TuiSpan(" " + line, ui.st("muted", bg))]) + foot = (bot["name"] + " · " if bot else "") + ui.fmt.schedule(r.get("schedule")) + ("" if r.get("active") else " · " + ui.t("automations.paused")) + lines.append([TuiSpan(" " + grr_clip(foot, inner - 3), ui.st("faint", bg))]) + out = [GrrCard.top(ui, width, bg)] + [GrrCard.line(ui, width, line, bg) for line in lines] + [GrrCard.bottom(ui, width, bg)] + self.hit(1, width - grr_margin(width) - 6, width, lambda: self.on_menu(self.r)) + return out + + +class GrrAutomationsView(GrrView): + fallback = "/" + + def open(self): + g, t = self.g, self.g.t + self.standard(t("home.automations"), close=True, back="/", right=[]) + self.screen.right = [self.screen.chip("plus", lambda: g.router.go("/automations/new"))] + self.screen.refresh() + self.sub(g.events, "routine.update", lambda _d: self.reload()) + self.reload() + + def reload(self): + self.get("/api/routines", self.paint) + + def paint(self, res): + g, t = self.g, self.g.t + items = [GrrRoutineItem(r, (lambda r=r: g.router.go("/automation/" + r["id"])), self.menu) for r in res.get("routines", [])] + if not items: + items.append(GrrEmptyItem("automation", t("automations.empty"), t("automations.empty_sub"))) + items.append(GrrButtonItem(t("automations.new"), lambda: g.router.go("/automations/new"), "secondary", inline=True, icon="plus")) + self.screen.items(items) + + def menu(self, r): + g, t = self.g, self.g.t + + def delete(): + g.confirm(t("automations.delete"), t("automations.delete_text"), t("ui.delete"), lambda: g.call("DELETE", "/api/routines/" + r["id"]), True) + g.menu([{"icon": "play", "label": t("automations.run_now"), "action": lambda: g.call("POST", "/api/routines/%s/run" % r["id"], {}, then=lambda _x: g.toast(t("automations.started")))}, + {"icon": "pause" if r.get("active") else "play", "label": t("automations.pause") if r.get("active") else t("automations.resume"), + "action": lambda: g.call("PATCH", "/api/routines/" + r["id"], {"active": not r.get("active")})}, "-", + {"icon": "trash", "label": t("automations.delete"), "danger": True, "action": delete}]) + + +def grr_schedule_dialog(g, initial, done): + """The schedule picker: Every, Daily or Weekly. done(schedule) gets the + picked schedule dict, never a partial one.""" + t, ui = g.t, g.ui + state = {"kind": "interval", "every": 5, "unit": 60, "time": "09:00", "day": 0} + if initial: + state["kind"] = initial.get("kind", "interval") + if state["kind"] == "interval": + sec = int(initial.get("every_seconds", 300)) + unit = 86400 if sec % 86400 == 0 else (3600 if sec % 3600 == 0 else 60) + state.update({"every": max(1, sec // unit), "unit": unit}) + else: + state.update({"time": initial.get("time", "09:00"), "day": initial.get("day", 0)}) + kinds = (g.spec.get("schedule_kinds") or ["interval", "daily", "weekly"]) + box = {} + + def build(): + rows = [GrrSegmentedItem([{"id": k, "label": t("sched.kind_" + k)} for k in kinds], state["kind"], lambda k: (state.update(kind=k), rebuild()))] + if state["kind"] == "interval": + units = [(60, t("sched.minutes")), (3600, t("sched.hours")), (86400, t("sched.days"))] + rows.append(GrrSegmentedItem([{"id": u, "label": l} for u, l in units], state["unit"], lambda u: state.update(unit=u))) + elif state["kind"] == "weekly": + days = [GrrFormat.weekday(i)[:3] for i in range(7)] + rows.append(GrrSegmentedItem([{"id": i, "label": d} for i, d in enumerate(days)], state["day"], lambda i: state.update(day=i))) + return rows + + def rebuild(): + dlg = box["dlg"] + dlg.grr_page.set_items(build()) + field = dlg.grr_fields[0] + label = t("sched.every") if state["kind"] == "interval" else t("sched.time") + field.placeholder = label + field.lines = [str(state["every"]) if state["kind"] == "interval" else state["time"]] + field.cx = len(field.lines[0]) + + def finish(value, texts): + if value != "done": + return + raw = texts[0].strip() + if state["kind"] == "interval": + try: + n = float(raw) + except ValueError: + g.toast(t("sched.floor", n=g.ui.cap("ROUTINE_MIN_INTERVAL", 30)), "bad") + return + secs = int(round(n * state["unit"])) + floor = g.ui.cap("ROUTINE_MIN_INTERVAL", 30) + if secs < floor: + g.toast(t("sched.floor", n=floor), "bad") + return + done({"kind": "interval", "every_seconds": secs}) + elif not re.match(r"^\d{1,2}:\d{2}$", raw): + g.toast(t("sched.need_time"), "bad") + elif state["kind"] == "daily": + done({"kind": "daily", "time": raw.zfill(5)}) + else: + done({"kind": "weekly", "day": state["day"], "time": raw.zfill(5)}) + first = str(state["every"]) if state["kind"] == "interval" else state["time"] + dlg = g.dialog(title=t("automations.select_schedule"), w=60, fields=[{"value": first, "placeholder": t("sched.every")}], rows=build(), page_h=5, + actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.done"), "value": "done", "primary": True}], done=finish) + box["dlg"] = dlg + + +class GrrAutomationView(GrrView): + fallback = "/automations" + + def open(self): + self.standard("", back="/automations") + self.get("/api/routines/" + self.params["id"], self.loaded) + + def loaded(self, r): + g, t = self.g, self.g.t + self.r = r + self.screen.title = r["name"] + self.screen.right = [self.screen.chip("moreV", self.more)] + self.screen.refresh() + self.sub(g.events, "routine.update", lambda d: self.refetch(d)) + self.paint() + + def refetch(self, d): + if d and d.get("id") == self.r["id"] and not d.get("deleted"): + self.get("/api/routines/" + self.r["id"], lambda r: (setattr(self, "r", r), self.paint())) + + def more(self): + g, t, r = self.g, self.g.t, self.r + + def delete(): + g.confirm(t("automations.delete"), t("automations.delete_text"), t("ui.delete"), lambda: self.call("DELETE", "/api/routines/" + r["id"], None, lambda _x: g.router.back("/automations")), True) + g.menu([{"icon": "trash", "label": t("automations.delete"), "danger": True, "action": delete}]) + + def patch(self, body): + self.call("PATCH", "/api/routines/" + self.r["id"], body, lambda res: (setattr(self, "r", res), self.paint())) + + def paint(self): + g, t, ui, r = self.g, self.g.t, self.ui, self.r + cap = ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) + + def save_text(value): + if not value.strip(): + g.toast(t("automations.prompt_empty"), "bad") + return + self.patch({"instruction": value}) + + def pick_schedule(): + grr_schedule_dialog(g, r.get("schedule"), lambda s: self.patch({"schedule": s})) + bot = r.get("bot") + items = grr_group([GrrRowItem(t("automations.active"), toggle=bool(r.get("active")), on_toggle=lambda v: self.patch({"active": v}))]) + items += [GrrLabel(t("automations.instruction")), GrrTextCard(r.get("instruction", ""), action=lambda: g.edit(t("automations.instruction"), r.get("instruction", ""), save_text, True, 8, cap)), + GrrLabel(t("automations.planning"))] + items += grr_group([GrrRowItem(t("automations.schedule"), icon="clock", value=ui.fmt.schedule(r.get("schedule")), chevron=True, action=pick_schedule), + GrrRowItem(t("automations.bot"), value=bot["name"] if bot else "-", lead=(lambda ui_, bg: GrrAvatar.chip(ui_, bot, bg)) if bot else None, chevron=True, + action=(lambda: g.router.go("/bot/" + bot["id"])) if bot else None)]) + items += [GrrLabel(t("automations.notifications"))] + items += grr_group([GrrRowItem(t("automations.push"), icon="bell", toggle=bool(r.get("notify_push")), on_toggle=lambda v: self.patch({"notify_push": v})), + GrrRowItem(t("automations.email"), icon="inbox", toggle=bool(r.get("notify_email")), on_toggle=lambda v: self.patch({"notify_email": v}))]) + items += [GrrSpacer(1), GrrButtonItem(t("automations.run_now"), lambda: self.call("POST", "/api/routines/%s/run" % r["id"], {}, lambda _x: g.toast(t("automations.started"))), "secondary")] + items.append(GrrNote(t("automations.next", when=ui.fmt.next_run(r.get("next_run")) if r.get("active") else "-"))) + if r.get("last_run"): + ok = r.get("last_status") == "ok" + items.append(GrrNote(t("automations.last", when=ui.fmt.ago(r["last_run"]), status=t("automations.status_" + (r.get("last_status") or "ok"))), tone="success" if ok else "danger")) + if r.get("last_result"): + items.append(GrrTextCard(r["last_result"])) + self.screen.items(items) + + +class GrrNewAutomationView(GrrView): + fallback = "/automations" + + def open(self): + g, t, ui = self.g, self.g.t, self.ui + self.s = None + self.tier = "auto" + self.bot_id = self.query.get("bot") or (g.store.bots[0]["id"] if g.store.bots else "") + self.name = "" + self.text = "" + self.push = True + self.email = False + self.standard(t("automations.new"), close=True, back="/automations") + self.screen.right = [{"text": " %s " % t("ui.create"), "fn": self.create, "bg": "accent", "fg": "accent-ink", "bold": True}] + self.screen.refresh() + self.paint() + + def paint(self): + g, t, ui = self.g, self.g.t, self.ui + cap = ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) + bots = [{"label": b["name"], "checked": b["id"] == self.bot_id, "action": (lambda b=b: self.set_bot(b["id"]))} for b in g.store.bots] + tiers = [{"label": t("tier." + x), "checked": x == self.tier, "action": (lambda x=x: self.set_tier(x))} for x in (g.spec.get("tiers") or ["auto", "fast", "expert"])] + bot = g.store.bot(self.bot_id) + items = [GrrHeading(ui.icon("automation"), center=True), + GrrRowItem(self.name or t("automations.name_ph"), icon="edit", tone="", action=lambda: g.edit(t("automations.name"), self.name, self.set_name, max_chars=60), pos="only"), + GrrLabel(t("automations.planning")), + GrrRowItem(ui.fmt.schedule(self.s) if self.s else t("automations.select_schedule"), icon="clock" if self.s else "plus", link=not self.s, action=self.pick_schedule), + GrrLabel(t("automations.instructions")), + GrrTextCard(self.text, placeholder=t("automations.instruction_ph"), action=lambda: g.edit(t("automations.instructions"), self.text, self.set_text, True, 8, cap)), + GrrSpacer(1), + GrrChipsItem([{"label": bot["name"] if bot else "-", "icon": "user", "action": lambda: g.menu(bots)}, + {"label": t("tier." + self.tier), "icon": "sparkle", "action": lambda: g.menu(tiers)}]), + GrrLabel(t("automations.notifications"))] + items += grr_group([GrrRowItem(t("automations.push"), icon="bell", toggle=self.push, on_toggle=lambda v: setattr(self, "push", v)), + GrrRowItem(t("automations.email"), icon="inbox", toggle=self.email, on_toggle=lambda v: setattr(self, "email", v))]) + self.screen.items(items) + + def set_bot(self, bot_id): + self.bot_id = bot_id + self.paint() + + def set_tier(self, tier): + self.tier = tier + self.paint() + + def set_name(self, value): + self.name = value + self.paint() + + def set_text(self, value): + self.text = value + self.paint() + + def pick_schedule(self): + grr_schedule_dialog(self.g, self.s, lambda s: (setattr(self, "s", s), self.paint())) + + def create(self): + g, t = self.g, self.g.t + if not self.text.strip(): + g.alert("", t("automations.prompt_empty")) + elif not self.s: + g.alert("", t("automations.need_schedule")) + elif not self.bot_id: + g.alert("", t("automations.need_bot")) + else: + body = {"bot_id": self.bot_id, "name": self.name or self.text[:40], "instruction": self.text, "schedule": self.s, + "notify_push": self.push, "notify_email": self.email, "tier": self.tier} + self.call("POST", "/api/routines", body, lambda r: g.router.go("/automation/" + r["id"], True)) + + +# ---- library, projects, search + +GRR_FILE_ICON = {"media": "image", "app": "terminal", "audio": "mic", "file": "file"} + + +class GrrLibraryView(GrrView): + fallback = "/" + + def open(self): + g, t, ui = self.g, self.g.t, self.ui + self.tab = "all" + self.q = "" + self.sort = "new" + self.files = [] + self.standard(t("home.library"), back="/") + self.sub(g.events, "library.update", lambda _d: self.reload()) + self.reload() + + def reload(self): + self.get("/api/library", self.loaded) + + def loaded(self, res): + self.files = res.get("files", []) + self.paint() + + def paint(self): + g, t, ui = self.g, self.g.t, self.ui + tabs = GrrTabsItem([{"id": "all", "label": t("library.all")}, {"id": "media", "label": t("library.media")}, {"id": "app", "label": t("library.apps")}, + {"id": "file", "label": t("library.files")}], self.tab, self.pick_tab) + tabs.key = "tabs" + sorts = [{"label": t("library.sort_" + s), "checked": self.sort == s, "action": (lambda s=s: self.set_sort(s))} for s in ("new", "old", "name", "size")] + tools = GrrChipsItem([{"label": t("library.sort_" + self.sort), "icon": "tune", "action": lambda: g.menu(sorts)}, + {"label": self.q or t("ui.search"), "icon": "search", "caret": False, "action": self.ask_filter}, + {"label": t("library.upload"), "icon": "plus", "caret": False, "action": self.upload}]) + rows = [f for f in self.files if (self.tab == "all" or (f.get("kind") in ("file", "audio") if self.tab == "file" else f.get("kind") == self.tab)) and (not self.q or self.q in f["name"].lower())] + by = {"new": lambda f: -f.get("created", 0), "old": lambda f: f.get("created", 0), "name": lambda f: f["name"].lower(), "size": lambda f: -f.get("size", 0)} + rows.sort(key=by[self.sort]) + if rows: + tiles = [{"label": f["name"], "sub": ui.fmt.ago(f.get("created", 0)) + " · " + GrrFormat.bytes(f.get("size", 0)), "icon": GRR_FILE_ICON.get(f.get("kind"), "file"), + "action": (lambda f=f: g.open_file(f))} for f in rows] + body = GrrTileGridItem(tiles, tile_h=6) + else: + text = {"app": t("library.no_apps"), "media": t("library.no_media"), "file": t("library.no_files")}.get(self.tab, t("library.empty")) + body = GrrEmptyItem("box", text, t("library.empty_sub")) + self.screen.items([tabs, tools, GrrSpacer(1), body]) + + def pick_tab(self, tab): + self.tab = tab + self.paint() + + def set_sort(self, sort): + self.sort = sort + self.paint() + + def ask_filter(self): + t = self.g.t + self.g.edit(t("ui.search"), self.q, lambda v: (setattr(self, "q", v.strip().lower()), self.paint())) + + def upload(self): + g, t = self.g, self.g.t + + def send(path): + path = os.path.expanduser(path.strip()) + try: + size = os.path.getsize(path) + if size > g.ui.cap("UPLOAD_MAX_BYTES", 20971520): + raise GrrError(0, "too_big", t("err.too_big", max=GrrFormat.bytes(g.ui.cap("UPLOAD_MAX_BYTES", 20971520)))) + with open(path, "rb") as fh: + data = fh.read() + except (OSError, GrrError) as exc: + g.error(exc if isinstance(exc, GrrError) else GrrError(0, "file", str(exc))) + return + g.app.offload(lambda: g.client.upload(os.path.basename(path), data), lambda _r, e: (g.error(e) if e else self.reload())) + g.edit(t("library.upload"), "", send, text="Path of the file on this machine") + + +class GrrProjectsView(GrrView): + fallback = "/" + + def open(self): + g, t = self.g, self.g.t + self.filter = "all" + self.sort = "updated" + self.standard(t("home.projects"), back="/") + self.screen.right = [self.screen.chip("plus", self.create)] + self.screen.refresh() + self.load() + + def load(self): + self.get("/api/projects?sort=%s%s" % (self.sort, "&starred=1" if self.filter == "starred" else ""), self.paint) + + def paint(self, res): + g, t, ui = self.g, self.g.t, self.ui + filters = [{"label": t("projects.all"), "checked": self.filter == "all", "action": lambda: self.set("filter", "all")}, + {"label": t("projects.starred"), "checked": self.filter == "starred", "action": lambda: self.set("filter", "starred")}] + sorts = [{"label": t("projects.last_edited"), "checked": self.sort == "updated", "action": lambda: self.set("sort", "updated")}, + {"label": t("projects.name"), "checked": self.sort == "name", "action": lambda: self.set("sort", "name")}] + chips = GrrChipsItem([{"label": t("projects.all") if self.filter == "all" else t("projects.starred"), "icon": "tune", "action": lambda: g.menu(filters)}, + {"label": t("projects.last_edited") if self.sort == "updated" else t("projects.name"), "icon": "tune", "action": lambda: g.menu(sorts)}]) + projects = res.get("projects", []) + if projects: + tiles = [{"label": p["name"], "sub": t("projects.modified", date=ui.fmt.short_date(p.get("updated", 0))), "icon": "star" if p.get("starred") else "folder", + "on": bool(p.get("starred")), "action": (lambda p=p: g.router.go("/project/" + p["id"]))} for p in projects] + body = GrrTileGridItem(tiles, tile_h=6) + else: + body = GrrEmptyItem("folder", t("projects.empty"), t("projects.empty_sub")) + self.screen.items([chips, GrrSpacer(1), body]) + + def set(self, key, value): + setattr(self, key, value) + self.load() + + def create(self): + g, t = self.g, self.g.t + + def done(value, texts): + if value == "create": + self.call("POST", "/api/projects", {"name": texts[0], "instructions": texts[1]}, lambda p: g.router.go("/project/" + p["id"])) + g.dialog(title=t("projects.new"), w=64, fields=[{"value": "", "placeholder": t("projects.name_ph"), "max": 60}, + {"value": "", "placeholder": t("projects.instructions_ph"), "rows": 4}], + actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.create"), "value": "create", "primary": True}], done=done) + + +class GrrProjectView(GrrView): + fallback = "/projects" + + def open(self): + self.standard("", back="/projects", foot=None) + self.sub(self.g.events, "chat.update", lambda _d: self.reload()) + self.reload() + + def reload(self): + self.get("/api/projects/" + self.params["id"], self.loaded) + + def loaded(self, p): + self.p = p + t, ui = self.g.t, self.ui + self.screen.title = ("%s " % ui.icon("star") if p.get("starred") else "") + p["name"] + self.screen.right = [self.screen.chip("moreV", self.menu)] + self.screen.refresh() + chats = p.get("chats") or [] + items = [] + if not chats: + items.append(GrrEmptyItem("compose", t("projects.no_chats"), t("projects.no_chats_sub"))) + for c in chats: + items.append(GrrConvItem(lambda ui_, bg: [[TuiSpan(" ", ui_.st("ink", bg))], [TuiSpan(" ", ui_.st("ink", bg))]], c.get("title") or t("chat.untitled"), "", + ui.fmt.short_date(c.get("updated", 0)), action=(lambda c=c: self.g.router.go("/chat/" + c["id"])), + menu=(lambda c=c: self.chat_menu(c)), key=c["id"])) + items.append(GrrButtonItem(t("projects.new_chat"), lambda: self.g.router.go("/chat/new?project=" + p["id"]), "secondary", inline=True, icon="compose")) + self.screen.items(items) + + def chat_menu(self, c): + g, t = self.g, self.g.t + g.menu([{"icon": "trash", "label": t("ui.delete"), "danger": True, "action": lambda: g.call("DELETE", "/api/chats/" + c["id"], then=lambda _r: self.reload())}]) + + def menu(self): + g, t, p = self.g, self.g.t, self.p + + def rename(): + g.edit(t("chat.rename"), p["name"], lambda v: self.call("PATCH", "/api/projects/" + p["id"], {"name": v}, lambda _r: self.reload()), max_chars=60) + + def instructions(): + g.edit(t("projects.instructions"), p.get("instructions", ""), lambda v: self.call("PATCH", "/api/projects/" + p["id"], {"instructions": v}, lambda _r: self.reload()), + True, 6, g.ui.cap("INSTRUCTIONS_MAX_CHARS", 12000), text=t("projects.instructions_hint")) + + def delete(): + g.confirm(t("projects.delete"), t("projects.delete_text"), t("ui.delete"), lambda: self.call("DELETE", "/api/projects/" + p["id"], None, lambda _r: g.router.go("/projects", True)), True) + g.menu([{"icon": "edit", "label": t("chat.rename"), "action": rename}, + {"icon": "star", "label": t("projects.unstar") if p.get("starred") else t("projects.star"), + "action": lambda: self.call("PATCH", "/api/projects/" + p["id"], {"starred": not p.get("starred")}, lambda _r: self.reload())}, + {"icon": "file", "label": t("projects.instructions"), "action": instructions}, "-", + {"icon": "trash", "label": t("ui.delete"), "danger": True, "action": delete}]) + + +class GrrSearchView(GrrView): + fallback = "/" + + def open(self): + g, t, ui = self.g, self.g.t, self.ui + self.kind = "all" + self.q = "" + self.seq = 0 + self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) + self.bar = GrrBar(ui, style=ui.st("ink", "bg")) + self.box = TuiTextBox("", h=1, border=True, rounded=True, padding=(0, 1), placeholder=t("ui.search"), style=ui.st("ink", "card"), + border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.run()) + self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") + self.root.add(self.bar, self.box, self.page) + self.screen = None + self.refresh_bar() + self.every(0.25, self.watch) + self.run() + self.show(self.root, self.box) + + def refresh_bar(self): + g, t, ui = self.g, self.g.t, self.ui + kinds = [{"label": t("search.kind_" + k), "checked": self.kind == k, "action": (lambda k=k: self.set_kind(k))} for k in (g.spec.get("search_kinds") or ["all"])] + self.bar.set([{"text": " %s " % ui.icon("close"), "fn": lambda: g.router.back("/"), "bg": "card", "bold": True}, + {"text": t("ui.search") + " · " + t("search.kind_" + self.kind), "fill": True, "align": "center", "bold": True}, + {"text": " %s " % ui.icon("filter"), "fn": lambda: g.menu(kinds), "bg": "card"}]) + + def on_escape(self): + self.g.router.back("/") + return True + + def set_kind(self, kind): + self.kind = kind + self.refresh_bar() + self.run() + + def watch(self): + if self.box.value != self.q: + self.q = self.box.value + self.run() + + def run(self): + self.seq += 1 + mine = self.seq + path = "/api/search?kind=%s&q=%s" % (self.kind, urllib.parse.quote(self.q)) + + def landed(res): + if mine == self.seq: + self.paint(res) + self.get(path, landed) + + def paint(self, res): + g, t, ui = self.g, self.g.t, self.ui + results = res.get("results", []) + if not results: + self.page.set_items([GrrEmptyItem("search", t("search.none"), t("search.none_sub"))]) + return + + def snippet(x): + return re.sub(r"[\[\]]", "", str(x.get("snippet", ""))) + items = [] + blank = lambda ui_, bg: [[TuiSpan(" ", ui_.st("ink", bg))], [TuiSpan(" ", ui_.st("soft", bg))]] + for x in results: + kind = x.get("type") + if kind == "bot": + bot = g.store.bot(x["id"]) or x.get("bot") + items.append(GrrConvItem(grr_bot_avatar(bot), x["title"], "", snippet(x), tag=t("search.tag_bot"), + action=lambda b=bot: g.router.go("/chat/" + (g.store.bot(b["id"]) or {}).get("chat", {}).get("id", "new")))) + elif kind == "group": + items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("bots.group"), action=lambda x=x: g.router.go("/chat/" + x["id"]))) + elif kind == "message": + items.append(GrrConvItem(blank, x["title"], ui.fmt.short_date(x.get("ts", 0)), snippet(x), action=lambda x=x: g.router.go("/chat/" + x["chat_id"]))) + elif kind == "file": + items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("search.tag_file"), + action=lambda x=x: g.open_file({"id": x["id"], "name": x["title"], "mime": x.get("snippet", ""), "kind": x.get("kind")}))) + else: + items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("search.tag_routine"), action=lambda x=x: g.router.go("/automation/" + x["id"]))) + self.page.set_items(items) + + +# --------------------------------------------------------------------------- +# Part E: settings and its sub screens, templates, shell helpers. +# --------------------------------------------------------------------------- + + +class GrrSettingsView(GrrView): + fallback = "/" + + def open(self): + t = self.g.t + self.standard(t("home.settings"), close=True, back="/") + self.batch([("GET", "/api/rules", None), ("GET", "/api/usage", None)], self.paint) + + def paint(self, res): + g, t, ui = self.g, self.g.t, self.ui + rules, usage = res + s = g.settings + me = g.me or {} + go = g.router.go + docs = lambda name: (lambda: g.open_url("%s/docs/%s.html" % (g.client.base, name))) + items = grr_group([GrrRowItem(me.get("username", ""), sub=me.get("email", ""), lead=(lambda ui_, bg: [TuiSpan(" %s " % (me.get("username", "?")[:1].upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]), + chevron=True, action=lambda: go("/settings/account")), + GrrRowItem(t("settings.usage"), value="%s%%" % usage.get("pct", 0), chevron=True, action=lambda: go("/settings/usage"))]) + items += [GrrSpacer(1)] + items += grr_group([GrrRowItem(t("settings.connect"), sub=t("settings.connect_sub"), chevron=True, action=lambda: go("/settings/connectors")), + GrrRowItem(t("settings.builder"), sub=t("settings.builder_sub"), chevron=True, action=lambda: go("/settings/builder")) if me.get("is_admin") else None]) + items += [GrrLabel(t("settings.bots"))] + items += grr_group([GrrRowItem(t("settings.auto_review"), sub=t("settings.auto_review_sub"), toggle=bool(s.get("auto_review")), on_toggle=lambda v: g.save_settings({"auto_review": v})), + GrrRowItem(t("settings.rules"), value=str(len(rules.get("rules", []))), chevron=True, action=lambda: go("/settings/rules")), + GrrRowItem(t("settings.auto_tz"), sub=t("settings.auto_tz_sub"), toggle=bool(s.get("auto_tz")), on_toggle=lambda v: g.save_settings({"auto_tz": v}, lambda _m: g.sync_timezone() if v else None)), + GrrRowItem(t("settings.timezone"), value=s.get("timezone", ""), chevron=True, action=lambda: go("/settings/timezone")), + GrrRowItem(t("settings.computer"), chevron=True, action=lambda: go("/settings/computer"))]) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.notifications"), toggle=bool(s.get("notifications")), on_toggle=lambda v: g.save_settings({"notifications": v}))]) + lang = s.get("language", "system") + items += [GrrSpacer(1)] + grr_group([ + GrrRowItem(t("settings.appearance"), value=t("appearance." + s.get("theme", "system")) + " · " + t("appearance.v_" + s.get("variant", "black")), chevron=True, action=lambda: go("/settings/appearance")), + GrrRowItem(t("settings.language"), value=t("lang.system") if lang == "system" else GRR_LANG_NAMES.get(lang, lang), chevron=True, action=lambda: go("/settings/language")), + GrrRowItem(t("settings.haptics"), value=t("ui.on") if s.get("haptics") else t("ui.off"), chevron=True, action=lambda: go("/settings/haptics")), + GrrRowItem(t("settings.widget"), chevron=True, action=lambda: go("/settings/widget")), + GrrRowItem(t("settings.advanced"), chevron=True, action=lambda: go("/settings/advanced"))]) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.help"), chevron="ext", action=docs("help")), GrrRowItem(t("settings.privacy"), chevron="ext", action=docs("privacy")), + GrrRowItem(t("settings.terms"), chevron="ext", action=docs("terms")), GrrRowItem(t("settings.bot_terms"), chevron="ext", action=docs("bots"))]) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.feedback"), chevron=True, action=lambda: go("/settings/feedback"))]) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.sign_out"), danger=True, action=lambda: sign_out_dialog(g))]) + items.append(GrrNote("grr " + str((g.spec or {}).get("version", "")), center=True)) + self.screen.items(items) + + +def sign_out_dialog(g): + t = g.t + g.confirm(t("settings.sign_out"), t("settings.sign_out_text"), t("settings.sign_out"), g.sign_out, True) + + +class GrrSubView(GrrView): + """A settings sub screen: header, page, optional foot button.""" + + title_key = "" + fallback = "/settings" + + def open(self): + self.standard(self.g.t(self.title_key), back="/settings", foot=self.foot()) + self.start() + + def foot(self): + return None + + def start(self): + self.paint(None) + + def paint(self, res): + pass + + +class GrrAccountView(GrrSubView): + title_key = "account.title" + + def paint(self, res): + g, t = self.g, self.g.t + me = g.me or {} + accounts = g.session.accounts + items = grr_group([GrrRowItem(me.get("username", ""), sub=me.get("email", ""), lead=(lambda ui_, bg: [TuiSpan(" %s " % (me.get("username", "?")[:1].upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]))]) + rows = [GrrRowItem(a.get("email") or a.get("username"), check=a["token"] == g.client.token, action=(lambda a=a: g.switch_account(a["token"]) if a["token"] != g.client.token else None)) for a in accounts] + rows.append(GrrRowItem(t("account.add"), icon="plus", action=lambda: g.show_login())) + items += [GrrLabel(t("account.switch"))] + grr_group(rows) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.sign_out"), danger=True, action=lambda: sign_out_dialog(g))]) + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("account.delete"), icon="trash", danger=True, action=self.remove)]) + [GrrNote(t("account.delete_note"))] + self.screen.items(items) + + def remove(self): + g, t = self.g, self.g.t + me = g.me or {} + + def done(value, texts): + if value == "delete": + g.call("DELETE", "/api/me", {"confirm": texts[0]}, then=lambda _r: g.after_sign_out()) + g.dialog(title=t("account.delete"), text=t("account.delete_confirm", name=me.get("username", "")), fields=[{"value": "", "placeholder": me.get("username", "")}], + actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.delete"), "value": "delete", "primary": True, "danger": True}], done=done) + + +class GrrUsageView(GrrSubView): + title_key = "settings.usage" + + def start(self): + self.get("/api/usage", self.paint) + + def paint(self, u): + g, t, ui = self.g, self.g.t, self.ui + items = [GrrProgressItem(t("usage.weekly"), u.get("pct", 0), t("usage.resets", n=u.get("resets_in_days", 0)), + "%s%% · %s / %s %s" % (u.get("pct", 0), GrrFormat.num(u.get("tokens", 0)), GrrFormat.num(u.get("limit", 0)), t("usage.tokens"))), + GrrRowItem(t("usage.change_limit"), chevron=True, action=lambda: self.change(u)), GrrLabel(t("usage.days"))] + days = u.get("days", []) + items.append(GrrBarsItem(days) if days else GrrNote(t("usage.none"))) + dp = u.get("devplace") + items.append(GrrLabel(t("usage.gateway"))) + if dp: + items.append(GrrKvItem([(t("usage.gw_spent"), "$%.4f" % float(dp.get("spent_usd", 0))), (t("usage.gw_limit"), t("usage.unlimited") if dp.get("unlimited") else "$%s" % dp.get("limit_usd"))])) + else: + items.append(GrrKvItem([(t("usage.gw"), t("usage.gw_down"))])) + models = u.get("models") or {} + if models: + items.append(GrrLabel(t("usage.models"))) + items.append(GrrKvItem([(m, "%s/%s · %s ms%s" % (v.get("wins"), v.get("runs"), v.get("latency_ms"), " · " + t("usage.cooling") if v.get("cooled_until", 0) * 1000 > time.time() * 1000 else "")) for m, v in models.items()])) + counters = u.get("counters") or {} + if counters: + items += [GrrLabel(t("usage.counters")), GrrKvItem([(k, str(v)) for k, v in counters.items()]), GrrNote(t("usage.counters_note"))] + self.screen.items(items) + + def change(self, u): + g, t = self.g, self.g.t + + def save(value): + try: + limit = int(float(value)) + except ValueError: + g.toast(t("apierr.bad_limit"), "bad") + return + g.call("PUT", "/api/usage/limit", {"limit": limit}, then=lambda _r: self.start()) + g.edit(t("usage.change_limit"), str(u.get("limit", "")), save, text=t("usage.limit_text")) + + +class GrrRulesView(GrrSubView): + title_key = "settings.rules" + + def start(self): + self.sub(self.g.events, "connector.update", lambda _d: self.start()) + self.get("/api/rules", self.paint) + + def paint(self, res): + g, t = self.g, self.g.t + rules = res.get("rules", []) + items = grr_group([GrrRowItem(r["text"], sub=t("rule." + r["action"]), wrap=True, chevron=True, action=(lambda r=r: g.router.go("/settings/rule/" + r["id"]))) for r in rules]) if rules else [] + items.append(GrrRowItem(t("rules.add"), link=True, action=lambda: g.router.go("/settings/rule/new"))) + items.append(GrrNote(t("rules.note"))) + self.screen.items(items) + + +class GrrRuleView(GrrView): + fallback = "/settings/rules" + + def open(self): + g, t = self.g, self.g.t + self.new = self.params["id"] == "new" + self.rule = {"text": "", "action": "allow", "bot_id": ""} + self.standard(t("rules.edit"), back="/settings/rules", foot=[GrrButtonItem(t("rules.save"), self.save)]) + if self.new: + self.paint() + else: + self.get("/api/rules", self.loaded) + + def loaded(self, res): + rule = next((r for r in res.get("rules", []) if r["id"] == self.params["id"]), None) + if rule is None: + self.fail_page(GrrError(404, "not_found", self.g.t("err.not_found")), "/settings/rules") + return + self.rule = dict(rule) + self.paint() + + def paint(self): + g, t = self.g, self.g.t + rule = self.rule + bots = [{"label": t("rules.all_bots"), "checked": not rule.get("bot_id"), "action": lambda: self.set("bot_id", "")}] + bots += [{"label": b["name"], "checked": rule.get("bot_id") == b["id"], "action": (lambda b=b: self.set("bot_id", b["id"]))} for b in g.store.bots] + current = g.store.bot(rule.get("bot_id")) if rule.get("bot_id") else None + items = [GrrLabel(t("rules.when")), GrrChipsItem([{"label": current["name"] if current else t("rules.all_bots"), "icon": "user", "action": lambda: g.menu(bots)}]), + GrrTextCard(rule.get("text", ""), placeholder=t("rules.placeholder"), action=lambda: g.edit(t("rules.text"), rule.get("text", ""), lambda v: self.set("text", v), True, 4, 300)), + GrrLabel(t("rules.should"))] + items += grr_group([GrrRowItem(t("rule." + a), check=rule.get("action") == a, action=(lambda a=a: self.set("action", a))) for a in (g.spec.get("rule_actions") or ["allow", "ask"])]) + if not self.new: + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("rules.delete"), danger=True, action=self.delete)]) + self.screen.items(items) + + def set(self, key, value): + self.rule[key] = value + self.paint() + + def save(self): + g, rule = self.g, self.rule + body = {"text": rule.get("text", ""), "action": rule.get("action", "allow"), "bot_id": rule.get("bot_id", "")} + if self.new: + self.call("POST", "/api/rules", body, lambda _r: g.router.back("/settings/rules")) + else: + self.call("PATCH", "/api/rules/" + rule["id"], body, lambda _r: g.router.back("/settings/rules")) + + def delete(self): + self.call("DELETE", "/api/rules/" + self.rule["id"], None, lambda _r: self.g.router.back("/settings/rules")) + + +class GrrAppearanceView(GrrSubView): + title_key = "settings.appearance" + + def paint(self, res): + g, t, ui = self.g, self.g.t, self.ui + s = g.settings + icons = {"system": "contrast", "day": "sun", "night": "moon"} + themes = [{"label": t("appearance." + x), "icon": icons[x], "on": s.get("theme") == x, "action": (lambda x=x: g.save_settings({"theme": x}, lambda _m: self.paint(None)))} for x in (g.spec.get("themes") or ["system", "day", "night"])] + variants = [] + for v in (g.spec.get("variants") or ["black", "blue"]): + accent = ((((g.spec.get("palette") or {}).get("variants") or {}).get(v) or {}).get("night") or {}).get("accent") or ui.palette.c("accent") + variants.append({"label": t("appearance.v_" + v), "on": s.get("variant") == v, "art": (lambda ui_, bg, a=accent: [[TuiSpan("●", TuiStyle(fg=a, bg=ui_.palette.c(bg)))]]), + "action": (lambda v=v: g.save_settings({"variant": v}, lambda _m: self.paint(None)))}) + box = g.spec.get("ui_scale") or {"min": 70, "max": 150, "step": 5, "default": 100} + size = int(s.get("ui_scale") or box["default"]) + + def step(delta): + nxt = max(box["min"], min(box["max"], size + delta)) + g.save_settings({"ui_scale": nxt}, lambda _m: self.paint(None)) + items = [GrrTileGridItem(themes, cols=3, tile_h=4), GrrSpacer(1), GrrTileGridItem(variants, cols=2, tile_h=4), GrrSpacer(1)] + items += grr_group([GrrRowItem(t("appearance.size"), value="%d%%" % size, sub=t("appearance.size_sub"), icon="tune", action=lambda: step(box["step"])), + GrrRowItem(t("appearance.size_reset"), action=lambda: g.save_settings({"ui_scale": box["default"]}, lambda _m: self.paint(None)), tone="ink")]) + self.screen.items(items) + + +class GrrLanguageView(GrrSubView): + title_key = "settings.language" + + def paint(self, res): + g, t = self.g, self.g.t + cur = g.settings.get("language", "system") + rows = [GrrRowItem(t("lang.system") if l == "system" else GRR_LANG_NAMES.get(l, l), check=cur == l, + action=(lambda l=l: g.save_settings({"language": l}, lambda _m: g.router.go("/settings/language", True)))) for l in (g.spec.get("languages") or ["system", "en"])] + self.screen.items(grr_group(rows)) + + +class GrrHapticsView(GrrSubView): + title_key = "settings.haptics" + + def paint(self, res): + g, t = self.g, self.g.t + self.screen.items(grr_group([GrrRowItem(t("haptics.title"), toggle=bool(g.settings.get("haptics")), on_toggle=lambda v: g.save_settings({"haptics": v}))]) + [GrrNote(t("haptics.unsupported"))]) + + +class GrrWidgetView(GrrSubView): + title_key = "settings.widget" + + def paint(self, res): + g, t = self.g, self.g.t + url = g.client.base + "/widget" + items = grr_group([GrrRowItem(t("widget.show"), toggle=bool(g.settings.get("widget")), on_toggle=lambda v: g.save_settings({"widget": v}))]) + [GrrNote(t("widget.note"))] + items += grr_group([GrrRowItem(t("widget.open"), chevron="ext", action=lambda: g.open_url(url)), GrrRowItem(t("widget.copy"), sub=url, action=lambda: g.copy_text(url))]) + self.screen.items(items) + + +class GrrComputersView(GrrSubView): + title_key = "settings.computer" + + def start(self): + bots = self.g.store.bots + self.batch([("GET", "/api/bots/%s/computer" % b["id"], None) for b in bots], self.paint) if bots else self.paint([]) + + def paint(self, res): + g, t = self.g, self.g.t + bots = g.store.bots + rows = [GrrRowItem(b["name"], lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), + sub=t("computer.disk") + ": " + t("computer.disk_" + c["disk"]["state"]) + (" · " + t("computer.busy") if c.get("busy") else ""), chevron=True, + action=(lambda b=b, c=c: self.manage(b, c))) for b, c in zip(bots, res)] + self.screen.items((grr_group(rows) if rows else [GrrEmptyItem("cpu", t("computer.none"))]) + [GrrNote(t("computer.each"))]) + + def manage(self, b, c): + g, t = self.g, self.g.t + + def act(kind, force=False): + def landed(_r): + g.toast(t("computer.done_" + kind)) + + def failed(err): + if isinstance(err, GrrError) and err.code == "busy" and not force: + g.dialog(title=t("computer.working_title"), text=t("computer.working_text"), actions=[{"label": t("ui.cancel"), "value": False}, {"label": t("computer.anyway"), "value": True, "primary": True}], + done=lambda v, _t: act(kind, True) if v else None) + else: + g.error(err) + g.call("POST", "/api/bots/%s/computer/%s" % (b["id"], kind), {"force": True} if force else {}, landed, failed) + rows = grr_group([GrrRowItem(t("computer.update"), sub=t("computer.update_sub"), link=True, wrap=True, action=lambda: act("update")), + GrrRowItem(t("computer.reset"), sub=t("computer.reset_sub"), danger=True, wrap=True, action=lambda: act("reset"))]) + g.pick(b["name"], rows) + + +class GrrConnectorsView(GrrSubView): + title_key = "settings.connect" + + def start(self): + self.get("/api/connectors", self.paint) + + def paint(self, res): + g, t = self.g, self.g.t + rows = [GrrRowItem(t("conn." + c["name"]), sub=t("conn." + c["name"] + "_sub") + " · " + ", ".join(c.get("tools", [])), wrap=True, toggle=bool(c.get("enabled")), + on_toggle=(lambda v, c=c: g.call("PUT", "/api/connectors/" + c["name"], {"enabled": v}))) for c in res.get("connectors", [])] + self.screen.items(grr_group(rows) + [GrrNote(t("conn.note"))]) + + +class GrrAdvancedView(GrrSubView): + title_key = "settings.advanced" + + def start(self): + self.get("/api/health", self.paint) + + def paint(self, h): + g, t, ui = self.g, self.g.t, self.ui + tiers = [{"label": t("tier." + x), "checked": g.settings.get("tier") == x, "action": (lambda x=x: g.save_settings({"tier": x}, lambda _m: self.start()))} for x in (g.spec.get("tiers") or ["auto", "fast", "expert"])] + items = [GrrLabel(t("advanced.tier")), GrrChipsItem([{"label": t("tier." + g.settings.get("tier", "auto")), "icon": "sparkle", "action": lambda: g.menu(tiers)}]), GrrNote(t("advanced.tier_note"))] + items += grr_group([GrrRowItem(t("advanced.replace_key"), icon="key", chevron=True, action=self.replace), + GrrRowItem(t("advanced.export"), icon="download", sub=t("advanced.export_sub"), chevron=True, action=self.export)]) + items += [GrrLabel(t("advanced.health")), GrrKvItem([(t("advanced.pressure"), t("pressure." + h.get("pressure", "calm"))), (t("advanced.turns"), "%s / %s" % (h.get("turns_active"), h.get("turns_cap"))), + (t("advanced.db"), "%.1f MB" % (h.get("db_bytes", 0) / 1048576.0)), (t("advanced.uptime"), "%d min" % round(h.get("uptime", 0) / 60)), + (t("advanced.version"), h.get("version", ""))])] + self.screen.items(items) + + def replace(self): + g, t = self.g, self.g.t + + def done(value, texts): + if value != "save": + return + + def work(): + return g.client.request("POST", "/api/auth/login", {"api_key": texts[0]}, quiet=True) + + def landed(res, err): + if err: + g.error(err) + else: + g.session.add(res["token"], res["account"]) + g.client.token = res["token"] + g.stop_stream() + g.signed_in = False + g.enter() + g.app.offload(work, landed) + g.dialog(title=t("advanced.replace_key"), text=t("advanced.replace_text"), fields=[{"value": "", "placeholder": t("login.key_placeholder"), "secret": True}], + actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.save"), "value": "save", "primary": True}], done=done) + + def export(self): + g = self.g + + def work(): + data, _cut, _type = g.client.fetch("/api/export", limit=GRR_BODY_MAX) + path = os.path.join(os.getcwd(), "grr-export.json") + with open(path, "wb") as fh: + fh.write(data) + return path + g.app.offload(work, lambda path, err: g.error(err) if err else g.toast("Saved " + path)) + + +class GrrBuilderView(GrrSubView): + title_key = "builder.title" + + def start(self): + self.get("/api/builder", self.paint) + + def paint(self, b): + g, t = self.g, self.g.t + if not b.get("admin"): + self.screen.items([GrrEmptyItem("key", t("builder.not_admin"))]) + return + c = b["config"] + builder = g.spec.get("builder") or {} + + def save(patch): + self.call("PUT", "/api/builder/config", patch, lambda _r: self.start()) + + def chip(key, ids, label): + return {"label": label(c[key]), "icon": "sparkle", "action": lambda: g.menu([{"label": label(i), "checked": c[key] == i, "action": (lambda i=i: save({key: i}))} for i in ids])} + + def number(key, label): + return GrrRowItem(label, value=str(c[key]), chevron=True, action=lambda: g.edit(label, str(c[key]), lambda v: save({key: float(v)}) if re.match(r"^[0-9.]+$", v.strip()) else g.toast(t("apierr.invalid"), "bad"))) + pct = lambda v: t("builder.unknown") if v is None else "%s%%" % v + items = [GrrLabel(t("builder.state")), + GrrKvItem([(t("builder.engine"), t("builder.engine_" + b["engine"])), (t("builder.weekly"), pct(b.get("weekly_pct"))), (t("builder.five_hour"), pct(b.get("five_hour_pct"))), + (t("builder.ladder"), "%s / %s / %s %%" % (b["ladder"]["notice"], b["ladder"]["ask"], b["ladder"]["floor"]))])] + items += grr_group([GrrRowItem(t("builder.enabled"), sub=t("builder.enabled_sub"), toggle=bool(c.get("enabled")), on_toggle=lambda v: save({"enabled": v}))]) + items += [GrrLabel(t("builder.model")), GrrChipsItem([chip("model", builder.get("models", []), lambda x: x)]), + GrrLabel(t("builder.effort")), GrrChipsItem([chip("effort", builder.get("efforts", []), lambda x: x)]), + GrrLabel(t("builder.permission")), GrrChipsItem([chip("permission", builder.get("permissions", []), lambda x: t("builder.perm_" + x))]), + GrrLabel(t("builder.tools")), GrrChipsItem([chip("tools", list((builder.get("tool_presets") or {}).keys()), lambda x: t("builder.tools_" + x))])] + items += grr_group([number("budget_usd", t("builder.budget")), number("timeout", t("builder.timeout"))]) + items += [GrrLabel(t("builder.notes")), GrrTextCard(c.get("notes", ""), placeholder=t("builder.notes_ph"), action=lambda: g.edit(t("builder.notes"), c.get("notes", ""), lambda v: save({"notes": v}), True, 6, g.ui.cap("BUILDER_NOTES_MAX_CHARS", 4000)))] + items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("builder.sync"), sub=t("builder.sync_sub"), icon="refresh", chevron=True, action=lambda: self.call("POST", "/api/builder/sync", {}, lambda r: g.toast(t("builder.synced", mode=r.get("synced")))))]) + items.append(GrrNote(t("builder.note"))) + self.screen.items(items) + + +class GrrFeedbackView(GrrSubView): + title_key = "settings.feedback" + + def foot(self): + return [GrrButtonItem(self.g.t("feedback.send"), self.send)] + + def start(self): + self.text = "" + self.bot_id = "" + self.wants = False + self.paint(None) + + def paint(self, res): + g, t = self.g, self.g.t + bots = [{"label": t("feedback.none"), "checked": not self.bot_id, "action": lambda: self.set("bot_id", "")}] + bots += [{"label": b["name"], "checked": self.bot_id == b["id"], "action": (lambda b=b: self.set("bot_id", b["id"]))} for b in g.store.bots] + bot = g.store.bot(self.bot_id) if self.bot_id else None + items = [GrrTextCard(self.text, placeholder=t("feedback.placeholder"), action=lambda: g.edit(t("settings.feedback"), self.text, lambda v: self.set("text", v), True, 7, 4000)), + GrrNote(t("feedback.note")), GrrLabel(t("feedback.which_bot")), GrrChipsItem([{"label": bot["name"] if bot else t("feedback.none"), "icon": "user", "action": lambda: g.menu(bots)}])] + items += grr_group([GrrRowItem(t("feedback.want_response"), toggle=self.wants, on_toggle=lambda v: setattr(self, "wants", v))]) + self.screen.items(items) + + def set(self, key, value): + setattr(self, key, value) + self.paint(None) + + def send(self): + g, t = self.g, self.g.t + if not self.text.strip(): + g.toast(t("feedback.empty"), "bad") + return + self.call("POST", "/api/feedback", {"text": self.text, "bot_id": self.bot_id, "wants_response": self.wants}, lambda _r: (g.toast(t("feedback.thanks")), g.router.back("/settings"))) + + +class GrrTimezoneView(GrrSubView): + title_key = "settings.timezone" + + def start(self): + try: + import zoneinfo + zones = sorted(zoneinfo.available_timezones()) + except Exception: + zones = ["UTC", "Europe/Amsterdam", "Europe/London", "America/New_York", "Asia/Tokyo"] + if "UTC" not in zones: + zones.insert(0, "UTC") + self.zones = zones + self.q = "" + self.paint(None) + + def paint(self, res): + g, t = self.g, self.g.t + cur = g.settings.get("timezone") + shown = [z for z in self.zones if self.q in z.lower()][:80] + items = [GrrChipsItem([{"label": self.q or t("ui.search"), "icon": "search", "caret": False, "action": lambda: g.edit(t("ui.search"), self.q, lambda v: self.search(v))}])] + items += grr_group([GrrRowItem(z, check=z == cur, action=(lambda z=z: g.save_settings({"timezone": z, "auto_tz": False}, lambda _m: g.router.back("/settings")))) for z in shown]) + self.screen.items(items) + + def search(self, value): + self.q = value.strip().lower() + self.paint(None) + + +# ---- templates + +class GrrTemplateView(GrrView): + fallback = "/" + + def open(self): + self.standard("", close=True, back="/") + self.get("/api/templates/" + self.params["id"], self.loaded) + + def loaded(self, tpl): + self.tpl = tpl + self.paint() + + def paint(self): + g, t, tpl = self.g, self.g.t, self.tpl + snap = tpl["snapshot"] + go = lambda part: (lambda: g.router.go("/template/%s/%s" % (tpl["id"], part))) + items = grr_hero_items(self.ui, tpl["name"], tpl.get("description", ""), snap) + items += [GrrLabel(t("template.details"))] + items += grr_group([GrrRowItem(t("profile.instructions"), chevron=True, action=go("instructions")), + GrrRowItem(t("profile.memories"), value=str(len(snap.get("memories", []))), chevron=True, action=go("memories")), + GrrRowItem(t("profile.skills"), value=str(len(snap.get("skills", []))), chevron=True, action=go("skills"))]) + items.append(GrrNote(t("template.scrub_note"))) + items.append(GrrSpacer(1)) + if tpl.get("state") == "published": + items.append(GrrButtonItem(t("template.copy_link"), lambda: g.copy_text("%s/t/%s" % (g.client.base, tpl["slug"])))) + items.append(GrrButtonItem(t("template.unpublish"), lambda: self.set_state("unpublish"), "secondary")) + else: + items.append(GrrButtonItem(t("template.publish"), lambda: self.set_state("publish"))) + self.screen.items(items) + + def set_state(self, verb): + self.call("POST", "/api/templates/%s/%s" % (self.tpl["id"], verb), {}, lambda res: (self.tpl.update(res), self.paint())) + + +def grr_hero_items(ui, name, desc, snap): + class Hero(GrrItem): + def build(self, ui_, width, selected): + out = [] + for row in GrrAvatar.rows(ui_, {"shape": snap.get("shape"), "color": snap.get("color")}, "bg", hero=True): + out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui_.st())] + row, width, ui_.st())) + out.append([TuiSpan(grr_clip(name, width - 4).center(width), ui_.st("ink", "bg", bold=True))]) + for line in grr_wrap(desc, width - 8)[:5]: + out.append([TuiSpan(line.center(width), ui_.st("muted", "bg"))]) + return out + return [GrrSpacer(1), Hero()] + + +class GrrTemplatePartView(GrrView): + fallback = "/" + + def open(self): + self.standard(self.g.t("profile." + self.params["part"]), back="/template/" + self.params["id"]) + self.get("/api/templates/" + self.params["id"], self.loaded) + + def loaded(self, tpl): + g, t = self.g, self.g.t + snap, part = tpl["snapshot"], self.params["part"] + show = lambda title, text: (lambda: g.dialog(title=title, text=text, w=72)) + if part == "instructions": + items = [GrrLabel(t("profile.instruction")), GrrTextCard(snap.get("instructions") or t("template.none"))] + elif part == "memories": + items = grr_group([GrrRowItem(m, wrap=True, chevron=True, action=show(t("profile.memory"), m)) for m in snap.get("memories", [])]) or [GrrEmptyItem("inbox", t("template.none"))] + else: + items = grr_group([GrrRowItem(s["name"], sub=s.get("description", ""), chevron=True, action=show(s["name"], s.get("content", ""))) for s in snap.get("skills", [])]) or [GrrEmptyItem("skills", t("template.none"))] + self.screen.items(items) + + +class GrrPublicTemplateView(GrrView): + fallback = "/" + + def open(self): + self.standard("grr", back=False) + self.get("/api/t/" + urllib.parse.quote(self.params["slug"]), self.loaded, lambda err: self.fail_page(err)) + + def loaded(self, tpl): + g, t = self.g, self.g.t + snap = tpl["snapshot"] + items = grr_hero_items(self.ui, tpl["name"], tpl.get("description", ""), snap) + [GrrLabel(t("template.details"))] + items += grr_group([GrrRowItem(t("profile.instructions"), sub=(snap.get("instructions") or "")[:160]), GrrRowItem(t("profile.memories"), value=str(len(snap.get("memories", [])))), + GrrRowItem(t("profile.skills"), value=str(len(snap.get("skills", [])))), GrrRowItem(t("profile.routines"), value=str(len(snap.get("routines") or [])))]) + items += [GrrSpacer(1), GrrButtonItem(t("template.add"), lambda: self.call("POST", "/api/templates/import", {"slug": tpl["slug"]}, lambda bot: g.store.refresh(lambda _e: g.router.go("/chat/" + bot["chat"]["id"]))))] + self.screen.items(items) + + +GRR_TEXT_MIMES = ("text/", "application/json", "application/xml", "application/javascript", "application/x-sh", "application/yaml", "application/toml") + + +class GrrShellExtras: + """Helpers every view reaches through the shell: files, links, the + clipboard (OSC 52).""" + + @staticmethod + def copy_text(shell, text): + data = base64.b64encode(text.encode("utf-8")).decode("ascii") + try: + os.write(shell.app._stdout_fd if shell.app._stdout_fd is not None else sys.stdout.fileno(), ("\x1b]52;c;%s\x07" % data).encode("ascii")) + except OSError: + pass + shell.toast(shell.t("chat.copied")) + + @staticmethod + def open_url(shell, url): + opener = shutil.which("xdg-open") or shutil.which("open") + if opener and re.match(r"^https?://", url, re.I): + try: + subprocess.Popen([opener, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + return + except OSError: + pass + shell.dialog(title=shell.t("ui.share"), text=url, w=76) + + @staticmethod + def open_file(shell, f): + view = GrrFileViewer(shell, f) + view.open() + + +class GrrFileViewer: + """The file viewer: text in a scrolling dialog with line numbers, other + files saved to a temporary path and opened by the desktop (or named).""" + + def __init__(self, shell, f): + self.g = shell + self.f = f + + def open(self): + g, f = self.g, self.f + mime = f.get("mime") or "" + external = bool(f.get("url")) + path = f["url"] if external else "/api/files/" + str(f["id"]) + + def work(): + if external: + return None + return g.client.fetch(path, limit=GRR_VIEWER_MAX_BYTES) + + def landed(res, err): + if err: + g.error(err) + return + self.show(res) + g.app.offload(work, landed) + + def show(self, res): + g, t, f = self.g, self.g.t, self.f + name = f.get("name") or str(f.get("id")) + data, cut, ctype = res if res else (b"", False, "") + mime = (f.get("mime") or ctype or "").split(";")[0] + is_text = mime.startswith(GRR_TEXT_MIMES) or (not mime and b"\x00" not in data[:2048]) + actions = [{"label": t("ui.close"), "value": "close", "primary": True}, {"label": t("ui.download"), "value": "save"}] + if is_text and data: + text = data.decode("utf-8", "replace") + numbered = "\n".join("%4d %s" % (i + 1, line) for i, line in enumerate(text.split("\n")[:5000])) + body = TuiLog(h=max(6, g.app.rows - 12), style=g.ui.st("ink", "code-bg"), focusable=True) + for line in numbered.split("\n"): + body.append(line, g.ui.st("ink", "code-bg")) + g.dialog(title=name, text=(t("files.cut", shown="2 MB", total="") if cut else ""), w=min(g.app.cols - 2, 110), body=body, actions=actions, + done=lambda v, _t: self.save(data) if v == "save" else None) + else: + g.dialog(title=name, text="%s · %s" % (mime or "file", GrrFormat.bytes(f.get("size", len(data)))), actions=[{"label": t("ui.close"), "value": "close"}, {"label": t("viewer.save"), "value": "save", "primary": True}], + done=lambda v, _t: self.save(data, True) if v == "save" else None) + + def save(self, data, open_after=False): + g, f = self.g, self.f + name = re.sub(r"[^A-Za-z0-9._-]", "_", f.get("name") or str(f.get("id")))[:80] or "file" + + def work(): + body = data + if not body and f.get("id"): + body = g.client.fetch("/api/files/%s?download=1" % f["id"], limit=GRR_BODY_MAX)[0] + path = os.path.join(tempfile.gettempdir(), "grr-" + name) + with open(path, "wb") as fh: + fh.write(body) + return path + + def landed(path, err): + if err: + g.error(err) + return + g.toast("Saved " + path) + opener = shutil.which("xdg-open") or shutil.which("open") + if open_after and opener: + try: + subprocess.Popen([opener, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + except OSError: + pass + g.app.offload(work, landed) + + +GrrShell.copy_text = lambda self, text: GrrShellExtras.copy_text(self, text) +GrrShell.open_url = lambda self, url: GrrShellExtras.open_url(self, url) +GrrShell.open_file = lambda self, f: GrrShellExtras.open_file(self, f) + + +# --------------------------------------------------------------------------- +# Part F: chat. Message items, the composer, the working row, live process +# cards and the remote terminal of a bot's computer. +# --------------------------------------------------------------------------- + + +def grr_bubble_styles(ui, side): + bg = "bubble-user" if side == "out" else "bubble-bot" + base = ui.st("ink", "bg") + return {"base": base, "bubble": ui.st("ink", bg), "meta": ui.st("muted", bg), "tick": ui.st("muted", bg), "name": ui.st("muted", bg, bold=True), + "b": ui.st("ink", bg, bold=True), "i": ui.st("soft", bg), "c": ui.st("soft", "code-bg"), "code": ui.st("accent", "code-bg"), + "h": ui.st("accent", bg, bold=True), "bullet": ui.st("accent", bg, bold=True), "quote": ui.st("muted", bg), "bar": ui.st("accent", bg)} + + +class GrrControlsItem(GrrItem): + """An item with a row of small controls that Left and Right walk + through. Subclasses fill `controls` as (label, callable) and draw.""" + + def __init__(self): + super().__init__() + self.controls = [] + + def nav(self, direction): + if direction in ("left", "right") and len(self.controls) > 1: + nxt = max(0, min(len(self.controls) - 1, self.stop + (1 if direction == "right" else -1))) + if nxt != self.stop: + self.stop = nxt + self.touch() + return True + return False + + def activate(self): + if self.controls: + self.controls[min(self.stop, len(self.controls) - 1)][1]() + + +class GrrMessageItem(GrrControlsItem): + """A text message: your bubble on the right, a bot's on the left with + its steps and an action bar (copy, good, bad, try again).""" + + selectable = True + + def __init__(self, view, m, last=False): + super().__init__() + self.view = view + self.m = m + self.last = last + self.open_steps = False + self.key = m["id"] + self.rebuild_controls() + + def rebuild_controls(self): + g, t = self.view.g, self.view.g.t + m = self.m + self.controls = [] + if m.get("role") == "user": + return + steps = (m.get("meta") or {}).get("steps") or [] + if steps: + self.controls.append(("steps", self.toggle_steps)) + self.controls.append(("copy", lambda: g.copy_text(m.get("content", "")))) + self.controls.append(("good", lambda: self.view.rate(m, "up"))) + self.controls.append(("bad", lambda: self.view.rate(m, "down"))) + if self.last: + self.controls.append(("again", self.view.regenerate)) + self.stops = len(self.controls) + + def set_last(self, last): + if last != self.last: + self.last = last + self.rebuild_controls() + self.touch() + + def toggle_steps(self): + self.open_steps = not self.open_steps + self.touch() + + def build(self, ui, width, selected): + v, m = self.view, self.m + meta = m.get("meta") or {} + g, t = v.g, v.g.t + mar = grr_margin(width) + inner = width - 2 * mar + page = ui.st("ink", "bg") + out = [] + user = m.get("role") == "user" + side = "out" if user else "in" + styles = grr_bubble_styles(ui, side) + sender = "" + if not user and v.chat and v.chat.get("kind") == "group": + bot = v.bot_of(m.get("sender")) + sender = bot["name"] if bot else "" + files = meta.get("files") or [] + for fid in files if user else []: + chip = " %s %s " % (ui.icon("image"), t("chat.attachment")) + out.append(grr_line([TuiSpan(" " * (width - mar - len(chip)), page), TuiSpan(chip, ui.st("soft", "bubble-user"))], width, page)) + self.hit(len(out) - 1, 0, width, (lambda fid=fid: g.open_file({"id": fid, "name": "", "mime": "image/*"}))) + text = m.get("content", "") + if user and meta.get("routine"): + text = t("chat.routine") + "\n" + text + if text: + if m.get("kind") == "text" and not user and meta.get("error"): + styles["bubble"] = ui.st("danger", "bubble-bot") + rows = tui_chat_bubble_rows(text, inner, side, styles, sender=sender, markdown=not user, boxed=True) + for row in rows: + out.append(grr_line([TuiSpan(" " * mar, page)] + row + [TuiSpan(" " * mar, page)], width, page)) + if not user: + steps = meta.get("steps") or [] + if steps: + label = "%s %s" % ("▾" if self.open_steps else "▸", t("chat.steps", n=len(steps))) + out.append(self.control_row(ui, width, [(label, "steps")], selected, len(out))) + if self.open_steps: + for s in steps: + mark = ui.icon("check") if s.get("ok") else "!" + line = " %s %s (%ss)" % (mark, grr_clip(s.get("summary", ""), inner - 12), s.get("secs", "")) + out.append(grr_line([TuiSpan(" " * mar + line, ui.st("success" if s.get("ok") else "danger", "bg"))], width, page)) + if meta.get("smile"): + out.append(grr_line([TuiSpan(" " * (mar + 1) + grr_clip(meta["smile"], inner - 2), ui.st("faint", "bg"))], width, page)) + if (self.last or selected) and m.get("kind") == "text": + glyph = {"copy": "copy", "good": "thumbup", "bad": "thumbdown", "again": "refresh"} + bar = [(ui.icon(glyph[name]), name) for name, _fn in self.controls if name in glyph] + out.append(self.control_row(ui, width, bar, selected, len(out))) + return out + + def control_row(self, ui, width, parts, selected, row): + mar = grr_margin(width) + page = ui.st("ink", "bg") + spans, x = [TuiSpan(" " * (mar + 1), page)], mar + 1 + names = [n for n, _f in self.controls] + for text, name in parts: + idx = names.index(name) if name in names else -1 + on = selected and idx == self.stop + label = " %s " % text + spans.append(TuiSpan(label, ui.st("ink" if on else "muted", "rule-strong" if on else "bg", bold=on))) + spans.append(TuiSpan(" ", page)) + fn = self.controls[idx][1] if idx >= 0 else None + if fn: + self.hit(row, x, x + len(label), fn) + x += len(label) + 1 + return grr_line(spans, width, page) + + +def grr_card_rows(ui, width, lines, bg="card"): + """Wrap card body lines (span lists) in the rounded card used by every + message card.""" + return [GrrCard.top(ui, width, bg)] + [GrrCard.line(ui, width, line, bg) for line in lines] + [GrrCard.bottom(ui, width, bg)] + + +class GrrApprovalItem(GrrControlsItem): + """A card that asks the owner to allow or deny what a bot wants to do.""" + + selectable = True + + def __init__(self, view, m): + super().__init__() + self.view = view + self.m = m + self.key = m["id"] + self.setup() + + def setup(self): + g, t, m = self.view.g, self.view.g.t, self.m + meta = m.get("meta") or {} + self.controls = [] + if meta.get("state") == "pending": + def decide(d): + g.call("POST", "/api/messages/%s/decision" % m["id"], {"decision": d}) + self.controls = [(t("chat.allow_once"), lambda: decide("allow")), (t("chat.deny"), lambda: decide("deny"))] + if meta.get("tool") == "browser": + self.controls.append((t("chat.always_allow"), lambda: decide("always"))) + self.stops = max(1, len(self.controls)) + self.selectable = bool(self.controls) + + def build(self, ui, width, selected): + v, m = self.view, self.m + t = v.g.t + meta = m.get("meta") or {} + bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else {"name": "Bot"}) + cw = GrrCard.inner(width) + lines = [[TuiSpan(" " + grr_clip(t("chat.needs_approval", name=bot["name"]), cw), ui.st("ink", "card", bold=True))]] + for line in grr_wrap(m.get("content", ""), cw - 1)[:8]: + lines.append([TuiSpan(" " + line, ui.st("soft", "card"))]) + lines.append([TuiSpan(" ", ui.st("ink", "card"))]) + if meta.get("state") == "pending": + spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) + for pos, (label, fn) in enumerate(self.controls): + on = selected and pos == self.stop + text = " %s " % label + primary = pos == 0 + spans.append(TuiSpan(text, ui.st("accent-ink" if primary and not on else "ink", ("ink" if on and primary else ("rule-strong" if on else ("accent" if primary else "rule-strong"))), bold=True))) + spans.append(TuiSpan(" ", ui.st("ink", "card"))) + self.hit(len(lines) + 1, x, x + len(text), fn) + x += len(text) + 1 + lines.append(spans) + else: + state = "auto" if meta.get("auto") else meta.get("state", "") + ok = meta.get("state") == "allowed" + lines.append([TuiSpan(" %s %s" % (ui.icon("check" if ok else "close"), t("chat.state_" + state)), ui.st("success" if ok else "danger", "card"))]) + return grr_card_rows(ui, width, lines) + + +class GrrQuestionItem(GrrControlsItem): + """A question card with lettered options; answering is a click or Enter.""" + + selectable = True + + def __init__(self, view, m): + super().__init__() + self.view = view + self.m = m + self.key = m["id"] + meta = m.get("meta") or {} + self.answer = meta.get("answer") + self.options = list(meta.get("options") or []) + t = view.g.t + if self.answer is None: + self.controls = [(o, (lambda o=o: view.answer(m, o))) for o in self.options] + [(t("chat.skip"), lambda: view.answer(m, "(skipped)"))] + self.stops = max(1, len(self.controls)) + self.selectable = self.answer is None + + def nav(self, direction): + if direction in ("up", "down") and len(self.controls) > 1: + nxt = self.stop + (1 if direction == "down" else -1) + if 0 <= nxt < len(self.controls): + self.stop = nxt + self.touch() + return True + return False + + def build(self, ui, width, selected): + t = self.view.g.t + cw = GrrCard.inner(width) + lines = [[TuiSpan(" " + grr_clip(self.m.get("content", ""), cw), ui.st("ink", "card", bold=True))]] + if self.answer is None: + for pos, option in enumerate(self.options): + on = selected and pos == self.stop + bg = "rule-strong" if on else "sunken" + row = [TuiSpan(" ", ui.st("ink", "card")), TuiSpan(" %s " % chr(65 + pos), ui.st("muted", bg)), TuiSpan(" " + grr_clip(option, cw - 6), ui.st("ink", bg))] + lines.append(grr_line(row, cw, ui.st("ink", bg))) + self.hit(len(lines), 0, width, self.controls[pos][1]) + skip = selected and self.stop == len(self.options) + lines.append([TuiSpan(" %s %s" % (ui.icon("close"), t("chat.skip")), ui.st("ink" if skip else "muted", "rule-strong" if skip else "card"))]) + self.hit(len(lines), 0, width, self.controls[-1][1]) + lines.append([TuiSpan(" " + t("chat.answer_below"), ui.st("faint", "card"))]) + else: + lines.append([TuiSpan(" ", ui.st("ink", "card")), TuiSpan(" %s %s " % (grr_clip(self.answer, cw - 6), ui.icon("check")), ui.st("ink", "sunken"))]) + return grr_card_rows(ui, width, lines) + + +class GrrFileMsgItem(GrrItem): + """A file a bot shares: name, size, caption. Enter opens the viewer.""" + + selectable = True + + def __init__(self, view, m): + super().__init__() + self.view = view + self.m = m + self.key = m["id"] + self.action = self.open + + def open(self): + meta = self.m.get("meta") or {} + self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": self.m.get("content")}) + + def build(self, ui, width, selected): + meta = self.m.get("meta") or {} + bg = "rule-strong" if selected else "card" + cw = GrrCard.inner(width) + mime = meta.get("mime") or "" + icon = "image" if mime.startswith("image/") else ("mic" if mime.startswith("audio/") else "file") + lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(meta.get("name", ""), cw - 4)), ui.st("ink", bg, bold=True))], + [TuiSpan(" %s · %s" % (mime, GrrFormat.bytes(meta.get("size", 0))), ui.st("muted", bg))]] + if self.m.get("content"): + lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 4), ui.st("soft", bg))]) + return grr_card_rows(ui, width, [grr_line(l, cw, ui.st("ink", bg)) for l in lines], bg) + + +class GrrFilesMsgItem(GrrControlsItem): + """Several files in one message: a list the arrows walk through.""" + + selectable = True + + def __init__(self, view, m): + super().__init__() + self.view = view + self.m = m + self.key = m["id"] + files = (m.get("meta") or {}).get("files") or [] + self.controls = [(f.get("name", ""), (lambda f=f: view.g.open_file({"id": f.get("file_id"), "name": f.get("name"), "mime": f.get("mime"), "kind": f.get("file_kind")}))) for f in files] + self.stops = max(1, len(files)) + self.files = files + + def nav(self, direction): + if direction in ("up", "down") and len(self.controls) > 1: + nxt = self.stop + (1 if direction == "down" else -1) + if 0 <= nxt < len(self.controls): + self.stop = nxt + self.touch() + return True + return False + + def build(self, ui, width, selected): + cw = GrrCard.inner(width) + lines = [] + for pos, f in enumerate(self.files): + on = selected and pos == self.stop + bg = "rule-strong" if on else "card" + row = [TuiSpan(" %s %s" % (ui.icon("image" if (f.get("mime") or "").startswith("image/") else "file"), grr_clip(f.get("name", ""), cw - 14)), ui.st("ink", bg, bold=on))] + row = grr_line(row, cw - 10, ui.st("ink", bg)) + [TuiSpan(GrrFormat.bytes(f.get("size", 0)).rjust(10), ui.st("muted", bg))] + lines.append(row) + self.hit(pos + 1, 0, width, self.controls[pos][1]) + if self.m.get("content"): + lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 2), ui.st("soft", "card"))]) + return grr_card_rows(ui, width, lines) + + +class GrrTemplateMsgItem(GrrControlsItem): + selectable = True + + def __init__(self, view, m): + super().__init__() + self.view = view + self.m = m + self.key = m["id"] + g, t = view.g, view.g.t + meta = m.get("meta") or {} + if meta.get("state") == "published": + first = (t("template.copy_link"), lambda: g.copy_text("%s/t/%s" % (g.client.base, meta.get("slug")))) + else: + first = (t("template.publish"), lambda: g.call("POST", "/api/templates/%s/publish" % meta.get("template_id"), {})) + self.controls = [first, (t("template.details"), lambda: g.router.go("/template/" + str(meta.get("template_id"))))] + self.stops = 2 + + def build(self, ui, width, selected): + v, m = self.view, self.m + t = v.g.t + meta = m.get("meta") or {} + bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else None) + live = meta.get("state") == "published" + cw = GrrCard.inner(width) + lines = [] + for row in GrrAvatar.rows(ui, bot, "card"): + lines.append([TuiSpan(" " * ((cw - tui_span_width(row)) // 2), ui.st("ink", "card"))] + row) + badge = t("template.published") if live else t("template.unpublished") + lines.append([TuiSpan(" " + grr_clip(m.get("content", ""), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))]) + for line in grr_wrap(meta.get("description", ""), cw - 2)[:3]: + lines.append([TuiSpan(" " + line, ui.st("muted", "card"))]) + spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) + for pos, (label, fn) in enumerate(self.controls): + on = selected and pos == self.stop + text = " %s " % label + spans.append(TuiSpan(text, ui.st("accent-ink" if pos == 0 else "ink", "ink" if on and pos == 0 else ("rule-strong" if on or pos else "accent"), bold=True))) + spans.append(TuiSpan(" ", ui.st("ink", "card"))) + self.hit(len(lines) + 1, x, x + len(text), fn) + x += len(text) + 1 + lines.append(spans) + return grr_card_rows(ui, width, lines) + + +class GrrRemoteTerminal(TuiTerminal): + """A terminal panel for a pty that lives on the server: bytes arrive over + server-sent events and keys go back over POST. The widget, its VT + screen and its key mapping are the stock TuiTerminal ones.""" + + def __init__(self, shell, stream_path, input_path, **kwargs): + super().__init__(cmd=["true"], interactive=True, **kwargs) + self.shell = shell + self.stream_path = stream_path + self.input_path = input_path + self.stream = None + self.sent = bytearray() + self.flush_timer = None + self.size_timer = None + self.running = True + self.connecting = True + self.on_exit = None + self.on_leave = None + + def spawn(self): + return False + + def attach(self, start_body=None): + self.stream = GrrStream(self.shell.client, self.stream_path, self.on_event, self.shell.app.run_on_loop, reconnect=False) + self.stream.start() + + def detach(self): + if self.stream is not None: + self.stream.stop() + self.stream = None + + def on_event(self, name, data): + if name == "out": + if data.get("skipped"): + self.screen.feed(("\r\n\x1b[2m[%s]\x1b[0m\r\n" % self.shell.t("proc.skipped", n=data["skipped"])).encode("utf-8")) + if data.get("d"): + try: + self.screen.feed(base64.b64decode(data["d"])) + except (ValueError, TypeError): + pass + self.connecting = False + elif name == "exit": + self.running = False + if callable(self.on_exit): + self.on_exit() + self.touch() + + def _apply_winsize(self): + if self.size_timer is not None: + self.shell.app.cancel_timer(self.size_timer) + self.size_timer = self.shell.app.after(0.15, self.send_size) + + def send_size(self): + self.size_timer = None + body = {"cols": self.screen.cols, "rows": self.screen.rows} + self.shell.call("POST", self.input_path, body, fail=lambda _e: None) + + def fds(self): + return [] + + def poll_exit(self): + return None + + def terminate(self): + self.detach() + + def drain_pending(self): + return None + + def on_key(self, event): + if event.key in ("ctrl+]", "ctrl+}") and callable(self.on_leave): + event.stop() + self.on_leave() + return + if not self.running: + return + data = TuiTerminal.key_bytes(event) + if data: + event.stop() + self.queue(data) + + def on_paste(self, event): + event.stop() + self.queue((event.text or "").replace("\n", "\r").encode("utf-8", "replace")) + + def send_text(self, text): + self.queue(text.encode("utf-8", "replace")) + + def queue(self, data): + self.sent += data + if self.flush_timer is None: + self.flush_timer = self.shell.app.after(0.025, self.flush) + + def flush(self): + self.flush_timer = None + chunk = bytes(self.sent[:4096]) + del self.sent[:4096] + if chunk: + self.shell.call("POST", self.input_path, {"data": chunk.decode("utf-8", "replace")}, fail=lambda _e: None) + if self.sent: + self.flush_timer = self.shell.app.after(0.025, self.flush) + + +class GrrProcessItem(GrrControlsItem): + """The live card of a command a bot runs: the command, its state, the + last rows of its screen, and ^C, Stop, Type and Expand. A running card + follows the server's byte stream into its own VT screen; a finished one + shows the stored transcript.""" + + selectable = True + ROWS = 8 + ROWS_TALL = 24 + + def __init__(self, view, m): + super().__init__() + self.view = view + self.m = m + self.key = m["id"] + self.tall = False + self.cols = 80 + self.screen = TuiVtScreen(self.ROWS_TALL, 80) + self.stream = None + self.fed = False + self.rebuild_controls() + self.sync() + + @property + def running(self): + return (self.m.get("meta") or {}).get("state") == "running" + + def pid(self): + return (self.m.get("meta") or {}).get("pid") + + def rebuild_controls(self): + g, t = self.view.g, self.view.g.t + pid = self.pid() + self.controls = [] + if self.running: + self.controls.append(("^C", lambda: g.call("POST", "/api/processes/%s/input" % pid, {"data": "\x03"}, fail=lambda _e: None))) + self.controls.append((t("proc.stop"), lambda: g.call("POST", "/api/processes/%s/input" % pid, {"kill": True}))) + self.controls.append(("Type", self.type_in)) + self.controls.append((t("proc.expand"), self.toggle_tall)) + self.stops = len(self.controls) + + def update(self, m): + was = self.running + self.m = m + self.rebuild_controls() + if was and not self.running: + self.finish() + self.touch() + + def sync(self): + if self.running: + self.follow() + else: + self.replay() + + def follow(self): + if self.stream is not None: + return + v = self.view + self.stream = GrrStream(v.g.client, "/api/processes/%s/stream?from=0" % self.pid(), self.on_event, v.g.app.run_on_loop, reconnect=False) + self.stream.start() + + def on_event(self, name, data): + if name == "out": + if data.get("skipped"): + self.screen.feed(("\r\n[%s]\r\n" % self.view.g.t("proc.skipped", n=data["skipped"])).encode("utf-8")) + if data.get("d"): + try: + self.screen.feed(base64.b64decode(data["d"])) + except (ValueError, TypeError): + pass + self.fed = True + self.touch() + elif name == "exit": + self.stop_stream() + + def stop_stream(self): + if self.stream is not None: + self.stream.stop() + self.stream = None + + def finish(self): + self.stop_stream() + if not self.fed: + self.replay() + + def replay(self): + self.screen = TuiVtScreen(self.ROWS_TALL, self.cols) + self.screen.feed(((self.m.get("meta") or {}).get("transcript") or "").encode("utf-8", "replace")) + + def destroy(self): + self.stop_stream() + + def toggle_tall(self): + self.tall = not self.tall + self.touch() + + def type_in(self): + v = self.view + g = v.g + pid = self.pid() + term = GrrRemoteTerminal(g, "/api/processes/%s/stream?from=0" % pid, "/api/processes/%s/input" % pid, w=min(g.app.cols - 6, 100), h=min(g.app.rows - 8, 28), + border=True, style=g.ui.st("ink", "sunken"), border_style=g.ui.st("accent", "bg")) + dlg = g.dialog(title=self.m.get("content", ""), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}], + done=lambda _v, _t: term.detach()) + term.on_exit = lambda: g.close_dialog(dlg, "done") + term.attach() + g.app.set_focus(term) + + def build(self, ui, width, selected): + t = self.view.g.t + meta = self.m.get("meta") or {} + state = meta.get("state", "running") + cw = GrrCard.inner(width) + exit_code = meta.get("exit") + status = t("proc.state_" + state, code="?" if exit_code is None else exit_code) + tone = "accent" if state == "running" else ("danger" if state == "exited" and exit_code else "muted") + head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(self.m.get("content", ""), cw - len(status) - 6), ui.st("ink", "card", bold=True))] + head = grr_line(head, cw - len(status) - 1, ui.st("ink", "card")) + [TuiSpan(status + " ", ui.st(tone, "card"))] + lines = [head] + rows = self.ROWS_TALL if self.tall else self.ROWS + screen = self.screen + base = ui.st("ink", "sunken") + used = [r for r in range(screen.rows) if any(c is not None and c[0] != " " for c in screen.grid[r])] + last = used[-1] if used else -1 + first = max(0, last - rows + 1) + for r in range(first, first + rows): + if r >= screen.rows: + lines.append([TuiSpan(" " * cw, base)]) + continue + cells = [] + for col in range(min(cw - 1, screen.cols)): + cell = screen.grid[r][col] + cells.append((cell[0] if cell else " ", TuiVtScreen.style_for(cell, base))) + lines.append([TuiSpan(" ", ui.st("ink", "card"))] + grr_line(tui_runs(cells), cw - 1, base)) + spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) + for pos, (label, fn) in enumerate(self.controls): + on = selected and pos == self.stop + text = " %s " % label + spans.append(TuiSpan(text, ui.st("danger" if label == t("proc.stop") else "ink", "rule-strong" if on else "sunken", bold=on))) + spans.append(TuiSpan(" ", ui.st("ink", "card"))) + self.hit(len(lines) + 1, x, x + len(text), fn) + x += len(text) + 1 + lines.append(spans) + return grr_card_rows(ui, width, lines) + + +class GrrComposerBox(TuiTextBox): + """The chat input: PageUp and PageDown scroll the conversation when the + message is a single line.""" + + def __init__(self, view, **kwargs): + super().__init__(**kwargs) + self.view = view + + def on_key(self, event): + key = event.key or "" + if key in ("pageup", "pagedown") and len(self.lines) <= 1: + event.stop() + self.view.log.scroll_by(-max(1, self.view.log._content_h() - 1) if key == "pageup" else max(1, self.view.log._content_h() - 1)) + return + if key in ("ctrl+up", "ctrl+down"): + event.stop() + self.view.log.scroll_by(-3 if key == "ctrl+up" else 3) + return + super().on_key(event) + + +class GrrChatView(GrrView): + """Bot chats, group chats and general chats (ask, imagine, build, + private) with the live message stream, composer and side pane.""" + + fallback = "/" + + def open(self): + g, ui, t = self.g, self.ui, self.g.t + self.chat = None + self.messages = [] + self.items = {} + self.attachments = [] + self.mode = self.query.get("mode") or "ask" + self.tier = g.settings.get("tier") or "auto" + self.private = False + self.has_more = False + self.busy = False + self.new_below = False + self.spin = 0 + self.pane = None + self.root = TuiCols(w="1fr", h="100%", style=ui.st("ink", "bg")) + self.col = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) + self.bar = GrrBar(ui, style=ui.st("ink", "bg")) + self.modebar = GrrBar(ui, style=ui.st("ink", "bg")) + self.log = GrrPage(ui, stick=True, auto_select=False, style=ui.st("ink", "bg"), h="1fr") + self.log.on_bottom = self.on_bottom + self.working = TuiRich([], h=1, style=ui.st("ink", "bg")) + self.attach_row = TuiRich([], h=1, style=ui.st("ink", "bg")) + self.attach_row.visible = False + self.box = GrrComposerBox(self, h=1, border=True, rounded=True, padding=(0, 1), w="1fr", style=ui.st("ink", "card"), + border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, + on_send=lambda _v: self.on_send(), max_chars=ui.cap("MESSAGE_MAX_CHARS", 20000), + mentions=lambda prefix: [(("@%s" % b["name"].replace(" ", "")), b["name"].replace(" ", "")) for b in g.store.bots if b["name"].replace(" ", "").lower().startswith((prefix or "").lower())]) + self.plus = TuiButton(ui.icon("plus"), brackets=False, border=False, w=3, h=1, style=ui.st("ink", "card", bold=True), on_press=lambda _e: self.attach_menu()) + self.send_btn = TuiButton("", brackets=False, border=False, w=5, h=1, style=ui.st("accent-ink", "accent", bold=True), on_press=lambda _e: self.send_action()) + self.composer = TuiCols(h=3, gap=1, padding=(1, 1, 0, 1), style=ui.st("ink", "bg")) + self.composer.add(self.plus, self.box, self.send_btn) + self.col.add(self.bar, self.modebar, self.log, self.working, self.attach_row, self.composer) + self.root.add(self.col) + self.box.on_change = lambda e: self.fit_box() + self.modebar.visible = False + chat_id = self.params["id"] + self.sub(g.events, "message.new", self.on_new) + self.sub(g.events, "message.update", self.on_update) + self.sub(g.events, "status", self.on_status) + self.sub(g.events, "chat.update", self.on_chat_update) + self.sub(g.events, "link", lambda up: self.catch_up() if up and self.chat else None) + self.sub(g.store, "changed", lambda _d: self.refresh_head()) + self.every(0.15, self.tick) + self.screen = None + if chat_id == "new": + self.refresh_head() + self.paint_all() + self.show(self.root, self.box) + self.fit_box() + self.set_send_mode() + else: + self.show(self.root, self.box) + self.batch([("GET", "/api/chats/" + chat_id, None), ("GET", "/api/chats/%s/messages" % chat_id, None)], self.loaded) + + # ---- data + def loaded(self, res): + chat, page = res + g = self.g + self.chat = chat + self.messages = page.get("messages", []) + self.has_more = page.get("has_more", False) + self.mode = chat.get("mode") or "ask" + self.tier = chat.get("tier") or "auto" + self.pane = GrrPane(self) if self.chat_is_bot() else None + self.refresh_head() + self.paint_all() + self.fit_box() + self.set_send_mode() + if chat.get("unread"): + g.app.after(1.2, lambda: None if self.closed else g.call("POST", "/api/chats/%s/read" % chat["id"], {}, fail=lambda _e: None)) + draft = g.drafts.get(chat["id"]) + if draft: + self.box.lines = draft.split("\n") + self.box.cy = len(self.box.lines) - 1 + self.box.cx = len(self.box.lines[-1]) + self.log.scroll_to_end() + self.restore_pane() + + def destroy(self): + g = self.g + if self.chat is not None: + text = self.box.value.strip() + if text: + if len(g.drafts) >= GRR_DRAFT_KEEP: + g.drafts.pop(next(iter(g.drafts))) + g.drafts[self.chat["id"]] = self.box.value + else: + g.drafts.pop(self.chat["id"], None) + if self.chat.get("kind") == "private": + g.client_call_quiet("DELETE", "/api/chats/" + self.chat["id"]) + else: + g.client_call_quiet("POST", "/api/chats/%s/read" % self.chat["id"], {}) + for item in self.items.values(): + if hasattr(item, "destroy"): + item.destroy() + if self.pane is not None: + self.pane.destroy() + super().destroy() + + def chat_is_bot(self): + return bool(self.chat) and self.chat.get("kind") in ("bot", "group") and bool(self.bots()) + + def is_general(self): + return not self.chat or self.chat.get("kind") in ("general", "private") + + def is_private(self): + return self.private or (self.chat and self.chat.get("kind") == "private") + + def bots(self): + if not self.chat: + return [] + return [b for b in (self.g.store.bot(i) for i in self.chat.get("bot_ids", [])) if b] + + def bot_of(self, bot_id): + return self.g.store.bot(bot_id) if bot_id else None + + def placeholder(self): + t = self.g.t + if not self.is_general(): + b = self.bots() + return t("chat.ask_bot", name=(b[0]["name"] if b else "") if self.chat.get("kind") == "bot" else self.chat.get("title", "")) + if self.is_private(): + return t("chat.private_placeholder") + return t("chat.mode_%s_ph" % self.mode) + + # ---- header and mode bar + def refresh_head(self): + g, ui, t = self.g, self.ui, self.g.t + c = self.chat + parts = [{"text": " %s " % ui.icon("back"), "fn": lambda: g.router.back("/"), "bg": "card", "bold": True}] + if c and c.get("kind") == "bot": + b = (self.bots() or [None])[0] + chip = (GrrAvatar.chip(ui, b) if b else []) + [TuiSpan(" " + (b["name"] if b else c.get("title", "")) + " ", ui.st("ink", "card", bold=True))] + parts.append({"text": "", "fill": True}) + parts.append({"spans": [TuiSpan(" ", ui.st("ink", "card"))] + chip, "fn": (lambda: g.router.go("/bot/" + b["id"])) if b else None, "fill": False}) + parts.append({"text": "", "fill": True}) + elif c and c.get("kind") == "group": + chip = [TuiSpan(" ", ui.st("ink", "card"))] + for b in self.bots()[:3]: + chip += [TuiSpan("●", TuiStyle(fg=b.get("color") or ui.palette.c("accent"), bg=ui.palette.c("card")))] + chip.append(TuiSpan(" %s " % c.get("title", ""), ui.st("ink", "card", bold=True))) + parts.append({"text": "", "fill": True}) + parts.append({"spans": chip, "fn": self.members}) + parts.append({"text": "", "fill": True}) + else: + parts.append({"text": (c.get("title") if c and c.get("title") else (t("chat.private") if self.is_private() else "")), "fill": True, "align": "center", "bold": True}) + if self.is_general() and not c: + parts.append({"text": " %s " % ui.icon("incognito"), "fn": self.toggle_private, "bg": "accent" if self.private else "card", "fg": "accent-ink" if self.private else "ink"}) + if self.pane is not None: + parts.append({"text": " %s " % ui.icon("panel"), "fn": self.pane.toggle, "bg": "accent" if self.pane.is_open else "card", "fg": "accent-ink" if self.pane.is_open else "ink"}) + if c: + parts.append({"text": " %s " % ui.icon("more"), "fn": self.menu, "bg": "card"}) + self.bar.set(parts) + self.refresh_modebar() + self.box.placeholder = self.placeholder() + + def refresh_modebar(self): + g, ui, t = self.g, self.ui, self.g.t + general = self.is_general() + self.modebar.visible = general + if not general: + return + modes = g.spec.get("modes") or ["ask", "imagine", "build"] + tiers = g.spec.get("tiers") or ["auto", "fast", "expert"] + width = max(10, self.col.aw or g.app.cols) + need = sum(len(t("chat.mode_" + m)) + 3 for m in modes) + len(t("tier." + self.tier)) + 8 + parts = [] + if need > width: + parts.append({"text": " %s %s " % (t("chat.mode_" + self.mode), ui.icon("down")), "fn": lambda: g.menu([{"label": t("chat.mode_" + m), "checked": m == self.mode, "action": (lambda m=m: self.set_mode(m))} for m in modes], align_left=True), "bg": "card", "bold": True}) + else: + for m in modes: + on = m == self.mode + parts.append({"text": " %s " % t("chat.mode_" + m), "fn": (lambda m=m: self.set_mode(m)), "bg": "accent" if on else "bg", "fg": "accent-ink" if on else "muted", "bold": on}) + parts.append({"text": "", "fill": True}) + parts.append({"text": " %s %s %s " % (ui.icon("sparkle"), t("tier." + self.tier), ui.icon("down")), "fn": lambda: g.menu([{"label": t("tier." + x), "checked": x == self.tier, "action": (lambda x=x: self.set_tier(x))} for x in tiers]), "bg": "card"}) + self.modebar.set(parts) + + def set_mode(self, mode): + self.mode = mode + self.refresh_head() + self.paint_all() + if self.chat: + self.call("PATCH", "/api/chats/" + self.chat["id"], {"mode": mode}) + + def set_tier(self, tier): + self.tier = tier + self.refresh_modebar() + if self.chat: + self.call("PATCH", "/api/chats/" + self.chat["id"], {"tier": tier}) + + def toggle_private(self): + self.private = not self.private + self.refresh_head() + self.paint_all() + + def members(self): + g, t = self.g, self.g.t + rows = [GrrRowItem(b["name"], lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), chevron=True, action=(lambda b=b: g.router.go("/bot/" + b["id"]))) for b in self.bots()] + g.pick(self.chat.get("title", ""), rows, text=t("chat.members_hint")) + + def menu(self): + g, t, c = self.g, self.g.t, self.chat + items = [] + if c.get("kind") == "bot": + b = (self.bots() or [None])[0] + items += [{"label": t("chat.view_profile"), "icon": "user", "action": lambda: b and g.router.go("/bot/" + b["id"])}, + {"label": t("profile.share_template"), "icon": "share", "action": self.share_template}, "-", + {"label": t("chat.clear"), "icon": "refresh", "action": self.clear_chat}, + {"label": t("profile.delete_bot"), "icon": "trash", "danger": True, "action": self.delete_bot}] + else: + if c.get("kind") == "group": + items.append({"label": t("chat.members"), "icon": "group", "action": self.members}) + items += [{"label": t("chat.unpin") if c.get("pinned") else t("chat.pin"), "icon": "pin", "action": lambda: self.call("PATCH", "/api/chats/" + c["id"], {"pinned": not c.get("pinned")})}, + {"label": t("chat.rename"), "icon": "edit", "action": self.rename}, "-", + {"label": t("ui.delete"), "icon": "trash", "danger": True, "action": self.delete_chat}] + g.menu(items) + + def rename(self): + g, t = self.g, self.g.t + g.edit(t("chat.rename"), self.chat.get("title", ""), lambda v: self.call("PATCH", "/api/chats/" + self.chat["id"], {"title": v}, lambda c: (self.chat.update(c), self.refresh_head()))) + + def share_template(self): + g, t = self.g, self.g.t + g.confirm(t("profile.share_title"), t("profile.share_text"), t("ui.continue"), lambda: self.send_text(GRR_TEMPLATE_ASK)) + + def clear_chat(self): + g, t = self.g, self.g.t + + def go(): + self.call("DELETE", "/api/chats/" + self.chat["id"], None, lambda _r: (setattr(self, "messages", []), self.paint_all())) + g.confirm(t("chat.clear"), t("chat.clear_text"), t("chat.clear"), go, True) + + def delete_bot(self): + g, t = self.g, self.g.t + b = (self.bots() or [None])[0] + if b: + g.confirm(t("profile.delete_bot"), t("profile.delete_text", name=b["name"]), t("ui.delete"), + lambda: self.call("DELETE", "/api/bots/" + b["id"], None, lambda _r: g.store.refresh(lambda _e: g.router.go("/bots", True))), True) + + def delete_chat(self): + g, t = self.g, self.g.t + + def go(): + cid = self.chat["id"] + self.chat["kind"] = "x" + self.call("DELETE", "/api/chats/" + cid, None, lambda _r: g.router.go("/", True)) + g.confirm(t("chat.delete_title"), t("chat.delete_text"), t("ui.delete"), go, True) + + # ---- messages + def make_item(self, m): + kind = m.get("kind") + if m.get("role") == "user": + return GrrMessageItem(self, m) + if kind == "approval": + return GrrApprovalItem(self, m) + if kind == "question": + return GrrQuestionItem(self, m) + if kind == "process": + return GrrProcessItem(self, m) + if kind == "file": + return GrrFileMsgItem(self, m) + if kind == "files": + return GrrFilesMsgItem(self, m) + if kind == "template": + return GrrTemplateMsgItem(self, m) + return GrrMessageItem(self, m) + + def newest_boundary(self): + n = (self.chat or {}).get("unread") or 0 + if not n: + return -1 + count = 0 + for i in range(len(self.messages) - 1, -1, -1): + if self.messages[i].get("role") != "user": + count += 1 + if count == n: + return i + return -1 + + def paint_all(self): + g, ui, t = self.g, self.ui, self.g.t + for item in self.items.values(): + if hasattr(item, "destroy"): + item.destroy() + self.items = {} + entries = [] + if not self.messages: + entries.append(self.empty_item()) + else: + if self.has_more: + entries.append(GrrButtonItem(t("chat.older"), self.older, "ghost", inline=True)) + prev, new_at = None, self.newest_boundary() + for i, m in enumerate(self.messages): + if i == new_at: + entries.append(GrrDividerItem(t("chat.new"), accent=True)) + if prev is None or m.get("ts", 0) - prev.get("ts", 0) > 1800: + entries.append(GrrDividerItem(ui.fmt.stamp(m.get("ts", 0)))) + item = self.make_item(m) + self.items[m["id"]] = item + entries.append(item) + prev = m + self.mark_last() + self.entries = entries + self.log.set_items(entries) + self.log.scroll_to_end() + + def empty_item(self): + g, ui, t = self.g, self.ui, self.g.t + if self.is_general(): + priv = self.is_private() + icon = "incognito" if priv else {"imagine": "image", "build": "terminal"}.get(self.mode, "sparkle") + title = t("chat.private") if priv else t("chat.empty_" + self.mode) + sub = t("chat.private_text") if priv else t("chat.empty_%s_sub" % self.mode) + return GrrEmptyItem(icon, title, sub) + bot = (self.bots() or [None])[0] + + class Intro(GrrItem): + def build(inner, ui_, width, selected): + out = [[TuiSpan(" " * width, ui_.st())] for _ in range(2)] + if bot: + for row in GrrAvatar.rows(ui_, bot, "bg", hero=True): + out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui_.st())] + row, width, ui_.st())) + out.append([TuiSpan(grr_clip(self.chat.get("title", ""), width - 4).center(width), ui_.st("ink", "bg", bold=True))]) + for line in grr_wrap(bot["description"] if bot else t("chat.group_empty"), width - 8)[:4]: + out.append([TuiSpan(line.center(width), ui_.st("muted", "bg"))]) + return out + return Intro() + + def mark_last(self): + last_id = None + for m in reversed(self.messages): + if m.get("role") != "user" and m.get("kind") == "text": + last_id = m["id"] + break + if m.get("role") == "user": + break + for mid, item in self.items.items(): + if isinstance(item, GrrMessageItem): + item.set_last(mid == last_id) + + def on_new(self, m): + if not self.chat or m.get("chat_id") != self.chat["id"] or m["id"] in self.items: + return + if not self.messages: + self.messages.append(m) + self.paint_all() + return + prev = self.messages[-1] + self.messages.append(m) + if len(self.messages) > GRR_MESSAGE_KEEP: + drop = self.messages.pop(0) + gone = self.items.pop(drop["id"], None) + if gone is not None and hasattr(gone, "destroy"): + gone.destroy() + self.has_more = True + if m.get("ts", 0) - prev.get("ts", 0) > 1800: + self.log.append(GrrDividerItem(self.ui.fmt.stamp(m.get("ts", 0)))) + item = self.make_item(m) + self.items[m["id"]] = item + self.log.append(item) + self.mark_last() + if m.get("role") == "user" or self.log._follow: + self.log.scroll_to_end() + else: + self.new_below = True + if m.get("role") != "user": + self.g.client_call_quiet("POST", "/api/chats/%s/read" % self.chat["id"], {}) + + def on_update(self, m): + if not self.chat or m.get("chat_id") != self.chat["id"]: + return + for i, old in enumerate(self.messages): + if old["id"] == m["id"]: + self.messages[i] = m + break + else: + return + item = self.items.get(m["id"]) + if isinstance(item, GrrProcessItem): + item.update(m) + return + if item is None: + return + fresh = self.make_item(m) + self.items[m["id"]] = fresh + entries = [fresh if e is item else e for e in self.log.items] + self.log.set_items(entries) + self.mark_last() + + def on_status(self, s): + if self.chat and s.get("chat_id") == self.chat["id"]: + self.set_send_mode() + + def on_chat_update(self, d): + if self.chat and d and d.get("id") == self.chat["id"]: + self.get("/api/chats/" + self.chat["id"], lambda c: (self.chat.update(c), self.set_send_mode(), self.refresh_head())) + + def on_bottom(self, at_bottom): + if at_bottom: + self.new_below = False + + def catch_up(self): + def landed(page): + have = set(self.items) + for m in page.get("messages", []): + if m["id"] not in have: + self.on_new(m) + self.get("/api/chats/%s/messages" % self.chat["id"], landed) + + def older(self): + if not self.messages: + return + + def landed(page): + self.messages = page.get("messages", []) + self.messages + self.has_more = page.get("has_more", False) + self.paint_all() + self.get("/api/chats/%s/messages?before=%s" % (self.chat["id"], self.messages[0]["id"]), landed) + + # ---- working row + def active(self): + return self.g.store.active_in(self.chat["id"]) if self.chat else [] + + def is_busy(self): + return bool(self.chat and (self.chat.get("busy") or self.active())) + + FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + def tick(self): + self.spin += 1 + self.paint_working() + + def paint_working(self): + ui, t = self.ui, self.g.t + act = self.active() + if not act and not (self.chat and self.chat.get("busy")): + hint = " ⌄ %s (End)" % t("chat.to_bottom") if self.new_below else "" + self.working.set([TuiSpan(hint, ui.st("accent", "bg"))] if hint else []) + return + s = act[0] if act else None + bot = self.bot_of(s.get("bot_id")) if s else (self.bots() or [None])[0] + name = bot["name"] if bot else "grr" + frame = self.FRAMES[self.spin % len(self.FRAMES)] + label = (s or {}).get("label") or "" + text = t("chat.is_working", name=name) + (" · " + label[:60] if label else "") + if not label: + text = "%s %s%s" % (t("chat.is_working", name=name), "." * (1 + self.spin // 3 % 3), "") + self.working.set([TuiSpan(" %s " % frame, ui.st("accent", "bg", bold=True))] + (GrrAvatar.chip(ui, bot) if bot else []) + [TuiSpan(" " + text, ui.st("muted", "bg"))]) + + # ---- composer + def fit_box(self): + rows = max(1, min(GRR_COMPOSER_MAX_ROWS, len(self.box.lines))) + if self.box.h != rows: + self.box.h = rows + self.plus.h = rows + self.send_btn.h = rows + self.composer.h = rows + 3 + self.set_send_mode() + + def set_send_mode(self): + busy = self.is_busy() + has = bool(self.box.value.strip()) or bool(self.attachments) + self.send_btn.label = " %s " % (self.ui.icon("stop") if busy else (self.ui.icon("send") if has else self.ui.icon("wave"))) + self.send_mode = "stop" if busy else ("send" if has else "voice") + self.paint_attach() + + def send_action(self): + g, t = self.g, self.g.t + if self.send_mode == "stop": + if self.chat: + self.call("POST", "/api/chats/%s/stop" % self.chat["id"], {}) + elif self.send_mode == "voice": + g.toast("Voice needs a microphone: use the web app for spoken conversations.") + else: + self.on_send() + + def on_send(self): + if self.is_busy() and not self.box.value.strip(): + return + self.send_text(self.box.value.strip()) + + def paint_attach(self): + ui = self.ui + self.attach_row.visible = bool(self.attachments) + if self.attachments: + spans = [TuiSpan(" ", ui.st())] + for a in self.attachments: + spans.append(TuiSpan(" %s %s%s " % (ui.icon("file"), a["name"], "…" if a.get("uploading") else ""), ui.st("soft", "card"))) + spans.append(TuiSpan(" ", ui.st())) + self.attach_row.set(spans) + + def attach_menu(self): + g, t = self.g, self.g.t + g.menu([{"icon": "image", "label": t("chat.gallery"), "action": self.pick_file}, + {"icon": "file", "label": t("chat.files"), "action": self.pick_file}, "-", + {"icon": "skills", "label": t("chat.skills"), "action": self.skills_sheet}, + {"icon": "plug", "label": t("chat.connectors"), "action": lambda: g.router.go("/settings/connectors")}], align_left=True) + + def pick_file(self): + g, t = self.g, self.g.t + g.edit(t("chat.files"), "", self.add_file, text="Path of a file on this machine") + + def add_file(self, path): + g, t = self.g, self.g.t + path = os.path.expanduser(path.strip()) + if not path: + return + try: + if os.path.getsize(path) > g.ui.cap("UPLOAD_MAX_BYTES", 20971520): + g.toast(t("err.too_big", max=GrrFormat.bytes(g.ui.cap("UPLOAD_MAX_BYTES", 20971520))), "bad") + return + with open(path, "rb") as fh: + data = fh.read() + except OSError as exc: + g.toast(str(exc), "bad") + return + chip = {"name": os.path.basename(path), "uploading": True} + self.attachments.append(chip) + self.set_send_mode() + chat = self.chat + + def landed(item, err): + if err or self.closed: + if chip in self.attachments: + self.attachments.remove(chip) + if err: + g.error(err) + else: + chip.update({"id": item["id"], "uploading": False}) + self.set_send_mode() + g.app.offload(lambda: g.client.upload(chip["name"], data, chat["id"] if chat else "", chat["bot_ids"][0] if chat and chat.get("kind") == "bot" else ""), landed) + + def skills_sheet(self): + g, t = self.g, self.g.t + bots = g.store.bots + if not bots: + return + + def landed(res): + rows = [] + for b, r in zip(bots, res): + for s in r.get("skills", []): + rows.append(GrrRowItem(s["name"], sub=s.get("description", ""), lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), + action=(lambda b=b, s=s: self.insert_skill(b, s)))) + if rows: + g.pick(t("chat.skills"), rows) + else: + g.dialog(title=t("chat.no_skills"), text=t("chat.no_skills_sub")) + self.batch([("GET", "/api/bots/%s/skills" % b["id"], None) for b in bots], landed) + + def insert_skill(self, bot, skill): + text = (self.box.value + " @" + bot["name"].replace(" ", "") + " " + self.g.t("chat.use_skill", name=skill["name"])).strip() + self.box.lines = text.split("\n") + self.box.cy = len(self.box.lines) - 1 + self.box.cx = len(self.box.lines[-1]) + self.box.touch() + self.g.app.set_focus(self.box) + + def ensure_chat(self, then): + if self.chat: + then(self.chat) + return + if getattr(self, "creating", False): + return + self.creating = True + body = {"kind": "private" if self.private else "general", "mode": self.mode, "tier": self.tier} + if self.query.get("project"): + body["project_id"] = self.query["project"] + + def landed(chat): + self.creating = False + self.chat = chat + self.params["id"] = chat["id"] + self.g.router.rewrite("/chat/" + chat["id"]) + self.g.store.soon() + self.refresh_head() + then(chat) + self.call("POST", "/api/chats", body, landed, lambda e: (setattr(self, "creating", False), self.g.error(e))) + + def send_text(self, text): + g, t = self.g, self.g.t + if not text and not self.attachments: + return + if any(a.get("uploading") for a in self.attachments): + g.toast(t("chat.uploading")) + return + files = [a["id"] for a in self.attachments if a.get("id")] + kept = self.attachments + + def go(chat): + body = {"text": text, "files": files} + if self.is_general(): + body.update({"mode": self.mode, "tier": self.tier}) + self.box.lines = [""] + self.box.cx = self.box.cy = 0 + self.box.touch() + self.attachments = [] + + def landed(res): + self.on_new(dict({"chat_id": chat["id"]}, **res["message"])) + self.chat["busy"] = True + self.set_send_mode() + self.log.scroll_to_end() + + def failed(err): + self.attachments = kept + if isinstance(err, GrrError) and err.code in ("busy", "chat_busy"): + g.toast(err.text()) + else: + g.error(err) + if not self.box.value: + self.box.lines = text.split("\n") + self.box.cy = len(self.box.lines) - 1 + self.box.cx = len(self.box.lines[-1]) + self.set_send_mode() + self.call("POST", "/api/chats/%s/messages" % chat["id"], body, landed, failed) + self.ensure_chat(go) + + def answer(self, m, text): + self.call("POST", "/api/messages/%s/answer" % m["id"], {"text": text}) + + def rate(self, m, kind): + g, t = self.g, self.g.t + body = {"text": ("[good] " if kind == "up" else "[bad] ") + m.get("content", "")[:500], "bot_id": m.get("sender")} + self.call("POST", "/api/feedback", body, lambda _r: g.toast(t("chat.thanks"))) + + def regenerate(self): + for m in reversed(self.messages): + if m.get("role") == "user" and m.get("content"): + self.send_text(m["content"]) + return + + def hotkey(self, key): + if key == "ctrl+p" and self.pane is not None: + self.pane.toggle() + return True + return False + + # ---- pane + def restore_pane(self): + if self.pane is not None: + self.pane.restore() + + def on_escape(self): + if self.pane is not None and self.pane.is_open and self.pane.layer_mode(): + self.pane.hide() + return True + g = self.g + g.router.back("/") + return True + + def restyled(self): + for node in self.root.walk(): + if hasattr(node, "ui"): + node.ui = self.g.ui + + +class GrrComputerView(GrrView): + """Take control of a bot's computer: its shell, live over the stream. + Ctrl+] hands control back, like `computer take` does in the REPL.""" + + def open(self): + g, ui, t = self.g, self.ui, self.g.t + self.started = False + self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) + self.bar = GrrBar(ui, style=ui.st("ink", "bg")) + self.root.add(self.bar) + self.term = None + self.show(self.root) + self.get("/api/bots/" + self.params["id"], self.loaded) + + def loaded(self, bot): + g, ui, t = self.g, self.ui, self.g.t + self.bot = bot + path = "/api/bots/%s/computer" % bot["id"] + self.term = GrrRemoteTerminal(g, path + "/stream?from=0", path + "/input", w="100%", h="1fr", border=False, style=ui.st("ink", "sunken")) + self.root.add(self.term) + self.term.on_leave = self.done + self.foot = GrrBar(ui, style=ui.st("ink", "bg")) + self.root.add(self.foot) + self.foot.set([{"text": " Ctrl+] %s " % t("computer.done"), "fn": self.done, "bg": "card"}, + {"text": "", "fill": True}, + {"text": " ^C ", "fn": lambda: self.term.queue(b"\x03"), "bg": "card"}, {"text": " ^L ", "fn": lambda: self.term.queue(b"\x0c"), "bg": "card"}, {"text": " ^D ", "fn": lambda: self.term.queue(b"\x04"), "bg": "card"}]) + self.bar.set([{"text": " %s " % ui.icon("back"), "fn": self.done, "bg": "card", "bold": True}, + {"spans": [TuiSpan(" ", ui.st("ink", "card"))] + GrrAvatar.chip(ui, bot) + [TuiSpan(" %s " % bot["name"], ui.st("ink", "card", bold=True))]}, + {"text": "", "fill": True}, {"text": " %s " % ui.icon("help"), "fn": self.help, "bg": "card"}]) + body = {"start": True, "cols": 80, "rows": 24} + self.call("POST", path + "/input", body, lambda _r: self.attached(), lambda e: g.error(e)) + g.app.set_focus(self.term) + + def attached(self): + self.started = True + self.term.attach() + self.term.send_size() + + def on_key_root(self, event): + return False + + def help(self): + g, t = self.g, self.g.t + lines = [t("computer.help_scroll_t"), "", t("computer.help_keyboard_t"), "", t("computer.help_done_t"), "", "Ctrl+] " + t("computer.done")] + g.dialog(title=t("computer.help_title"), text="\n".join(lines), w=70) + + def done(self): + g = self.g + if self.started: + g.client_call_quiet("POST", "/api/bots/%s/computer/input" % self.bot["id"], {"release": True}) + if self.term is not None: + self.term.detach() + g.router.back("/bot/%s?tab=computer" % self.params["id"]) + + def destroy(self): + if self.term is not None: + self.term.detach() + if self.started: + self.g.client_call_quiet("POST", "/api/bots/%s/computer/input" % self.bot["id"], {"release": True}) + super().destroy() + + def on_escape(self): + return True + + +class GrrScreenView(GrrView): + """The graphical screen of a bot is a VNC session; a terminal cannot draw + it. This view starts and stops it and says where to see it.""" + + def open(self): + g, t = self.g, self.g.t + self.standard(t("screen.title"), back="/bot/%s?tab=computer" % self.params["id"]) + self.sub(g.events, "preview.ready", lambda _d: None) + self.reload() + + def reload(self): + self.get("/api/bots/%s/desktop" % self.params["id"], self.paint) + + def paint(self, d): + g, t = self.g, self.g.t + bid = self.params["id"] + url = "%s/#/bot/%s/screen" % (g.client.base, bid) + items = [GrrEmptyItem("monitor", t("screen.title"), "running" if d.get("running") else t("screen.starting").rstrip(".")), GrrKvItem([("Display", "%sx%s" % (d.get("width"), d.get("height"))), ("Screens", "%s / %s" % (d.get("clients", 0), d.get("max"))), ("Browser", "yes" if d.get("browser") else t("screen.no_browser"))])] + if d.get("running"): + items.append(GrrButtonItem(t("screen.stop"), lambda: g.confirm(t("screen.stop"), t("screen.stop_text"), t("screen.stop"), lambda: self.call("POST", "/api/bots/%s/desktop/stop" % bid, {}, lambda _r: self.reload()), True), "secondary", inline=True)) + else: + items.append(GrrButtonItem(t("profile.take_control"), lambda: self.call("POST", "/api/bots/%s/desktop/start" % bid, {}, lambda _r: self.reload()), inline=True)) + items.append(GrrButtonItem("Open in browser", lambda: g.open_url(url), "secondary", inline=True, icon="external")) + items.append(GrrNote("The graphical screen is a VNC view. It needs a graphical client: open it in a browser. The shell of the same computer is under Terminal.", center=True)) + self.screen.items(items) + + +# --------------------------------------------------------------------------- +# Part G: the side pane of a bot chat (Preview, Files, Deploy), the route +# table and the entry point behind `tai.py --tui`. +# --------------------------------------------------------------------------- + + +class GrrPane: + """Preview and Files beside the chat, or over it on a narrow terminal. + The tabs and what is enabled come from the server's spec.""" + + def __init__(self, view): + self.v = view + g = view.g + ui = view.ui + self.g = g + self.cfg = g.spec.get("pane") or {} + self.tabs = [t for t in (self.cfg.get("tabs") or [{"id": "preview"}, {"id": "files"}, {"id": "deploy", "enabled": False}])] + self.tab = self.cfg.get("default_tab") or "preview" + self.is_open = False + bots = view.bots() + self.bot = bots[0] + self.root = TuiRows(w=self.width(), h="100%", style=ui.st("ink", "bg")) + self.head = GrrBar(ui, style=ui.st("ink", "bg")) + self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") + self.root.add(self.head, self.page) + self.state = g.pane_state.setdefault(view.chat["id"], {}) + self.files = GrrPaneFiles(self) + self.preview = GrrPanePreview(self) + self.deploy = GrrPaneDeploy(self) + self.watchers = [] + self.watchers.append(g.events.on("files.changed", lambda d: self.on_event("files", d))) + self.watchers.append(g.events.on("port.opened", lambda d: self.on_event("preview", d))) + self.watchers.append(g.events.on("port.closed", lambda d: self.on_event("preview", d))) + self.watchers.append(g.events.on("preview.ready", lambda d: self.on_event("preview", d))) + self.watchers.append(g.events.on("preview.failed", lambda d: self.on_event("preview", d))) + self.keeper = None + + def width(self): + cols = self.g.app.cols + if self.layer_mode(): + return "1fr" + return max(GRR_PANE_MIN_W, min(GRR_PANE_MAX_W, cols * 46 // 100)) + + def layer_mode(self): + return self.g.app.cols < GRR_PANE_MIN_COLS + + def destroy(self): + for off in self.watchers: + off() + if self.keeper is not None: + self.g.app.cancel_timer(self.keeper) + self.preview.hide() + + def restore(self): + if self.state.get("open") and not self.layer_mode(): + self.show(self.state.get("tab") or self.tab) + + def toggle(self): + if self.is_open: + self.hide() + else: + self.show(self.state.get("tab") or self.tab) + + def show(self, tab): + v = self.v + self.is_open = True + self.state["open"] = True + self.root.w = self.width() + if self.root.parent is None: + v.root.add(self.root) + if self.layer_mode(): + v.col.visible = False + self.v.refresh_head() + self.pick(tab if any(t["id"] == tab for t in self.tabs) else self.tab) + self.g.app.set_focus(self.page) + + def hide(self): + v = self.v + if not self.is_open: + return + self.is_open = False + self.state["open"] = False + if self.root.parent is not None: + v.root.remove(self.root) + v.col.visible = True + self.preview.hide() + v.refresh_head() + self.g.app.set_focus(v.box) + + def pick(self, tab): + self.tab = tab + self.state["tab"] = tab + if tab != "preview": + self.preview.hide() + self.paint_head() + self.current().show() + + def current(self): + return {"preview": self.preview, "files": self.files, "deploy": self.deploy}.get(self.tab, self.preview) + + def paint_head(self): + g, ui, t = self.g, self.v.ui, self.g.t + parts = [] + for tab in self.tabs: + on = tab["id"] == self.tab + parts.append({"text": " %s " % t("pane.tab_" + tab["id"]), "fn": (lambda i=tab["id"]: self.pick(i)), "bg": "accent" if on else "bg", "fg": "accent-ink" if on else "muted", "bold": on}) + parts.append({"text": "", "fill": True}) + bots = self.v.bots() + if len(bots) > 1: + parts.append({"text": " %s %s " % (ui.icon("user"), grr_clip(self.bot["name"], 12)), "bg": "card", "fn": lambda: g.menu([{"label": b["name"], "checked": b["id"] == self.bot["id"], "action": (lambda b=b: self.choose(b))} for b in bots])}) + parts.append({"text": " %s " % ui.icon("close"), "fn": self.hide, "bg": "card"}) + self.head.set(parts) + + def choose(self, bot): + self.bot = bot + self.state["bot"] = bot["id"] + self.files.reset() + self.preview.reset() + self.paint_head() + self.current().show() + + def on_event(self, kind, data): + if not self.is_open or (data or {}).get("bot_id", self.bot["id"]) != self.bot["id"]: + return + if kind == "files" and self.tab == "files": + self.files.changed() + elif kind == "preview" and self.tab == "preview": + self.preview.load(True) + + +class GrrPaneTab: + def __init__(self, pane): + self.pane = pane + + @property + def g(self): + return self.pane.g + + @property + def bot(self): + return self.pane.bot + + def base(self, name): + return "/api/bots/%s/%s" % (self.bot["id"], name) + + def items(self, items): + self.pane.page.set_items(items) + + def reset(self): + pass + + def hide(self): + pass + + +class GrrPaneFiles(GrrPaneTab): + """The bot's workspace, read only: folders, a name filter, a viewer.""" + + def __init__(self, pane): + super().__init__(pane) + self.path = "" + self.q = "" + self.file = None + self.entries = None + self.total = 0 + self.truncated = False + self.gen = 0 + + def base(self, name): + return "/api/bots/%s/workspace/%s" % (self.bot["id"], name) + + def reset(self): + self.path, self.q, self.file, self.entries = "", "", None, None + self.gen += 1 + + def show(self): + if self.file is not None: + self.view_file(self.file) + else: + self.load() + + def changed(self): + if self.file is None: + self.load(True) + + def load(self, quiet=False): + g, t = self.g, self.g.t + self.gen += 1 + gen = self.gen + if self.q: + path = self.base("search") + "?q=" + urllib.parse.quote(self.q) + else: + path = self.base("tree") + "?path=" + urllib.parse.quote(self.path) + + def landed(res, err): + if gen != self.gen: + return + if err: + if not quiet: + g.error(err) + self.items([GrrEmptyItem("warn", t("files.load_failed"))]) + return + self.entries = res.get("entries") or res.get("results") or [] + self.total = res.get("total", len(self.entries)) + self.truncated = bool(res.get("truncated")) + self.paint() + g.app.offload(lambda: g.client.get(path), landed) + + def paint(self): + g, t, ui = self.g, self.g.t, self.g.ui + crumb = "/" + self.path if self.path else "/" + chips = [{"label": crumb, "icon": "folder", "caret": False, "action": self.up}, {"label": self.q or t("files.filter"), "icon": "search", "caret": False, "action": self.ask}, + {"label": t("files.refresh"), "icon": "refresh", "caret": False, "action": lambda: self.load()}] + items = [GrrChipsItem(chips)] + rows = [] + for e in self.entries or []: + name = e.get("path") or ((self.path + "/" if self.path else "") + e["name"]) + is_dir = e.get("type") == "dir" + sub = (t("files.cache") if e.get("cache") else "") if is_dir else GrrFormat.bytes(e.get("size", 0)) + rows.append(GrrRowItem(e.get("path") or e["name"] if self.q else e["name"], icon="folder" if is_dir else "file", sub=sub, chevron=is_dir, + action=(lambda n=name, d=is_dir: self.enter(n) if d else self.open_file(n)))) + if self.path and not self.q: + rows.insert(0, GrrRowItem("..", icon="back", action=self.up)) + if rows: + items += grr_group(rows) + else: + items.append(GrrEmptyItem("folder", t("files.none") if self.q else t("files.empty"))) + if self.truncated: + items.append(GrrNote(t("files.cut", shown=len(self.entries or []), total=self.total))) + self.items(items) + + def enter(self, name): + self.path = name + self.q = "" + self.load() + + def up(self): + if self.q: + self.q = "" + else: + self.path = self.path.rsplit("/", 1)[0] if "/" in self.path else "" + self.load() + + def ask(self): + g, t = self.g, self.g.t + g.edit(t("files.filter"), self.q, lambda v: (setattr(self, "q", v.strip()), self.load())) + + def open_file(self, path): + g, t = self.g, self.g.t + self.gen += 1 + gen = self.gen + + def landed(res, err): + if gen != self.gen: + return + if err: + g.error(err) + return + self.file = res + self.view_file(res) + g.app.offload(lambda: g.client.get(self.base("file") + "?path=" + urllib.parse.quote(path)), landed) + + def view_file(self, info): + g, t, ui = self.g, self.g.t, self.g.ui + items = [GrrChipsItem([{"label": t("files.back_list"), "icon": "back", "caret": False, "action": self.close_file}, + {"label": t("files.copy_path"), "icon": "copy", "caret": False, "action": lambda: g.copy_text(info.get("path", ""))}, + {"label": t("files.download"), "icon": "download", "caret": False, "action": lambda: self.download(info)}]), + GrrNote(info.get("path", ""), tone="muted")] + text = info.get("text") + if isinstance(text, str): + lines = text.split("\n") + shown = lines[:2000] + width = len(str(len(shown))) + body = "\n".join("%*d %s" % (width, i + 1, line) for i, line in enumerate(shown)) + items.append(GrrTextCard(body, mono=True)) + if len(lines) > len(shown): + items.append(GrrNote(t("files.cut", shown=len(shown), total=len(lines)))) + else: + kind = info.get("kind") + items.append(GrrNote({"binary": t("files.binary"), "large": t("files.large", max=GrrFormat.bytes(g.ui.cap("VIEWER_MAX_BYTES", 2097152))), "gone": t("files.gone")}.get(kind, t("files.binary")), center=True)) + self.items(items) + + def close_file(self): + self.file = None + self.load() + + def download(self, info): + g = self.g + path = self.base("raw") + "?path=" + urllib.parse.quote(info.get("path", "")) + "&download=1" + + def work(): + data = g.client.fetch(path, limit=GRR_BODY_MAX)[0] + dest = os.path.join(tempfile.gettempdir(), "grr-" + re.sub(r"[^A-Za-z0-9._-]", "_", info.get("name", "file"))) + with open(dest, "wb") as fh: + fh.write(data) + return dest + g.app.offload(work, lambda dest, err: g.error(err) if err else g.toast("Saved " + dest)) + + +class GrrPanePreview(GrrPaneTab): + """What the bot built: folders with an index.html and servers listening + inside its computer. A terminal cannot frame a page, so it shows the + address and opens it in the desktop browser.""" + + def __init__(self, pane): + super().__init__(pane) + self.info = None + self.target = "" + self.public = False + self.shown = False + + def reset(self): + self.info = None + self.target = "" + + def show(self): + self.shown = True + self.load() + if self.pane.keeper is None: + ttl = max(5, (self.g.ui.cap("PORT_WATCH_TTL", 120)) / 2.0) + self.pane.keeper = self.g.app.every(ttl, lambda: self.load(True) if self.shown else None) + + def hide(self): + self.shown = False + if self.pane.keeper is not None: + self.g.app.cancel_timer(self.pane.keeper) + self.pane.keeper = None + + def load(self, quick=False): + g = self.g + path = self.base("preview") + ("?ports=0" if quick else "") + + def landed(res, err): + if err: + self.info = {"folders": [], "ports": [], "active": None, "proxy": "local", "domain": "", "failed": True} + g.error(err) + else: + self.info = res + self.paint() + active = (self.info or {}).get("active") + if self.shown and active and active.get("state") == "starting": + g.app.after(2.0, lambda: self.load(True) if self.shown else None) + g.app.offload(lambda: g.client.get(path), landed) + + def options(self): + t = self.g.t + info = self.info or {} + out = [{"id": "folder:" + f["path"], "label": "/" + f["path"] if f["path"] else t("preview.root"), "kind": "folder", "path": f["path"]} for f in info.get("folders", [])] + out += [{"id": "port:%s" % p["port"], "label": t("preview.port", port=p["port"]) + (" (%s)" % p["process"] if p.get("process") else ""), "kind": "port", "port": p["port"]} for p in info.get("ports", [])] + return out + + def paint(self): + g, t, ui = self.g, self.g.t, self.g.ui + info = self.info + if info is None: + self.items([GrrEmptyItem("globe", "...")]) + return + if info.get("proxy") == "none": + self.items([GrrEmptyItem("warn", t("preview.no_proxy", domain=info.get("domain", "")))]) + return + active = info.get("active") + items = [] + if active: + url = active.get("url") or active.get("address") or "" + state = active.get("state", "") + items += [GrrEmptyItem("globe", url or t("preview.starting", host=active.get("host", "")), state)] + items.append(GrrKvItem([("State", state), ("Access", t("preview.public_badge") if active.get("access") == "public" or active.get("public") else "private")])) + if url: + items.append(GrrButtonItem(t("preview.external"), lambda: g.open_url(url), inline=True, icon="external")) + items.append(GrrButtonItem(t("ui.share"), lambda: g.copy_text(url), "secondary", inline=True, icon="copy")) + items.append(GrrButtonItem(t("preview.stop"), self.stop, "secondary", inline=True)) + else: + options = self.options() + if not options: + state = info.get("container") + items.append(GrrEmptyItem("globe", t("preview.container_off", state=state) if state and state != "running" else t("preview.no_folders"), t("preview.pick_hint"))) + else: + rows = [GrrRowItem(o["label"], icon="folder" if o["kind"] == "folder" else "bolt", check=o["id"] == (self.target or options[0]["id"]), action=(lambda o=o: self.set_target(o["id"]))) for o in options] + items += [GrrLabel(t("preview.pick_hint"))] + grr_group(rows) + items += grr_group([GrrRowItem(t("preview.public"), sub=t("preview.public_hint"), toggle=self.public, on_toggle=lambda v: setattr(self, "public", v))]) + items.append(GrrButtonItem(t("preview.open"), self.open, inline=True, icon="play")) + items.append(GrrRowItem(t("preview.port_placeholder"), icon="plus", link=True, action=self.ask_port)) + items.append(GrrNote(t("preview.port_opened", port="N", process="") if False else "")) + self.items([i for i in items if not (isinstance(i, GrrNote) and not i.text)]) + + def set_target(self, target): + self.target = target + self.paint() + + def ask_port(self): + g, t = self.g, self.g.t + + def go(value): + if value.strip().isdigit(): + self.post({"kind": "port", "port": int(value.strip())}) + g.edit(t("preview.port_placeholder"), "", go) + + def open(self): + options = self.options() + if not options: + return + choice = next((o for o in options if o["id"] == self.target), options[0]) + self.post({"kind": "port", "port": choice["port"]} if choice["kind"] == "port" else {"kind": "folder", "path": choice["path"]}) + + def post(self, target): + g = self.g + body = dict({"access": "public" if self.public else ((self.g.spec.get("pane") or {}).get("preview", {}).get("access_default") or "login")}, **target) + + def landed(res, err): + if err: + g.error(err) + else: + self.info = res + self.paint() + active = res.get("active") + if self.shown and active and active.get("state") == "starting": + g.app.after(2.0, lambda: self.load(True) if self.shown else None) + g.app.offload(lambda: g.client.post(self.base("preview"), body), landed) + + def stop(self): + g = self.g + + def landed(res, err): + if err: + g.error(err) + else: + self.info = res + self.paint() + g.app.offload(lambda: g.client.delete(self.base("preview")), landed) + + +class GrrPaneDeploy(GrrPaneTab): + """Shown with every control disabled, like the web app: deploy has no + server behind it yet.""" + + def show(self): + t = self.g.t + self.items(grr_group([GrrRowItem(t("deploy.address"), value="-"), GrrRowItem(t("deploy.access"), value=t("deploy.access_me")), GrrRowItem(t("deploy.cover"), value="-")]) + + [GrrButtonItem(t("deploy.button"), lambda: None, "secondary", inline=True, disabled=True), GrrNote(t("deploy.note"), center=True)]) + + +GRR_ROUTES = ( + ("/bots", "GrrBotsView"), ("/bot/:id", "GrrProfileView"), ("/bot/:id/instructions", "GrrInstructionsView"), ("/bot/:id/memories", "GrrMemoriesView"), + ("/memory/:id", "GrrMemoryView"), ("/bot/:id/skills", "GrrSkillsView"), ("/skill/:id", "GrrSkillView"), ("/bot/:id/computer", "GrrComputerView"), + ("/bot/:id/screen", "GrrScreenView"), ("/chat/:id", "GrrChatView"), ("/automations", "GrrAutomationsView"), ("/automations/new", "GrrNewAutomationView"), + ("/automation/:id", "GrrAutomationView"), ("/library", "GrrLibraryView"), ("/projects", "GrrProjectsView"), ("/project/:id", "GrrProjectView"), + ("/search", "GrrSearchView"), ("/settings", "GrrSettingsView"), ("/settings/account", "GrrAccountView"), ("/settings/usage", "GrrUsageView"), + ("/settings/rules", "GrrRulesView"), ("/settings/rule/:id", "GrrRuleView"), ("/settings/appearance", "GrrAppearanceView"), ("/settings/language", "GrrLanguageView"), + ("/settings/haptics", "GrrHapticsView"), ("/settings/widget", "GrrWidgetView"), ("/settings/computer", "GrrComputersView"), ("/settings/connectors", "GrrConnectorsView"), + ("/settings/advanced", "GrrAdvancedView"), ("/settings/builder", "GrrBuilderView"), ("/settings/feedback", "GrrFeedbackView"), ("/settings/timezone", "GrrTimezoneView"), + ("/template/:id", "GrrTemplateView"), ("/template/:id/:part", "GrrTemplatePartView"), ("/public/:slug", "GrrPublicTemplateView"), +) + + +def grr_shell_call_quiet(self, method, path, body=None): + """Fire and forget a server call from the loop (read marks, releases).""" + self.app.offload(lambda: self.client.request(method, path, body, quiet=True), lambda _r, _e: None) + + +def grr_router_rewrite(self, raw): + """Replace the current route in the stack without opening it again.""" + if self.stack: + self.stack[-1] = raw + + +GrrShell.client_call_quiet = grr_shell_call_quiet +GrrRouter.rewrite = grr_router_rewrite + + +def grr_tui(): + """Run the fullscreen grr client. The theme comes from the account's + settings (system, day, night and the Black or Blue variant).""" + app = GrrTuiApp() + app.rows, app.cols = app._winsize() + shell = GrrShell(app) + try: + app.mount(shell.root) + shell.boot() + return app.run() + except TuiError as exc: + print("tui unavailable: %s" % exc, file=sys.stderr) + return 2 + finally: + shell.stop_stream() + + def build_parser(): parser = argparse.ArgumentParser(prog="tai", description="single-file autonomous agent, standard library only") parser.add_argument("--profile", default=None) @@ -23526,7 +30120,8 @@ def build_parser(): parser.add_argument("--mplex", nargs=argparse.REMAINDER, default=None, metavar="COMMAND", help="terminal multiplexer: any mplex command (new-session, attach-session, list-keys ...); bare --mplex starts a session. Config: ~/.tai/mplex.toml") parser.add_argument("--demo", action="store_true", help="run the fullscreen TUI framework showcase") parser.add_argument("--demo-shot", action="store_true", help="print one composed demo frame as text and exit") - parser.add_argument("--tui", action="store_true", help="run the fullscreen Telegram-style chat UI") + parser.add_argument("--tui", action="store_true", help="run the fullscreen grr client (bots, chats, automations, library, projects, settings) against the grr server; address in GRR_URL") + parser.add_argument("--tui-local", action="store_true", help="run the fullscreen Telegram-style chat UI on the local vault, no server needed") parser.add_argument("--theme", default=None, metavar="NAME", help="TUI theme: dark, light, nord, gruvbox, ansi (or TAI_THEME)") parser.add_argument("prompt", nargs="*") return parser @@ -23662,6 +30257,8 @@ def main(argv=None): profile = args.profile or "default" backend = NativePlexerBackend(home, profile) return backend.run() + if args.tui: + return grr_tui() if args.profile is None: args.profile = "telegram" if args.telegram else "default" if not PROFILE_RE.match(args.profile): @@ -23713,7 +30310,7 @@ def main(argv=None): except Exception: pass return 0 - if args.tui: + if args.tui_local: try: return tui_chat(agent, theme_name) finally: diff --git a/test_tai.py b/test_tai.py index e7b3eab..27371c9 100644 --- a/test_tai.py +++ b/test_tai.py @@ -1,15 +1,19 @@ # retoor import contextlib +import http.server import io import json import os import re import shutil +import socketserver import sqlite3 import subprocess import sys import tempfile +import threading import time +import unicodedata import unittest import urllib.error from datetime import datetime, timedelta, timezone @@ -10558,6 +10562,1029 @@ class CtxStoreTests(unittest.TestCase): self.assertTrue(any("c00" in line for line in lines)) self.assertFalse(any("c04" in line for line in lines)) +# --------------------------------------------------------------------------- +# The grr client behind `tai.py --tui`: strings, formats, client, stream, +# router, page engine and the shell against a small fake grr server. +# --------------------------------------------------------------------------- + +GRR_FAKE_SPEC = { + "version": "9.9.9", "caps": {"GROUP_MAX_BOTS": 6, "INSTRUCTIONS_MAX_CHARS": 12000, "MESSAGE_MAX_CHARS": 20000, "UPLOAD_MAX_BYTES": 20971520, "ROUTINE_MIN_INTERVAL": 30}, + "modes": ["ask", "imagine", "build"], "tiers": ["auto", "fast", "expert"], "shapes": list(tai.GRR_SHAPES), "themes": ["system", "day", "night"], "variants": ["black", "blue"], + "languages": ["system", "en", "nl"], "schedule_kinds": ["interval", "daily", "weekly"], "rule_actions": ["allow", "ask"], + "search_kinds": ["all", "messages", "bots"], "voice": {"engines": ["browser", "neural"], "default_engine": "browser"}, + "pane": {"tabs": [{"id": "preview"}, {"id": "files"}, {"id": "deploy", "enabled": False}], "default_tab": "preview", "preview": {"access_default": "login"}}, + "prompts": {"persona": "You are {name}.", "rules": "Be brief."}, "builder": {"models": ["m1"], "efforts": ["low"], "permissions": ["dontAsk"], "tool_presets": {"build": {}}}, +} +GRR_FAKE_LOCALES = {"en": {"app.tagline": "Bots that deliver.", "home.bots": "Bots", "home.search": "Search", "home.automations": "Automations", "home.library": "Library", + "home.projects": "Projects", "home.settings": "Settings", "chat.ask_bot": "Ask {name}", "chat.is_working": "{name} is working", + "err.offline": "Cannot reach the server.", "apierr.not_found": "That is gone, champ.", "apierr.chat_cap": "You reached the cap of {n} chats.", + "chat.allow_once": "Allow once", "chat.deny": "Deny", "chat.needs_approval": "{name} needs your approval", "chat.skip": "Skip", + "time.today": "Today", "time.yesterday": "Yesterday", "sched.every_minutes": "Every {n} minutes", "sched.daily": "Every day at {time}", + "sched.weekly": "Every {day} at {time}", "sched.every_hours": "Every {n} hours", "sched.every_days": "Every {n} days", "sched.every_seconds": "Every {n} seconds", + "login.submit": "Sign in", "login.username": "Username or email", "login.password": "Password", "chat.state_allowed": "Allowed", "chat.steps": "Steps ({n})"}, + "nl": {"home.bots": "Bots", "chat.ask_bot": "Vraag {name}"}} +GRR_FAKE_BOTS = [ + {"id": "b1", "name": "Tai", "description": "Straight professional.", "instructions": "Be short.", "shape": "tilt", "color": "#8c857a", "notifications": 1, "auto_approve": 0, + "voice": None, "persona": None, "rules": None, "memories": 1, "skills": 1, "routines": [], "chat": {"id": "c1", "preview": "hello", "updated": 1792257020.0, "unread": 2, "busy": False}}, + {"id": "b2", "name": "Smile", "description": "Hype co-pilot.", "instructions": "Cheer.", "shape": "flower", "color": "#e93d82", "notifications": 1, "auto_approve": 0, + "voice": None, "persona": None, "rules": None, "memories": 0, "skills": 0, "routines": [], "chat": {"id": "c2", "preview": "", "updated": 1792256000.0, "unread": 0, "busy": False}}, +] +GRR_FAKE_CHATS = [ + {"id": "c1", "kind": "bot", "title": "Tai", "bot_ids": ["b1"], "mode": "ask", "tier": "auto", "pinned": 0, "unread": 2, "preview": "hello", "updated": 1792257020.0, "busy": False}, + {"id": "c2", "kind": "bot", "title": "Smile", "bot_ids": ["b2"], "mode": "ask", "tier": "auto", "pinned": 0, "unread": 0, "preview": "", "updated": 1792256000.0, "busy": False}, + {"id": "c3", "kind": "group", "title": "Both", "bot_ids": ["b1", "b2"], "mode": "ask", "tier": "auto", "pinned": 1, "unread": 0, "preview": "hey", "updated": 1792255000.0, "busy": False}, + {"id": "c4", "kind": "general", "title": "Notes", "bot_ids": [], "mode": "ask", "tier": "auto", "pinned": 1, "unread": 0, "preview": "", "updated": 1792254000.0, "busy": False}, +] +GRR_FAKE_MESSAGES = [ + {"id": 1, "chat_id": "c1", "role": "user", "kind": "text", "sender": "user", "content": "Run echo hello and tell me what it printed.", "meta": {}, "ts": 1792257000.0}, + {"id": 2, "chat_id": "c1", "role": "assistant", "kind": "process", "sender": "b1", "content": "echo hello", "meta": {"pid": "p1", "state": "exited", "cols": 80, "rows": 24, "exit": 0, "secs": 1.0, "transcript": "hello\r\n"}, "ts": 1792257001.0}, + {"id": 3, "chat_id": "c1", "role": "assistant", "kind": "approval", "sender": "b1", "content": "Run: rm -rf /tmp/x", "meta": {"state": "pending", "tool": "shell"}, "ts": 1792257002.0}, + {"id": 4, "chat_id": "c1", "role": "assistant", "kind": "question", "sender": "b1", "content": "Which one?", "meta": {"options": ["Red", "Blue"], "answer": None}, "ts": 1792257003.0}, + {"id": 5, "chat_id": "c1", "role": "assistant", "kind": "file", "sender": "b1", "content": "the report", "meta": {"file_id": "f1", "name": "report.txt", "mime": "text/plain", "size": 20, "file_kind": "file"}, "ts": 1792257004.0}, + {"id": 6, "chat_id": "c1", "role": "assistant", "kind": "text", "sender": "b1", "content": "It printed **hello**.\n\n- one\n- two\n\n```\ncode here\n```", "meta": {"steps": [{"tool": "shell", "summary": "Run: echo hello", "ok": True, "secs": 1.2}], "smile": "Delivered."}, "ts": 1792257005.0}, +] + + +class GrrFakeServer: + """A small stand-in for the grr REST API. Every call is recorded in + `calls` as (method, path, body); unknown reads answer an empty object.""" + + def __init__(self): + self.calls = [] + self.events = [] + self.streams = {} + self.fail = {} + me = {"id": "a1", "username": "retoor", "email": "r@x", "is_admin": False, "version": "9.9.9", + "settings": {"theme": "system", "variant": "black", "language": "system", "haptics": True, "notifications": True, "auto_review": True, "auto_tz": False, "timezone": "UTC", "widget": False, "tier": "auto", "ui_scale": 100}} + self.get = { + "/api/spec": GRR_FAKE_SPEC, "/locales.json": GRR_FAKE_LOCALES, "/api/auth/options": {"env_key": False}, "/api/me": me, + "/api/bots": {"bots": GRR_FAKE_BOTS}, "/api/chats": {"chats": GRR_FAKE_CHATS}, + "/api/chats/c1": GRR_FAKE_CHATS[0], "/api/chats/c2": GRR_FAKE_CHATS[1], "/api/chats/c3": GRR_FAKE_CHATS[2], "/api/chats/c4": GRR_FAKE_CHATS[3], + "/api/chats/c1/messages": {"messages": GRR_FAKE_MESSAGES, "has_more": False}, "/api/chats/c3/messages": {"messages": [], "has_more": False}, + "/api/chats/c4/messages": {"messages": [], "has_more": False}, + "/api/bots/b1": GRR_FAKE_BOTS[0], "/api/bots/b2": GRR_FAKE_BOTS[1], "/api/rules": {"rules": [{"id": "r1", "text": "run ssh", "action": "allow", "bot_id": ""}]}, + "/api/usage": {"tokens": 10, "calls": 1, "limit": 100, "pct": 10.0, "resets_in_days": 7, "days": [{"day": "2026-10-17", "tokens": 10}], "devplace": None, "models": {}, "counters": {}}, + "/api/routines": {"routines": [{"id": "t1", "name": "Hygiene", "instruction": "Clean up", "active": 1, "schedule": {"kind": "daily", "time": "09:00"}, "bot": GRR_FAKE_BOTS[0]}]}, + "/api/routines/t1": {"id": "t1", "name": "Hygiene", "instruction": "Clean up", "active": 1, "schedule": {"kind": "daily", "time": "09:00"}, "bot": GRR_FAKE_BOTS[0], "next_run": 1792300000.0}, + "/api/library": {"files": [{"id": "f1", "name": "report.txt", "kind": "file", "mime": "text/plain", "size": 20, "created": 1792257000.0}], "storage": 20}, + "/api/files/f1": b"Quarterly report\n", "/api/projects": {"projects": [{"id": "p1", "name": "Pravda", "starred": 1, "updated": 1792257000.0}]}, + "/api/projects/p1": {"id": "p1", "name": "Pravda", "instructions": "Be brief.", "starred": 1, "chats": [{"id": "c1", "title": "Tai", "updated": 1792257000.0}]}, + "/api/search": {"results": [{"type": "bot", "id": "b1", "title": "Tai", "snippet": "Straight", "bot": GRR_FAKE_BOTS[0]}]}, + "/api/connectors": {"connectors": [{"name": "shell", "tools": ["shell"], "enabled": True}]}, "/api/health": {"ok": True, "pressure": "calm", "turns_active": 0, "turns_cap": 8, "db_bytes": 2048, "uptime": 120, "version": "9.9.9"}, + "/api/builder": {"admin": False}, "/api/bots/b1/memories": {"memories": [{"id": "m1", "content": "Likes tabs", "bot_id": "b1"}]}, + "/api/memories/m1": {"id": "m1", "content": "Likes tabs", "bot_id": "b1"}, "/api/bots/b1/skills": {"skills": [{"id": "s1", "name": "fetch", "description": "Fetch pages", "bot_id": "b1"}]}, + "/api/skills/s1": {"id": "s1", "name": "fetch", "description": "Fetch pages", "content": "# Fetch", "bot_id": "b1"}, "/api/bots/b1/links": {"links": [{"id": "l1", "url": "https://example.com/a", "title": "Example"}]}, + "/api/bots/b1/computer": {"running": False, "busy": False, "disk": {"state": "normal", "free_pct": 50}}, + "/api/bots/b2/computer": {"running": False, "busy": False, "disk": {"state": "normal", "free_pct": 50}}, + "/api/bots/b1/desktop": {"running": False, "width": 1280, "height": 800, "clients": 0, "max": 4, "browser": True}, + "/api/bots/b1/workspace/tree": {"path": "", "entries": [{"name": "src", "type": "dir", "size": 0, "cache": False}, {"name": "a.py", "type": "file", "size": 12, "cache": False}], "total": 2, "truncated": False}, + "/api/bots/b1/workspace/file": {"path": "a.py", "name": "a.py", "kind": "text", "size": 12, "text": "print('hi')\n"}, + "/api/bots/b1/preview": {"folders": [{"path": ""}], "container": "running", "ports": [{"port": 8000, "process": "python"}], "active": None, "proxy": "local", "domain": "localhost"}, + "/api/templates/t9": {"id": "t9", "name": "Tai", "description": "A template", "state": "unpublished", "slug": "s", "snapshot": {"shape": "tilt", "color": "#8c857a", "instructions": "Be short.", "memories": ["m"], "skills": [{"name": "fetch", "description": "d", "content": "c"}]}}, + } + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def reply(self, status, payload): + if isinstance(payload, bytes): + data, kind = payload, "application/octet-stream" + else: + data, kind = json.dumps(payload).encode("utf-8"), "application/json" + self.send_response(status) + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def handle_any(self, method): + path = self.path.split("?")[0] + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw.decode("utf-8")) if raw and "json" in (self.headers.get("Content-Type") or "") else raw + except ValueError: + body = raw + outer.calls.append((method, self.path, body, self.headers.get("Authorization"))) + if path in outer.fail: + status, payload = outer.fail[path] + return self.reply(status, payload) + if path == "/api/events" or path.endswith("/stream"): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + try: + self.wfile.write(b": hello\n\n") + for name, data in (outer.events if path == "/api/events" else outer.streams.get(path, [])): + self.wfile.write(("event: %s\ndata: %s\n\n" % (name, json.dumps(data))).encode("utf-8")) + self.wfile.flush() + except OSError: + pass + self.close_connection = True + return + if method == "GET": + if path in outer.get: + return self.reply(200, outer.get[path]) + return self.reply(200, {}) + if path == "/api/auth/login": + return self.reply(200, {"token": "tok-" + str(body.get("username", "key")), "account": {"username": body.get("username", "key"), "email": "e@x"}}) + if path.endswith("/messages"): + return self.reply(200, {"message": {"id": 99, "chat_id": "c1", "role": "user", "kind": "text", "sender": "user", "content": body.get("text", ""), "meta": {}, "ts": time.time()}}) + return self.reply(200, {"id": "new", "ok": True}) + + def do_GET(self): + self.handle_any("GET") + + def do_POST(self): + self.handle_any("POST") + + def do_PATCH(self): + self.handle_any("PATCH") + + def do_PUT(self): + self.handle_any("PUT") + + def do_DELETE(self): + self.handle_any("DELETE") + + class Server(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + + self.httpd = Server(("127.0.0.1", 0), Handler) + self.url = "http://127.0.0.1:%d" % self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + def seen(self, method, prefix): + return [call for call in self.calls if call[0] == method and call[1].startswith(prefix)] + + +class GrrStringsTests(unittest.TestCase): + def test_substitutes_and_falls_back_to_english_then_key(self): + s = tai.GrrStrings({"en": {"a": "Hello {name}", "b": "only en"}, "nl": {"a": "Hallo {name}"}}, "nl") + self.assertEqual(s.t("a", name="X"), "Hallo X") + self.assertEqual(s.t("b"), "only en") + self.assertEqual(s.t("missing.key"), "missing.key") + + def test_the_sort_labels_the_server_lacks_have_a_fallback(self): + self.assertEqual(tai.GrrStrings({"en": {}}).t("library.sort_new"), "Newest first") + + def test_language_follows_the_environment_when_the_setting_is_system(self): + langs = ["system", "en", "nl", "zh-CN", "zh-TW", "pt"] + self.assertEqual(tai.GrrStrings.resolve("system", langs, {"LANG": "nl_NL.UTF-8"}), "nl") + self.assertEqual(tai.GrrStrings.resolve("system", langs, {"LANG": "zh_TW.UTF-8"}), "zh-TW") + self.assertEqual(tai.GrrStrings.resolve("system", langs, {"LANG": "pt_BR.UTF-8"}), "pt") + self.assertEqual(tai.GrrStrings.resolve("system", langs, {}), "en") + self.assertEqual(tai.GrrStrings.resolve("nl", langs, {"LANG": "de_DE"}), "nl") + + def test_explain_words_an_error_code_with_its_numbers_and_hint(self): + s = tai.GrrStrings({"en": {"apierr.chat_cap": "Cap of {n} chats.", "apierr.chat_cap_hint": "Delete one.", "apierr.invalid": "Not valid."}}) + err = s.explain(400, {"error": {"code": "chat_cap", "vars": {"n": 3}}}) + self.assertEqual((err.status, err.code, err.args[0], err.hint), (400, "chat_cap", "Cap of 3 chats.", "Delete one.")) + self.assertEqual(s.explain(400, {"error": {"code": "bad_mode"}}).args[0], "Not valid.") + self.assertEqual(s.explain(500, {"error": {"code": "weird", "message": "plain words"}}).args[0], "plain words") + self.assertEqual(s.explain(502, None).args[0], "HTTP 502") + + +class GrrFormatTests(unittest.TestCase): + def setUp(self): + self.fmt = tai.GrrFormat(tai.GrrStrings(GRR_FAKE_LOCALES)) + + def test_sizes(self): + self.assertEqual([tai.GrrFormat.bytes(n) for n in (0, 1023, 1536, 20480, 5 * 1048576, 3 * 1073741824)], ["0 B", "1023 B", "1.5 kB", "20 kB", "5.0 MB", "3.00 GB"]) + + def test_schedule_words(self): + f = self.fmt + self.assertEqual(f.schedule({"kind": "interval", "every_seconds": 300}), "Every 5 minutes") + self.assertEqual(f.schedule({"kind": "interval", "every_seconds": 7200}), "Every 2 hours") + self.assertEqual(f.schedule({"kind": "interval", "every_seconds": 172800}), "Every 2 days") + self.assertEqual(f.schedule({"kind": "interval", "every_seconds": 45}), "Every 45 seconds") + self.assertEqual(f.schedule({"kind": "daily", "time": "09:00"}), "Every day at 09:00") + self.assertEqual(f.schedule({"kind": "weekly", "day": 0, "time": "10:30"}), "Every Monday at 10:30") + self.assertEqual(f.schedule(None), "") + + def test_list_time_today_yesterday_and_older(self): + now = time.time() + self.assertRegex(self.fmt.list_time(now), r"^\d\d:\d\d$") + self.assertEqual(self.fmt.list_time(now - 86400), "Yesterday") + self.assertRegex(self.fmt.list_time(now - 40 * 86400), r"^\d{1,2} \w+$") + self.assertEqual(self.fmt.list_time(0), "") + + def test_stamp_names_the_day(self): + self.assertTrue(self.fmt.stamp(time.time()).startswith("Today ")) + self.assertTrue(self.fmt.stamp(time.time() - 86400).startswith("Yesterday ")) + + def test_relative_times(self): + now = time.time() + self.assertEqual(self.fmt.ago(now - 3 * 3600), "3 hours ago") + self.assertEqual(self.fmt.ago(now - 60), "1 minute ago") + self.assertEqual(self.fmt.ago(now + 2 * 86400 + 60), "in 2 days") + + def test_domain(self): + self.assertEqual(tai.GrrFormat.domain("https://www.example.com/a?b=1"), "example.com") + self.assertEqual(tai.GrrFormat.domain("not a url"), "not a url") + + +class GrrPaletteTests(unittest.TestCase): + def test_modes_variants_and_the_tui_theme(self): + night = tai.GrrPalette({}, "night", "black") + day = tai.GrrPalette({}, "day", "blue") + self.assertEqual(night.c("bg"), "#0d0c0a") + self.assertEqual(day.c("bg"), "#f6f1e6") + self.assertEqual(day.c("accent"), "#41697f") + self.assertEqual(night.c("accent"), "#d4a020") + for palette in (night, day): + theme = palette.tui_theme() + for role in tai.TUI_THEME_ROLES: + self.assertTrue(theme.color(role), role) + + def test_the_server_spec_wins_over_the_built_in_colors(self): + spec = {"palette": {"night": {"accent": "#112233"}}} + self.assertEqual(tai.GrrPalette(spec, "night", "black").c("accent"), "#112233") + + def test_system_follows_colorfgbg(self): + with mock.patch.dict(os.environ, {"COLORFGBG": "0;15"}): + self.assertEqual(tai.GrrPalette.mode_of("system"), "day") + with mock.patch.dict(os.environ, {"COLORFGBG": "15;0"}): + self.assertEqual(tai.GrrPalette.mode_of("system"), "night") + + def test_light_colors_get_dark_eyes(self): + self.assertTrue(tai.GrrPalette.light("#ffffff")) + self.assertFalse(tai.GrrPalette.light("#120f08")) + + +class GrrAvatarTests(unittest.TestCase): + def setUp(self): + self.ui = tai.GrrUi(tai.GrrPalette(), tai.GrrStrings()) + + def test_every_shape_has_two_rows_of_seven_cells_and_two_eyes(self): + for shape in tai.GRR_SHAPES: + top, bottom = tai.GRR_AVATAR_ROWS[shape] + self.assertEqual((len(top), len(bottom)), (7, 7), shape) + self.assertEqual((top.count("E"), bottom.count("E")), (2, 2), shape) + + def test_sizes(self): + bot = {"shape": "blob", "color": "#d4a020"} + self.assertEqual([tai.tui_span_width(r) for r in tai.GrrAvatar.rows(self.ui, bot)], [7, 7]) + self.assertEqual([tai.tui_span_width(r) for r in tai.GrrAvatar.rows(self.ui, bot, small=True)], [5, 5]) + self.assertEqual(len(tai.GrrAvatar.rows(self.ui, bot, hero=True)), 4) + self.assertEqual(tai.tui_span_width(tai.GrrAvatar.chip(self.ui, bot)), 5) + + def test_an_unknown_shape_draws_the_blob(self): + rows = tai.GrrAvatar.rows(self.ui, {"shape": "nope", "color": "#fff"}) + self.assertEqual("".join(s.text for s in rows[0]), "".join(s.text for s in tai.GrrAvatar.rows(self.ui, {"shape": "blob", "color": "#fff"})[0])) + + def test_icons_are_one_cell_wide(self): + for name, ch in tai.GRR_ICONS.items(): + self.assertEqual(len(ch), 1, name) + self.assertNotIn(unicodedata.east_asian_width(ch), ("W", "F"), name) + + +class GrrRouterTests(unittest.TestCase): + def setUp(self): + self.seen = [] + self.router = tai.GrrRouter(lambda route, params, query, raw: self.seen.append((route.pattern if route else None, params, query, raw))) + self.router.add("/", None) + self.router.add("/bot/:id", "V") + self.router.add("/bot/:id/skills", "S") + + def test_params_query_and_unknown_paths(self): + self.router.go("/bot/b%201/skills?x=1&y=2") + self.assertEqual(self.seen[-1][:3], ("/bot/:id/skills", {"id": "b 1"}, {"x": "1", "y": "2"})) + self.router.go("/nowhere") + self.assertEqual(self.seen[-1][0], "/") + + def test_back_stack_and_fallback(self): + self.router.go("/") + self.router.go("/bot/a") + self.router.go("/bot/b") + self.router.back("/") + self.assertEqual(self.router.current, "/bot/a") + self.router.back("/") + self.router.back("/fallback") + self.assertEqual(self.router.current, "/fallback") + + def test_going_to_the_previous_route_pops_instead_of_growing(self): + self.router.go("/") + self.router.go("/bot/a") + self.router.go("/") + self.assertEqual(self.router.stack, ["/"]) + + def test_replace_and_rewrite(self): + self.router.go("/") + self.router.go("/bot/a", replace=True) + self.assertEqual(self.router.stack, ["/bot/a"]) + count = len(self.seen) + self.router.rewrite("/bot/z") + self.assertEqual((self.router.current, len(self.seen)), ("/bot/z", count)) + + def test_the_stack_is_capped(self): + for n in range(tai.GrrRouter.STACK_MAX + 20): + self.router.go("/bot/%d" % n) + self.assertEqual(len(self.router.stack), tai.GrrRouter.STACK_MAX) + + +class GrrSessionTests(unittest.TestCase): + def test_accounts_are_kept_private_and_survive_a_reload(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "sub", "grr.json") + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("GRR_URL", None) + s = tai.GrrSession(path) + s.url = "http://example:1" + s.add("tok1", {"username": "a", "email": "a@x"}) + s.add("tok2", {"username": "b", "email": "b@x"}) + self.assertEqual(oct(os.stat(path).st_mode & 0o777), "0o600") + again = tai.GrrSession(path) + self.assertEqual((again.url, again.token, [a["username"] for a in again.accounts]), ("http://example:1", "tok2", ["a", "b"])) + again.drop("tok2") + self.assertEqual(again.token, "tok1") + again.switch("tok1") + again.drop("tok1") + self.assertEqual(again.token, "") + + def test_the_same_user_replaces_its_old_token(self): + with tempfile.TemporaryDirectory() as tmp: + s = tai.GrrSession(os.path.join(tmp, "g.json")) + s.add("old", {"username": "a"}) + s.add("new", {"username": "a"}) + self.assertEqual([a["token"] for a in s.accounts], ["new"]) + + def test_the_environment_beats_the_saved_address(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "g.json") + with open(path, "w") as fh: + json.dump({"url": "http://saved:1", "accounts": [], "active": ""}, fh) + with mock.patch.dict(os.environ, {"GRR_URL": "http://env:2"}): + self.assertEqual(tai.GrrSession(path).url, "http://env:2") + os.environ.pop("GRR_URL", None) + self.assertEqual(tai.GrrSession(path).url, "http://saved:1") + + +class GrrClientTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = GrrFakeServer() + + @classmethod + def tearDownClass(cls): + cls.server.close() + + def client(self, token="tok"): + return tai.GrrClient(self.server.url, token, tai.GrrStrings(GRR_FAKE_LOCALES)) + + def test_calls_carry_the_bearer_token_and_json(self): + c = self.client() + self.assertEqual(c.get("/api/bots")["bots"][0]["name"], "Tai") + c.post("/api/chats/c1/stop", {"a": 1}) + method, path, body, auth = self.server.calls[-1] + self.assertEqual((method, path, body, auth), ("POST", "/api/chats/c1/stop", {"a": 1}, "Bearer tok")) + + def test_errors_become_grr_errors_in_the_users_words(self): + self.server.fail["/api/boom"] = (404, {"error": {"code": "not_found"}}) + try: + with self.assertRaises(tai.GrrError) as ctx: + self.client().get("/api/boom") + self.assertEqual((ctx.exception.status, ctx.exception.code, ctx.exception.args[0]), (404, "not_found", "That is gone, champ.")) + finally: + del self.server.fail["/api/boom"] + + def test_unauthorized_calls_the_hook_unless_quiet(self): + self.server.fail["/api/me"] = (401, {"error": {"code": "unauthorized"}}) + hits = [] + try: + c = self.client() + c.on_unauthorized = lambda: hits.append(1) + with self.assertRaises(tai.GrrError): + c.get("/api/me") + with self.assertRaises(tai.GrrError): + c.get("/api/me", quiet=True) + self.assertEqual(hits, [1]) + finally: + del self.server.fail["/api/me"] + + def test_no_answer_is_an_offline_error(self): + c = tai.GrrClient("http://127.0.0.1:1", "", tai.GrrStrings(GRR_FAKE_LOCALES), timeout=1) + with self.assertRaises(tai.GrrError) as ctx: + c.get("/api/health") + self.assertEqual((ctx.exception.status, ctx.exception.code, ctx.exception.args[0]), (0, "offline", "Cannot reach the server.")) + + def test_upload_sends_the_bytes_and_the_query(self): + self.client().upload("a b.txt", b"hello", chat="c1", bot="b1") + method, path, body, _auth = self.server.calls[-1] + self.assertEqual(method, "POST") + self.assertIn("name=a+b.txt", path) + self.assertIn("chat=c1", path) + self.assertEqual(body, b"hello") + + def test_fetch_caps_what_it_reads(self): + data, cut, _kind = self.client().fetch("/api/files/f1", limit=5) + self.assertEqual((data, cut), (b"Quart", True)) + data, cut, _kind = self.client().fetch("/api/files/f1") + self.assertEqual((data, cut), (b"Quarterly report\n", False)) + + def test_urls_with_the_token(self): + c = self.client("a b") + self.assertTrue(c.url("/api/x").endswith("/api/x?token=a%20b")) + self.assertTrue(c.file_url("f1", True).endswith("/api/files/f1?download=1&token=a%20b")) + + +class GrrStreamTests(unittest.TestCase): + def test_events_are_parsed_comments_ignored_and_posted(self): + server = GrrFakeServer() + try: + server.events = [("message.new", {"id": 1}), ("status", {"chat_id": "c1", "state": "working"})] + got, links = [], [] + done = threading.Event() + + def on_event(name, data): + got.append((name, data)) + if len(got) == 2: + done.set() + client = tai.GrrClient(server.url, "tok", tai.GrrStrings()) + stream = tai.GrrStream(client, "/api/events?token=tok", on_event, lambda fn: fn(), on_link=links.append, reconnect=False) + stream.start() + self.assertTrue(done.wait(5)) + stream.stop() + self.assertEqual(got, [("message.new", {"id": 1}), ("status", {"chat_id": "c1", "state": "working"})]) + self.assertEqual(links[0], True) + finally: + server.close() + + def test_a_line_over_the_cap_ends_the_connection_and_is_counted_not_parsed(self): + server = GrrFakeServer() + try: + server.events = [("big", {"x": "a" * 500}), ("after", {})] + got = [] + client = tai.GrrClient(server.url, "tok", tai.GrrStrings()) + with mock.patch.object(tai, "GRR_SSE_LINE_MAX", 100): + stream = tai.GrrStream(client, "/api/events", lambda n, d: got.append(n), lambda fn: fn(), reconnect=False) + stream.start() + stream.thread.join(5) + self.assertFalse(stream.thread.is_alive()) + self.assertEqual((got, stream.oversize), ([], 1)) + finally: + server.close() + + def test_stop_ends_a_connection_that_is_waiting(self): + server = GrrFakeServer() + try: + client = tai.GrrClient(server.url, "tok", tai.GrrStrings()) + stream = tai.GrrStream(client, "/api/events", lambda n, d: None, lambda fn: fn(), reconnect=False) + stream.start() + stream.stop() + stream.thread.join(5) + self.assertFalse(stream.thread.is_alive()) + finally: + server.close() + + +class GrrThemeIsolation: + """Booting the grr shell registers its themes and selects one in the + process-wide registry; put the registry back so other tests see the builtins.""" + + def setUp(self): + self._themes = dict(tai.TUI_THEMES) + self._theme = tai.TUI_THEME + + def tearDown(self): + tai.TUI_THEMES.clear() + tai.TUI_THEMES.update(self._themes) + tai.TUI_THEME = self._theme + + +class GrrPageTests(GrrThemeIsolation, unittest.TestCase): + """The page engine on a headless app: widths, keys, clicks, scrolling.""" + + def setUp(self): + GrrThemeIsolation.setUp(self) + self.ui = tai.GrrUi(tai.GrrPalette(), tai.GrrStrings(GRR_FAKE_LOCALES)) + self.app = tai.GrrTuiApp() + self.app.rows, self.app.cols = 20, 60 + self.page = tai.GrrPage(self.ui, w="100%", h="100%", style=self.ui.st()) + self.app.mount(self.page) + self.log = [] + + def items(self, items): + self.page.set_items(items) + self.app.layout() + + def key(self, name, char=""): + self.app.dispatch(tai.TuiKey(key=name, char=char)) + self.app.layout() + + def click(self, x, y): + self.app.layout() + self.app.dispatch(tai.TuiClick(x=x, y=y, button=0, modifiers=[], pressed=True)) + self.app.layout() + + def test_every_item_draws_exactly_the_width_it_is_given(self): + ui = self.ui + bot = {"name": "Tai", "shape": "tilt", "color": "#8c857a"} + items = [tai.GrrSpacer(2), tai.GrrLabel("Label"), tai.GrrHeading("Head", True), tai.GrrNote("A long note " * 12, True), + *tai.grr_group([tai.GrrRowItem("Row title " * 5, sub="sub " * 30, icon="file", value="val", chevron=True, wrap=True, action=lambda: None), + tai.GrrRowItem("Switch", toggle=True, on_toggle=lambda v: None), tai.GrrRowItem("Check", check=True, action=lambda: None)]), + tai.GrrConvItem(lambda u, bg: tai.GrrAvatar.rows(u, bot, bg, small=True), "Tai " * 10, "19:10", "preview " * 20, dot=True, tag="Group", action=lambda: None), + tai.GrrSectionHead("Bots", "plus", lambda: None), tai.GrrButtonItem("Go", lambda: None), tai.GrrButtonItem("Go wide " * 8, lambda: None, "secondary", inline=True, icon="plus"), + tai.GrrTextCard("text " * 80, action=lambda: None), tai.GrrTabsItem([{"id": "a", "label": "Info"}, {"id": "b", "label": "Documents"}, {"id": "c", "label": "Computer"}], "a", lambda i: None), + tai.GrrSegmentedItem([{"id": "a", "label": "Every"}, {"id": "b", "label": "Daily"}], "a", lambda i: None), + tai.GrrChipsItem([{"label": "Grr", "icon": "user", "action": lambda: None}, {"label": "Auto", "action": lambda: None}]), tai.GrrDividerItem("NEW", True), tai.GrrDividerItem("Today 10:00"), + tai.GrrEmptyItem("box", "Nothing here yet", "Sub text " * 5), tai.GrrKvItem([("Key", "value"), ("Long key " * 6, "v" * 30)]), tai.GrrProgressItem("Weekly", 87, "Resets", "foot"), + tai.GrrBarsItem([{"day": "2026-10-1%d" % n, "tokens": n * 10} for n in range(7)]), tai.GrrHeroItem(bot), + tai.GrrTileGridItem([{"label": "t%d" % n, "sub": "sub", "icon": "file"} for n in range(7)], tile_h=6), tai.GrrStripItem([dict(bot, id=str(n)) for n in range(6)], lambda b: None), + tai.GrrSwatchItem(["#ffffff", "#d4a020"], "#d4a020", lambda c: None), tai.GrrNameCard("Tai", "desc " * 20, "ph", lambda: None, lambda: None)] + for width in (12, 20, 33, 47, 48, 80, 140): + for item in items: + rows = item.rows(ui, width, False) + self.assertTrue(rows, type(item).__name__) + for row in rows: + self.assertEqual(tai.tui_span_width(row), width, "%s at %d: %r" % (type(item).__name__, width, "".join(s.text for s in row))) + rows = item.rows(ui, width, True) + for row in rows: + self.assertEqual(tai.tui_span_width(row), width, "%s selected at %d" % (type(item).__name__, width)) + + def test_up_and_down_move_the_selection_and_enter_runs_the_action(self): + self.items(tai.grr_group([tai.GrrRowItem("one", action=lambda: self.log.append("one")), tai.GrrRowItem("two", action=lambda: self.log.append("two"))])) + self.app.set_focus(self.page) + self.key("enter") + self.key("down") + self.key("enter") + self.key("up") + self.key("space", " ") + self.assertEqual(self.log, ["one", "two", "one"]) + + def test_a_switch_flips_and_reports_the_new_value(self): + row = tai.GrrRowItem("Switch", toggle=False, on_toggle=self.log.append) + self.items([row]) + self.app.set_focus(self.page) + self.key("enter") + self.key("enter") + self.assertEqual((self.log, row.toggle), ([True, False], False)) + + def test_left_and_right_walk_inside_an_item(self): + picked = [] + tabs = tai.GrrTabsItem([{"id": "a", "label": "A"}, {"id": "b", "label": "B"}, {"id": "c", "label": "C"}], "a", picked.append) + self.items([tabs]) + self.app.set_focus(self.page) + self.key("right") + self.key("right") + self.key("right") + self.key("left") + self.assertEqual(picked, ["b", "c", "b"]) + + def test_a_click_runs_the_action_of_the_row_under_the_pointer(self): + rows = tai.grr_group([tai.GrrRowItem("one", action=lambda: self.log.append("one")), tai.GrrRowItem("two", action=lambda: self.log.append("two"))]) + self.items(rows) + text = self.app.render_text().split("\n") + y_two = next(i for i, line in enumerate(text) if "two" in line) + self.click(10, y_two) + self.assertEqual(self.log, ["two"]) + + def test_a_click_on_a_registered_region_runs_it_not_the_item(self): + item = tai.GrrChipsItem([{"label": "First", "caret": False, "action": lambda: self.log.append("first")}, {"label": "Second", "caret": False, "action": lambda: self.log.append("second")}]) + self.items([item]) + line = self.app.render_text().split("\n")[0] + self.click(line.index("Second") + 1, 0) + self.assertEqual(self.log, ["second"]) + + def test_tabs_fold_into_one_dropdown_when_they_do_not_fit(self): + tabs = tai.GrrTabsItem([{"id": "a", "label": "Information"}, {"id": "b", "label": "Documents"}, {"id": "c", "label": "Computer"}, {"id": "d", "label": "Links"}], "b", lambda i: None) + wide = "".join(s.text for s in tabs.rows(self.ui, 80, False)[0]) + narrow = "".join(s.text for s in tabs.rows(self.ui, 30, False)[0]) + self.assertIn("Information", wide) + self.assertIn("Documents", narrow) + self.assertNotIn("Information", narrow) + + def test_scrolling_is_bounded_and_stick_follows_the_tail(self): + page = tai.GrrPage(self.ui, stick=True, auto_select=False, w="100%", h="100%", style=self.ui.st()) + self.app.mount(page) + self.app.layout() + page.set_items([tai.GrrNote("line %d" % n) for n in range(60)]) + self.app.layout() + self.assertIn("line 59", self.app.render_text()) + page.scroll_by(-1000) + self.assertEqual(page._top, 0) + self.assertFalse(page._follow) + page.scroll_by(100000) + self.assertTrue(page._follow) + self.assertIn("line 59", self.app.render_text()) + + def test_the_wheel_scrolls(self): + self.items([tai.GrrNote("line %d" % n) for n in range(80)]) + self.app.dispatch(tai.TuiScroll(x=5, y=5, direction="down", modifiers=[])) + self.assertEqual(self.page._top, 3) + + def test_selection_survives_items_that_have_no_key(self): + """A refresh that lands while the user is moving must not throw the + selection back to the first row (found by tests/grr_tui.py).""" + def build(): + return [tai.GrrRowItem("first", action=lambda: None), tai.GrrSectionHead("Bots", "plus", lambda: None), tai.GrrRowItem("last", action=lambda: None)] + self.items(build()) + self.page._sel = 1 + self.items(build()) + self.assertEqual(self.page._sel, 1) + self.items([tai.GrrRowItem("first", action=lambda: None)]) + self.assertEqual(self.page._sel, 0) + + def test_selection_survives_a_new_list_with_the_same_keys(self): + a = tai.GrrRowItem("a", action=lambda: None, key="a") + b = tai.GrrRowItem("b", action=lambda: None, key="b") + self.items([a, b]) + self.page._sel = 1 + self.items([tai.GrrRowItem("a", action=lambda: None, key="a"), tai.GrrRowItem("b", action=lambda: None, key="b")]) + self.assertEqual(self.page._sel, 1) + + def test_bar_parts_fill_and_click(self): + bar = tai.GrrBar(self.ui, w="100%", style=self.ui.st()) + self.app.mount(bar) + bar.set([{"text": " < ", "fn": lambda: self.log.append("back"), "bg": "card"}, {"text": "Title", "fill": True, "align": "center"}, {"text": " + ", "fn": lambda: self.log.append("plus")}]) + self.app.layout() + text = self.app.render_text().split("\n")[0] + self.assertEqual(len(text.rstrip()), len(text.rstrip())) + self.assertIn("Title", text) + self.click(2, 0) + self.click(text.index("+"), 0) + self.assertEqual(self.log, ["back", "plus"]) + self.assertIsNotNone(bar.last_fn()) + + def test_the_menu_runs_the_picked_action_and_escape_closes_it(self): + shell = tai.GrrShell(self.app, tai.GrrSession(os.path.join(tempfile.mkdtemp(), "g.json"))) + self.app.mount(shell.root) + shell.menu([{"label": "One", "action": lambda: self.log.append("one")}, "-", {"label": "Two", "danger": True, "action": lambda: self.log.append("two")}]) + self.app.layout() + self.assertIn("Two", self.app.render_text()) + self.key("down") + self.key("enter") + self.assertEqual(self.log, ["two"]) + self.assertIsNone(shell.menu_panel) + shell.menu([{"label": "One", "action": lambda: self.log.append("x")}]) + self.key("escape") + self.assertIsNone(shell.menu_panel) + self.assertEqual(self.log, ["two"]) + + def test_a_dialog_returns_the_action_and_the_typed_text(self): + shell = tai.GrrShell(self.app, tai.GrrSession(os.path.join(tempfile.mkdtemp(), "g.json"))) + self.app.mount(shell.root) + shell.dialog(title="Name", fields=[{"value": "ab"}], actions=[{"label": "Cancel", "value": "cancel"}, {"label": "Save", "value": "save", "primary": True}], + done=lambda value, texts: self.log.append((value, texts))) + self.app.layout() + for ch in "cd": + self.key(ch, ch) + self.key("enter") + self.assertEqual(self.log, [("save", ["abcd"])]) + shell.dialog(title="x", actions=[{"label": "OK", "value": 1}], done=lambda v, t: self.log.append(v)) + self.app.layout() + self.key("escape") + self.assertEqual(self.log[-1], None) + + def test_the_secret_box_never_shows_what_is_typed(self): + box = tai.GrrSecretBox("hunter2", h=1, w=20, border=False) + self.app.mount(box) + self.app.layout() + text = self.app.render_text() + self.assertNotIn("hunter2", text) + self.assertIn("•••••••", text) + self.assertEqual(box.value, "hunter2") + + +class GrrShellTests(GrrThemeIsolation, unittest.TestCase): + """The whole client against the fake server, headless: boot, every route + at phone, terminal and wide sizes, and the chat conversation.""" + + @classmethod + def setUpClass(cls): + cls.server = GrrFakeServer() + + @classmethod + def tearDownClass(cls): + cls.server.close() + + def make(self, cols=120, rows=40, signed_in=True): + self.server.calls.clear() + self.home = tempfile.mkdtemp() + session = tai.GrrSession(os.path.join(self.home, "grr.json")) + session.url = self.server.url + if signed_in: + session.accounts = [{"token": "tok", "username": "retoor", "email": "r@x"}] + session.active = "tok" + app = tai.GrrTuiApp() + app.rows, app.cols = rows, cols + shell = tai.GrrShell(app, session) + shell.client.base = self.server.url + app.mount(shell.root) + with mock.patch.object(tai.GrrStream, "start", lambda self: None): + shell.boot() + return app, shell + + def frame(self, app): + app.layout() + return app.render_text() + + def go(self, shell, app, path): + shell.router.go(path) + return self.frame(app) + + def test_boot_signs_in_and_draws_the_drawer(self): + app, shell = self.make() + text = self.frame(app) + self.assertTrue(shell.signed_in) + for word in ("retoor", "Automations", "Library", "Projects", "Tai", "Smile", "Search"): + self.assertIn(word, text) + self.assertIn("Bots that deliver.", text) + + def test_without_a_token_it_shows_the_login_form_and_signs_in(self): + app, shell = self.make(signed_in=False) + text = self.frame(app) + self.assertIn("Sign in", text) + view = shell.view + view.user.lines = ["me"] + view.passw.lines = ["pw"] + view.go(False) + self.assertEqual(shell.session.token, "tok-me") + self.assertTrue(shell.signed_in) + login = self.server.seen("POST", "/api/auth/login")[0] + self.assertEqual(login[2], {"username": "me", "password": "pw"}) + + def test_an_unreachable_server_shows_a_way_forward(self): + server = GrrFakeServer() + url = server.url + server.close() + session = tai.GrrSession(os.path.join(tempfile.mkdtemp(), "g.json")) + session.url = url + app = tai.GrrTuiApp() + app.rows, app.cols = 30, 90 + shell = tai.GrrShell(app, session) + shell.client.base = url + app.mount(shell.root) + shell.boot() + text = self.frame(app) + self.assertIn("cannot reach its server", text) + self.assertIn("Try again", text) + self.assertIn(url, text) + + def test_every_route_draws_at_every_size_without_overflow(self): + paths = ["/bots", "/bot/b1", "/bot/b1?tab=documents", "/bot/b1?tab=computer", "/bot/b1?tab=links", "/bot/b1/instructions", "/bot/b1/memories", "/memory/m1", "/bot/b1/skills", + "/skill/s1", "/bot/b1/screen", "/chat/c1", "/chat/c3", "/chat/c4", "/chat/new", "/automations", "/automations/new", "/automation/t1", "/library", "/projects", "/project/p1", + "/search", "/settings", "/settings/account", "/settings/usage", "/settings/rules", "/settings/rule/r1", "/settings/rule/new", "/settings/appearance", "/settings/language", + "/settings/haptics", "/settings/widget", "/settings/computer", "/settings/connectors", "/settings/advanced", "/settings/builder", "/settings/feedback", "/settings/timezone", + "/template/t9", "/template/t9/instructions", "/template/t9/memories", "/template/t9/skills"] + for cols, rows in ((30, 20), (56, 30), (100, 30), (160, 40)): + app, shell = self.make(cols, rows) + for path in paths: + text = self.go(shell, app, path) + self.assertTrue(text.strip(), path) + for line in text.split("\n"): + self.assertLessEqual(len(line), cols, "%s at %d wide: %r" % (path, cols, line)) + + def test_narrow_screens_show_one_pane_at_a_time(self): + app, shell = self.make(60, 30) + self.assertTrue(shell.side.visible) + self.assertFalse(shell.main.visible) + self.go(shell, app, "/settings") + self.assertFalse(shell.side.visible) + self.assertTrue(shell.main.visible) + app, shell = self.make(140, 30) + self.assertTrue(shell.side.visible and shell.main.visible) + + def test_the_chat_shows_every_kind_of_message(self): + app, shell = self.make(130, 70) + text = self.go(shell, app, "/chat/c1") + for word in ("Run echo hello", "echo hello", "hello", "Tai needs your approval", "Allow once", "Deny", "Which one?", "Red", "Blue", "report.txt", "It printed", "code here", "Steps (1)"): + self.assertIn(word, text) + + def test_sending_posts_the_text_and_the_echo_appears(self): + app, shell = self.make(120, 40) + self.go(shell, app, "/chat/c1") + view = shell.view + view.box.lines = ["hello there"] + view.on_send() + post = self.server.seen("POST", "/api/chats/c1/messages")[0] + self.assertEqual(post[2]["text"], "hello there") + self.assertIn("hello there", self.frame(app)) + self.assertEqual(view.box.value, "") + self.assertEqual(view.send_mode, "stop") + + def test_a_live_message_lands_in_the_open_chat_and_working_status_shows(self): + app, shell = self.make(120, 40) + self.go(shell, app, "/chat/c1") + shell.on_event("message.new", {"id": 50, "chat_id": "c1", "role": "assistant", "kind": "text", "sender": "b1", "content": "fresh reply", "meta": {}, "ts": time.time()}) + shell.on_event("message.new", {"id": 51, "chat_id": "c2", "role": "assistant", "kind": "text", "sender": "b2", "content": "other chat", "meta": {}, "ts": time.time()}) + text = self.frame(app) + self.assertIn("fresh reply", text) + self.assertNotIn("other chat", text) + shell.on_event("status", {"chat_id": "c1", "bot_id": "b1", "state": "working", "label": "reading files"}) + shell.view.tick() + self.assertIn("Tai is working", self.frame(app)) + self.assertIn("reading files", self.frame(app)) + shell.on_event("status", {"chat_id": "c1", "bot_id": "b1", "state": "idle"}) + shell.view.tick() + self.assertNotIn("Tai is working", self.frame(app)) + + def test_approval_and_question_buttons_post_their_decision(self): + app, shell = self.make(120, 70) + self.go(shell, app, "/chat/c1") + approval = shell.view.items[3] + approval.controls[0][1]() + self.assertEqual(self.server.seen("POST", "/api/messages/3/decision")[0][2], {"decision": "allow"}) + approval.controls[1][1]() + self.assertEqual(self.server.seen("POST", "/api/messages/3/decision")[1][2], {"decision": "deny"}) + question = shell.view.items[4] + question.controls[1][1]() + self.assertEqual(self.server.seen("POST", "/api/messages/4/answer")[0][2], {"text": "Blue"}) + + def test_a_pending_approval_in_another_state_has_no_buttons(self): + app, shell = self.make(120, 70) + self.go(shell, app, "/chat/c1") + shell.on_event("message.update", dict(GRR_FAKE_MESSAGES[2], meta={"state": "allowed", "tool": "shell"})) + text = self.frame(app) + self.assertNotIn("Allow once", text) + self.assertIn("Allowed", text) + + def test_a_new_chat_is_created_by_the_first_send(self): + app, shell = self.make(120, 40) + self.go(shell, app, "/chat/new") + view = shell.view + view.box.lines = ["first"] + view.on_send() + created = self.server.seen("POST", "/api/chats") + self.assertEqual(created[0][2]["kind"], "general") + self.assertEqual(shell.router.current, "/chat/new") + + def test_settings_switches_patch_the_account(self): + app, shell = self.make(120, 60) + self.go(shell, app, "/settings") + row = next(i for i in shell.view.screen.page.items if getattr(i, "title", "") == "settings.auto_review" or getattr(i, "toggle", None) is not None) + row.activate() + patch = self.server.seen("PATCH", "/api/me/settings")[0] + self.assertEqual(patch[2], {"auto_review": False}) + + def test_errors_become_a_toast_in_the_users_words(self): + app, shell = self.make(120, 40) + self.server.fail["/api/routines/t1"] = (404, {"error": {"code": "not_found"}}) + try: + self.go(shell, app, "/automation/t1") + self.assertIn("That is gone, champ.", self.frame(app)) + finally: + del self.server.fail["/api/routines/t1"] + + def test_a_group_needs_two_bots_and_posts_the_picked_ones(self): + app, shell = self.make(120, 40) + tai.GrrBots.new_group(shell) + dlg = shell.dialogs[0] + rows = dlg.grr_page.items + rows[0].activate() + rows[1].activate() + dlg.grr_fields[0].lines = ["Pair"] + shell.dialog_pick(dlg, {"value": "create"}) + post = self.server.seen("POST", "/api/chats")[0][2] + self.assertEqual((post["kind"], post["bot_ids"], post["title"]), ("group", ["b1", "b2"], "Pair")) + + def test_the_schedule_picker_enforces_the_floor(self): + app, shell = self.make(120, 40) + out = [] + tai.grr_schedule_dialog(shell, None, out.append) + dlg = shell.dialogs[0] + dlg.grr_fields[0].lines = ["0.1"] + shell.dialog_pick(dlg, {"value": "done"}) + self.assertEqual(out, []) + self.assertTrue(shell.toasts) + tai.grr_schedule_dialog(shell, None, out.append) + dlg = shell.dialogs[-1] + dlg.grr_fields[0].lines = ["15"] + shell.dialog_pick(dlg, {"value": "done"}) + self.assertEqual(out, [{"kind": "interval", "every_seconds": 900}]) + + def test_theme_settings_restyle_the_screen(self): + app, shell = self.make(120, 40) + shell.settings["theme"] = "day" + shell.apply_settings() + self.assertEqual(shell.palette.mode, "day") + vt = tai.TuiVtScreen(40, 120) + app.layout() + vt.feed(app.frame_bytes().encode("utf-8")) + self.assertIn("246;241;230", vt.grid[30][60][2]) + + def test_a_forced_sign_out_returns_to_the_login_form(self): + app, shell = self.make(120, 40) + shell.expired() + self.assertFalse(shell.signed_in) + self.assertIn("Sign in", self.frame(app)) + self.assertEqual(shell.session.accounts, []) + + def test_drafts_survive_leaving_a_chat(self): + app, shell = self.make(120, 40) + self.go(shell, app, "/chat/c1") + shell.view.box.lines = ["half a thought"] + self.go(shell, app, "/settings") + self.assertEqual(shell.drafts["c1"], "half a thought") + self.go(shell, app, "/chat/c1") + self.assertEqual(shell.view.box.value, "half a thought") + + def test_a_running_command_card_follows_the_servers_byte_stream(self): + import base64 as b64 + self.server.streams["/api/processes/p9/stream"] = [("out", {"d": b64.b64encode(b"live line one\r\nsecond\r\n").decode()}), ("exit", {})] + running = {"id": 7, "chat_id": "c2", "role": "assistant", "kind": "process", "sender": "b2", "content": "tail -f log", "meta": {"pid": "p9", "state": "running", "cols": 80, "rows": 24}, "ts": 1792257000.0} + self.server.get["/api/chats/c2/messages"] = {"messages": [running], "has_more": False} + try: + app, shell = self.make(120, 40) + self.go(shell, app, "/chat/c2") + item = shell.view.items[7] + deadline = time.time() + 5 + while time.time() < deadline and "second" not in "".join("".join(c[0] if c else " " for c in row) for row in item.screen.grid): + time.sleep(0.05) + text = self.frame(app) + self.assertIn("live line one", text) + self.assertIn("second", text) + self.assertIn("^C", text) + item.update(dict(running, meta=dict(running["meta"], state="exited", exit=0))) + self.assertNotIn("^C", self.frame(app)) + finally: + self.server.get.pop("/api/chats/c2/messages", None) + + def test_the_computer_terminal_sends_keys_and_leaves_on_ctrl_bracket(self): + app, shell = self.make(120, 40) + left = [] + term = tai.GrrRemoteTerminal(shell, "/api/bots/b1/computer/stream", "/api/bots/b1/computer/input", w="100%", h="100%") + term.on_leave = lambda: left.append(1) + app.mount(term) + app.layout() + for char in "ls": + term.on_key(tai.TuiKey(key=char, char=char)) + term.on_key(tai.TuiKey(key="enter", char="\n")) + term.flush() + sent = [c[2]["data"] for c in self.server.seen("POST", "/api/bots/b1/computer/input") if "data" in c[2]] + self.assertEqual("".join(sent), "ls\r") + term.on_key(tai.TuiKey(key="ctrl+}", char="\x1d")) + self.assertEqual(left, [1]) + term.on_event("out", {"d": "aGVsbG8gYm94DQo="}) + self.assertIn("hello box", app.render_text() if False else "".join("".join(c[0] if c else " " for c in row) for row in term.screen.grid)) + + def test_the_pane_shows_files_and_preview_of_a_bot_chat(self): + app, shell = self.make(170, 40) + self.go(shell, app, "/chat/c1") + view = shell.view + view.pane.show("files") + text = self.frame(app) + for word in ("src", "a.py"): + self.assertIn(word, text) + view.pane.pick("preview") + self.assertIn("python", self.frame(app) + str([i.title for i in view.pane.page.items if hasattr(i, "title")])) + view.pane.hide() + self.assertFalse(view.pane.is_open) + + +class GrrSpecTests(unittest.TestCase): + """grrtui.md is the specification; it must list exactly what the code has.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "grrtui.md"), "r", encoding="utf-8") as handle: + cls.doc = handle.read() + + def section(self, heading): + start = self.doc.index("\n" + heading + "\n") + rest = self.doc[start + len(heading) + 2:] + end = re.search(r"\n#{1,3} ", rest) + return rest[:end.start()] if end else rest + + def test_the_route_table_matches_the_code(self): + rows = re.findall(r"^\| `([^`]+)` \| `([^`]+)` \|", self.section("## Screens"), re.MULTILINE) + self.assertEqual(rows, [(pattern, name) for pattern, name in tai.GRR_ROUTES]) + + def test_every_route_class_exists_and_is_a_view(self): + for _pattern, name in tai.GRR_ROUTES: + self.assertTrue(issubclass(getattr(tai, name), tai.GrrView), name) + + def test_the_key_table_matches_the_code(self): + rows = re.findall(r"^\| `([^`]+)` \| (.+?) \|$", self.section("## Keys"), re.MULTILINE) + self.assertEqual(rows, [(key, action) for key, action in tai.GRR_KEYS]) + + def test_every_cap_is_documented_with_its_value(self): + text = self.section("## Caps") + names = sorted(n for n in dir(tai) if n.startswith("GRR_") and isinstance(getattr(tai, n), (int, float)) and not isinstance(getattr(tai, n), bool)) + documented = dict(re.findall(r"^\| `(GRR_[A-Z_]+)` \| ([0-9.]+) \|", text, re.MULTILINE)) + self.assertEqual(sorted(documented), names) + for name in names: + self.assertEqual(float(documented[name]), float(getattr(tai, name)), name) + + def test_the_shapes_and_events_are_named(self): + for shape in tai.GRR_SHAPES: + self.assertIn("`%s`" % shape, self.doc) + for name in tai.GrrShell.PASS: + self.assertIn("`%s`" % name, self.doc) + + def test_the_flags_are_documented(self): + self.assertIn("--tui-local", self.doc) + self.assertIn("GRR_URL", self.doc) + if __name__ == "__main__": unittest.main() diff --git a/tests/grr_tui.py b/tests/grr_tui.py new file mode 100644 index 0000000..130f6ff --- /dev/null +++ b/tests/grr_tui.py @@ -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"))) diff --git a/tui.md b/tui.md index 58a0c78..f3dcb11 100644 --- a/tui.md +++ b/tui.md @@ -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