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