From bbdc927215a94a6e820138f85a58908fadb746da Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 06:15:05 +0100 Subject: [PATCH] tui: grr terminal client (Grr* classes), control socket, entry points, spec test, parity docs Port of the grr client of tai.py: client, SSE stream, session, store, router, shell, views, chat, panes, file viewer and the unix control socket; GrrTui::run is the --tui entry. Verified against tai.py --tui on a real grr server over a pty: 236 steps, screen text and per-cell styles identical except clocks, spinner frames and server-made ids. docs/tui.md holds the symbol table and gaps, docs/wiring/tui.md the integrator hooks. Co-Authored-By: Claude Sonnet 5.5 --- taicpp/docs/tui.md | 438 ++++++++++ taicpp/docs/wiring/tui.md | 115 +++ taicpp/src/tui/GrrAvatar.cpp | 75 ++ taicpp/src/tui/GrrAvatar.h | 24 + taicpp/src/tui/GrrBar.cpp | 73 ++ taicpp/src/tui/GrrBar.h | 36 + taicpp/src/tui/GrrChat.cpp | 1232 +++++++++++++++++++++++++++ taicpp/src/tui/GrrChat.h | 156 ++++ taicpp/src/tui/GrrChatItems.cpp | 747 ++++++++++++++++ taicpp/src/tui/GrrChatItems.h | 167 ++++ taicpp/src/tui/GrrClient.cpp | 244 ++++++ taicpp/src/tui/GrrClient.h | 62 ++ taicpp/src/tui/GrrConst.cpp | 138 +++ taicpp/src/tui/GrrConst.h | 58 ++ taicpp/src/tui/GrrControl.cpp | 408 +++++++++ taicpp/src/tui/GrrControl.h | 85 ++ taicpp/src/tui/GrrDialog.cpp | 13 + taicpp/src/tui/GrrDialog.h | 50 ++ taicpp/src/tui/GrrEmitter.cpp | 29 + taicpp/src/tui/GrrEmitter.h | 24 + taicpp/src/tui/GrrError.cpp | 20 + taicpp/src/tui/GrrError.h | 26 + taicpp/src/tui/GrrFileViewer.cpp | 117 +++ taicpp/src/tui/GrrFileViewer.h | 22 + taicpp/src/tui/GrrFormat.cpp | 168 ++++ taicpp/src/tui/GrrFormat.h | 33 + taicpp/src/tui/GrrHome.cpp | 299 +++++++ taicpp/src/tui/GrrHome.h | 39 + taicpp/src/tui/GrrItem.cpp | 73 ++ taicpp/src/tui/GrrItem.h | 67 ++ taicpp/src/tui/GrrItems.cpp | 758 ++++++++++++++++ taicpp/src/tui/GrrItems.h | 340 ++++++++ taicpp/src/tui/GrrMenu.cpp | 185 ++++ taicpp/src/tui/GrrMenu.h | 52 ++ taicpp/src/tui/GrrPage.cpp | 253 ++++++ taicpp/src/tui/GrrPage.h | 54 ++ taicpp/src/tui/GrrPalette.cpp | 81 ++ taicpp/src/tui/GrrPalette.h | 28 + taicpp/src/tui/GrrPane.cpp | 559 ++++++++++++ taicpp/src/tui/GrrPane.h | 124 +++ taicpp/src/tui/GrrRouter.cpp | 114 +++ taicpp/src/tui/GrrRouter.h | 42 + taicpp/src/tui/GrrScreen.cpp | 77 ++ taicpp/src/tui/GrrScreen.h | 62 ++ taicpp/src/tui/GrrSecretBox.cpp | 34 + taicpp/src/tui/GrrSecretBox.h | 13 + taicpp/src/tui/GrrSession.cpp | 98 +++ taicpp/src/tui/GrrSession.h | 27 + taicpp/src/tui/GrrShell.cpp | 838 ++++++++++++++++++ taicpp/src/tui/GrrShell.h | 134 +++ taicpp/src/tui/GrrStore.cpp | 79 ++ taicpp/src/tui/GrrStore.h | 32 + taicpp/src/tui/GrrStream.cpp | 170 ++++ taicpp/src/tui/GrrStream.h | 47 + taicpp/src/tui/GrrStrings.cpp | 130 +++ taicpp/src/tui/GrrStrings.h | 33 + taicpp/src/tui/GrrText.cpp | 43 + taicpp/src/tui/GrrText.h | 23 + taicpp/src/tui/GrrTui.cpp | 35 + taicpp/src/tui/GrrTui.h | 13 + taicpp/src/tui/GrrTuiApp.cpp | 17 + taicpp/src/tui/GrrTuiApp.h | 27 + taicpp/src/tui/GrrUi.cpp | 28 + taicpp/src/tui/GrrUi.h | 38 + taicpp/src/tui/GrrView.cpp | 84 ++ taicpp/src/tui/GrrView.h | 52 ++ taicpp/src/tui/GrrViews.cpp | 63 ++ taicpp/src/tui/GrrViewsAuto.cpp | 433 ++++++++++ taicpp/src/tui/GrrViewsAuto.h | 52 ++ taicpp/src/tui/GrrViewsBots.cpp | 817 ++++++++++++++++++ taicpp/src/tui/GrrViewsBots.h | 145 ++++ taicpp/src/tui/GrrViewsLib.cpp | 514 +++++++++++ taicpp/src/tui/GrrViewsLib.h | 73 ++ taicpp/src/tui/GrrViewsSettings.cpp | 1106 ++++++++++++++++++++++++ taicpp/src/tui/GrrViewsSettings.h | 122 +++ taicpp/src/tui/TuiApp.cpp | 49 +- taicpp/src/tui/TuiApp.h | 14 +- taicpp/src/tui/TuiChatScene.cpp | 4 +- taicpp/src/tui/TuiMarkdown.cpp | 49 +- taicpp/src/tui/TuiTerminal.cpp | 5 +- taicpp/src/tui/TuiTerminal.h | 16 +- taicpp/src/tui/TuiTextBox.cpp | 1 + taicpp/src/tui/TuiTextBox.h | 1 + taicpp/src/tui/TuiTextWrap.cpp | 147 ++++ taicpp/src/tui/TuiTextWrap.h | 29 + taicpp/src/tui/TuiValue.h | 11 + taicpp/src/tui/tui.cpp | 5 - taicpp/src/tui/tui.h | 18 +- taicpp/src/tui/tui.mk | 3 +- taicpp/tests/tui_grr.cpp | 6 + taicpp/tests/tui_spec.cpp | 103 +++ 91 files changed, 13457 insertions(+), 91 deletions(-) create mode 100644 taicpp/docs/tui.md create mode 100644 taicpp/docs/wiring/tui.md create mode 100644 taicpp/src/tui/GrrAvatar.cpp create mode 100644 taicpp/src/tui/GrrAvatar.h create mode 100644 taicpp/src/tui/GrrBar.cpp create mode 100644 taicpp/src/tui/GrrBar.h create mode 100644 taicpp/src/tui/GrrChat.cpp create mode 100644 taicpp/src/tui/GrrChat.h create mode 100644 taicpp/src/tui/GrrChatItems.cpp create mode 100644 taicpp/src/tui/GrrChatItems.h create mode 100644 taicpp/src/tui/GrrClient.cpp create mode 100644 taicpp/src/tui/GrrClient.h create mode 100644 taicpp/src/tui/GrrConst.cpp create mode 100644 taicpp/src/tui/GrrConst.h create mode 100644 taicpp/src/tui/GrrControl.cpp create mode 100644 taicpp/src/tui/GrrControl.h create mode 100644 taicpp/src/tui/GrrDialog.cpp create mode 100644 taicpp/src/tui/GrrDialog.h create mode 100644 taicpp/src/tui/GrrEmitter.cpp create mode 100644 taicpp/src/tui/GrrEmitter.h create mode 100644 taicpp/src/tui/GrrError.cpp create mode 100644 taicpp/src/tui/GrrError.h create mode 100644 taicpp/src/tui/GrrFileViewer.cpp create mode 100644 taicpp/src/tui/GrrFileViewer.h create mode 100644 taicpp/src/tui/GrrFormat.cpp create mode 100644 taicpp/src/tui/GrrFormat.h create mode 100644 taicpp/src/tui/GrrHome.cpp create mode 100644 taicpp/src/tui/GrrHome.h create mode 100644 taicpp/src/tui/GrrItem.cpp create mode 100644 taicpp/src/tui/GrrItem.h create mode 100644 taicpp/src/tui/GrrItems.cpp create mode 100644 taicpp/src/tui/GrrItems.h create mode 100644 taicpp/src/tui/GrrMenu.cpp create mode 100644 taicpp/src/tui/GrrMenu.h create mode 100644 taicpp/src/tui/GrrPage.cpp create mode 100644 taicpp/src/tui/GrrPage.h create mode 100644 taicpp/src/tui/GrrPalette.cpp create mode 100644 taicpp/src/tui/GrrPalette.h create mode 100644 taicpp/src/tui/GrrPane.cpp create mode 100644 taicpp/src/tui/GrrPane.h create mode 100644 taicpp/src/tui/GrrRouter.cpp create mode 100644 taicpp/src/tui/GrrRouter.h create mode 100644 taicpp/src/tui/GrrScreen.cpp create mode 100644 taicpp/src/tui/GrrScreen.h create mode 100644 taicpp/src/tui/GrrSecretBox.cpp create mode 100644 taicpp/src/tui/GrrSecretBox.h create mode 100644 taicpp/src/tui/GrrSession.cpp create mode 100644 taicpp/src/tui/GrrSession.h create mode 100644 taicpp/src/tui/GrrShell.cpp create mode 100644 taicpp/src/tui/GrrShell.h create mode 100644 taicpp/src/tui/GrrStore.cpp create mode 100644 taicpp/src/tui/GrrStore.h create mode 100644 taicpp/src/tui/GrrStream.cpp create mode 100644 taicpp/src/tui/GrrStream.h create mode 100644 taicpp/src/tui/GrrStrings.cpp create mode 100644 taicpp/src/tui/GrrStrings.h create mode 100644 taicpp/src/tui/GrrText.cpp create mode 100644 taicpp/src/tui/GrrText.h create mode 100644 taicpp/src/tui/GrrTui.cpp create mode 100644 taicpp/src/tui/GrrTui.h create mode 100644 taicpp/src/tui/GrrTuiApp.cpp create mode 100644 taicpp/src/tui/GrrTuiApp.h create mode 100644 taicpp/src/tui/GrrUi.cpp create mode 100644 taicpp/src/tui/GrrUi.h create mode 100644 taicpp/src/tui/GrrView.cpp create mode 100644 taicpp/src/tui/GrrView.h create mode 100644 taicpp/src/tui/GrrViews.cpp create mode 100644 taicpp/src/tui/GrrViewsAuto.cpp create mode 100644 taicpp/src/tui/GrrViewsAuto.h create mode 100644 taicpp/src/tui/GrrViewsBots.cpp create mode 100644 taicpp/src/tui/GrrViewsBots.h create mode 100644 taicpp/src/tui/GrrViewsLib.cpp create mode 100644 taicpp/src/tui/GrrViewsLib.h create mode 100644 taicpp/src/tui/GrrViewsSettings.cpp create mode 100644 taicpp/src/tui/GrrViewsSettings.h create mode 100644 taicpp/src/tui/TuiTextWrap.cpp create mode 100644 taicpp/src/tui/TuiTextWrap.h delete mode 100644 taicpp/src/tui/tui.cpp create mode 100644 taicpp/tests/tui_grr.cpp create mode 100644 taicpp/tests/tui_spec.cpp diff --git a/taicpp/docs/tui.md b/taicpp/docs/tui.md new file mode 100644 index 0000000..4c1c879 --- /dev/null +++ b/taicpp/docs/tui.md @@ -0,0 +1,438 @@ +# tui parity (tai.py -> taicpp, `src/tui`) + +Scope: every `Tui*` class and `tui_*` function of tai.py (framework, VT screen, +widgets, themes, chat scene, live block, keys, mouse) and every `Grr*` class and +`grr_*` function (the grr terminal client). Specs the code follows: +`../tui.md`, `../grrtui.md` (tables copied into tests, see below). The grr client +is a client of the grr server: it shows what `/api/spec` and `/locales.json` say, +no server string, color or limit is copied into the code (`GrrConst.h` holds only +what tai.py itself carries as fallback). + +## Rules of the port + +- Python class `TuiFoo` is C++ class `detz::tui::TuiFoo` in `TuiFoo.h` / `TuiFoo.cpp`. + Small related classes share a file pair and are named in its header comment. +- Module functions become static methods of the class that owns the data + (`tui_resolve_theme_name` is `TuiThemes::resolve_name`, `tui_demo` is `TuiDemo::demo`). +- Module constants become `constexpr` or static functions; names are kept where C++ allows. +- Text is `UStr` (`std::u32string`) so column math is by code point like python. + `TuiValue` is the dynamic JSON-like value (python dict/list/str/float formatting). +- No behaviour was invented: where python has a quirk the port has the same quirk. + +## Declared differences (all invisible on screen) + +| python | C++ | why | +|---|---|---| +| `__setattr__` change signals (`TuiObserved`, `TuiList`, `TuiDict`, `TuiSet`) | explicit setters call `relayout`, `restack` or `touch` | C++ has no attribute hook. A field written without its setter does not repaint; every field python watches has a setter | +| per-style cell pools (`TuiCellPool`) | cells are `(char32_t, interned style id)` | perf only, equal cells still compare by two integers | +| asyncio loop | one `poll()` loop with a self-pipe wake, timers and `offload` threads | same ordering contract: offload `work` runs on a thread, `done(result, error)` on the loop | +| `TuiTerminal` row memo | not kept | perf only | +| python exceptions in `offload` | `std::exception_ptr`, text through `TuiApp::describe` | same message as `str(exc)` | +| `http.client` (`GrrClient`, `GrrStream`) | libcurl | the repo already links libcurl; error texts for transport failures are mapped by hand (see gaps) | +| agent / store / vault used by the local chat scene | interfaces `TuiChatAgent`, `TuiChatHost`, `TuiHistoryBackend`, `TuiComplete::Sources` | the agent module is another port; the integrator writes the adapters (`wiring/tui.md`) | +| `GrrControl` threads | one accept thread, one thread per connection, jobs run on the UI loop | same protocol, same caps | + +## Verification (what was run, not what is believed) + +All differential runs compare the C++ binary with `tai.py` on the same input. + +| what | how | result | +|---|---|---| +| `--demo-shot` frame | text, per-cell sgr and raw frame bytes at 30x100, 24x80, 10x30, 50x160, 8x20 | identical | +| framework scripts | 9 scenarios (keys, mouse, prompt and autocomplete, resize, spawn, shell feed, paste, kitty keys) at 30x100 and 12x40 | identical | +| chat rows, markdown, textwrap | 3318 cases, 65553 output lines, every span as `text` | identical | +| local chat scene | 10 scenarios (send, commands, keys, bots, resize, history, completion, scroll, multiline, quit) at 30x100 and 14x50, a fake agent on both sides | identical | +| grr client against a real grr server | both binaries on a pty, driven by the control socket (`screen`, `key`, `type`, `click`, `goto`), server restored from one snapshot before each run | see below | +| grr spec drift | `tests/tui_spec.cpp` (routes, keys, caps, shapes, events, flags against `grrtui.md`) | ok | + +Grr runs (screen text of every step plus per-cell fg, bg and attributes of the +pty stream replayed through python's `TuiVtScreen`, minute digits ignored): + +| steps | size | text diffs | style diffs | +|---|---|---|---| +| 57 (every bot, computer, screen, chat route) | 30x100 | 0 | 0 | +| 128 (keys, mouse, dialogs, settings, language, composer) | 30x100, 24x80, 14x50 | 0 | 0 | +| 128 | 40x160 | 1 (spinner frame) | 2 steps (spinner frame) | +| 51 (pane tabs, composer, send, remote terminal) | 30x100 | 0 | 0 | +| 14 login screen, 14 offline screen | 30x100 | 0 | 0 | + +The only differences ever seen were a wall clock, the database size shown in +Advanced, a chat title that the server generates after the fact, a spinner frame, +and ids the server creates. Each was traced to the environment, none to the port. + +## Symbol table + +344 python symbols (classes, `tui_*` / `grr_*` functions, `TUI_*` / `GRR_*` constants) +and where they live. Generated by matching names against `src/tui`; a hit means the +name is defined or used there, not that the behaviour is proven (that is the section above). + +| kind | python | line in tai.py | C++ | +|---|---|---|---| +| class | `TuiTheme` | 22227 | `TuiTheme.h` | +| class | `TuiWatched` | 22327 | `TuiStyle.h` | +| class | `TuiStyle` | 22369 | `TuiStyle.h` | +| class | `TuiSpan` | 22490 | `TuiStyle.h` | +| class | `TuiEvent` | 22517 | `TuiEvent.h` | +| class | `TuiKey` | 22536 | `GrrControl.h`, `TuiEvent.h` | +| class | `TuiClick` | 22540 | `TuiEvent.h` | +| class | `TuiScroll` | 22544 | `TuiEvent.h` | +| class | `TuiFocus` | 22548 | `TuiEvent.h` | +| class | `TuiBlur` | 22553 | `TuiEvent.h` | +| class | `TuiResize` | 22558 | `TuiEvent.h` | +| class | `TuiTick` | 22563 | `TuiEvent.h` | +| class | `TuiModeReply` | 22568 | `TuiEvent.h` | +| class | `TuiMount` | 22576 | `TuiEvent.h` | +| class | `TuiUnmount` | 22581 | `TuiEvent.h` | +| class | `TuiSelect` | 22586 | `TuiEvent.h` | +| class | `TuiPress` | 22590 | `TuiEvent.h` | +| class | `TuiClose` | 22594 | `TuiEvent.h` | +| class | `TuiPaste` | 22598 | `TuiEvent.h` | +| class | `TuiChange` | 22602 | `TuiEvent.h` | +| class | `TuiObserved` | 22702 | replaced by explicit setters (relayout, restack, touch) and `TuiWatched` in `TuiStyle.h` | +| class | `TuiList` | 22739 | replaced by explicit setters (see TuiObserved) | +| class | `TuiDict` | 22774 | replaced by explicit setters (see TuiObserved) | +| class | `TuiSet` | 22803 | replaced by explicit setters (see TuiObserved) | +| class | `TuiPanel` | 22863 | `TuiEvent.h`, `TuiPanel.h`, `TuiStyle.h` | +| class | `TuiRows` | 23552 | `TuiPanel.h` | +| class | `TuiCols` | 23562 | `TuiPanel.h` | +| class | `TuiCellPool` | 23618 | omitted on purpose: cells are `(char32_t, style id)` values, equal cells compare as two integers; perf only | +| class | `TuiCompositor` | 23633 | `TuiCompositor.h`, `TuiPanel.h` | +| class | `TuiHitMap` | 24053 | `TuiHitMap.h` | +| class | `TuiInput` | 24383 | `TuiInput.h` | +| class | `TuiVtScreen` | 24423 | `TuiVtScreen.h` | +| class | `TuiText` | 25066 | `TuiText.h` | +| class | `TuiRich` | 25123 | `TuiRich.h` | +| class | `TuiButton` | 25164 | `TuiButton.h` | +| class | `TuiOptions` | 25216 | `TuiOptions.h` | +| class | `TuiChatList` | 25356 | `TuiChatList.h` | +| class | `TuiAutocomplete` | 25429 | `TuiAutocomplete.h` | +| class | `TuiHistory` | 25527 | `TuiHistory.h` | +| class | `TuiTextBox` | 25589 | `TuiTextBox.h` | +| class | `TuiDialog` | 25945 | `TuiDialog.h` | +| class | `TuiQuestion` | 25978 | `TuiDialog.h` | +| class | `TuiPromptDialog` | 26007 | `TuiDialog.h` | +| class | `TuiProgress` | 26027 | `TuiProgress.h` | +| class | `TuiLog` | 26050 | `TuiLog.h` | +| class | `TuiTerminal` | 26199 | `TuiApp.h`, `TuiTerminal.h` | +| class | `TuiError` | 26406 | `TuiApp.h` | +| class | `TuiDoubleTap` | 26410 | `TuiDoubleTap.h` | +| class | `TuiApp` | 26429 | `TuiApp.cpp`, `TuiApp.h`, `TuiPanel.h` | +| class | `TuiChatLog` | 27959 | `TuiChatLog.h` | +| class | `TuiChatSession` | 28022 | `TuiChatSession.h` | +| class | `TuiChatState` | 28043 | members of `TuiChatScene` plus `TuiChatSession.h` | +| class | `TuiLiveStream` | 28071 | `TuiLiveStream.h` | +| class | `TuiStdioGuard` | 28095 | `TuiStdioGuard.h` | +| class | `GrrError` | 28938 | `GrrError.h` | +| class | `GrrStrings` | 28956 | `GrrStrings.h` | +| class | `GrrPalette` | 29018 | `GrrPalette.h` | +| class | `GrrUi` | 29071 | `GrrUi.h` | +| class | `GrrFormat` | 29103 | `GrrFormat.h` | +| class | `GrrAvatar` | 29205 | `GrrAvatar.h` | +| class | `GrrClient` | 29259 | `GrrClient.h` | +| class | `GrrStream` | 29353 | `GrrStream.h` | +| class | `GrrSession` | 29453 | `GrrSession.h` | +| class | `GrrEmitter` | 29509 | `GrrEmitter.h` | +| class | `GrrStore` | 29533 | `GrrStore.h` | +| class | `GrrRoute` | 29605 | `GrrRouter.h` | +| class | `GrrRouter` | 29615 | `GrrRouter.h` | +| class | `GrrCard` | 29709 | `GrrItem.h` | +| class | `GrrItem` | 29739 | `GrrItem.h`, `GrrText.h` | +| class | `GrrSpacer` | 29799 | `GrrItems.h` | +| class | `GrrLabel` | 29808 | `GrrItems.h` | +| class | `GrrHeading` | 29820 | `GrrItems.h` | +| class | `GrrNote` | 29833 | `GrrItems.h` | +| class | `GrrRowItem` | 29849 | `GrrItems.h` | +| class | `GrrConvItem` | 29944 | `GrrItems.h` | +| class | `GrrSectionHead` | 29990 | `GrrItems.h` | +| class | `GrrButtonItem` | 30009 | `GrrItems.h` | +| class | `GrrTextCard` | 30050 | `GrrItems.h` | +| class | `GrrTabsItem` | 30077 | `GrrItems.h` | +| class | `GrrSegmentedItem` | 30144 | `GrrItems.h` | +| class | `GrrChipsItem` | 30183 | `GrrItems.h` | +| class | `GrrDividerItem` | 30222 | `GrrItems.h` | +| class | `GrrEmptyItem` | 30237 | `GrrItems.h` | +| class | `GrrKvItem` | 30256 | `GrrItems.h` | +| class | `GrrProgressItem` | 30276 | `GrrItems.h` | +| class | `GrrBarsItem` | 30303 | `GrrItems.h` | +| class | `GrrTileGridItem` | 30320 | `GrrItems.h` | +| class | `GrrPage` | 30392 | `GrrItem.h`, `GrrPage.h` | +| class | `GrrBar` | 30654 | `GrrBar.h` | +| class | `GrrTuiApp` | 30716 | `GrrTuiApp.h` | +| class | `GrrSecretBox` | 30739 | `GrrSecretBox.h` | +| class | `GrrMenu` | 30764 | `GrrMenu.h` | +| class | `GrrToast` | 30871 | `GrrMenu.h` | +| class | `GrrWelcome` | 30884 | `GrrMenu.h` | +| class | `GrrScreen` | 30907 | `GrrScreen.h` | +| class | `GrrView` | 30962 | `GrrView.h` | +| class | `GrrShell` | 31046 | `GrrChatItems.h`, `GrrControl.h`, `GrrFileViewer.h` | +| class | `GrrHome` | 31643 | `GrrHome.h`, `GrrShell.h` | +| class | `GrrBots` | 31739 | `GrrHome.h` | +| class | `GrrLoginView` | 31784 | `GrrViewsBots.h` | +| class | `GrrStripItem` | 31879 | `GrrItems.h` | +| class | `GrrBotsView` | 31931 | `GrrViewsBots.h` | +| class | `GrrProfileView` | 31970 | `GrrViewsBots.h` | +| class | `GrrHeroItem` | 32141 | `GrrItems.h` | +| class | `GrrNameCard` | 32156 | `GrrItems.h` | +| class | `GrrSwatchItem` | 32197 | `GrrItems.h` | +| class | `GrrScreenPreview` | 32235 | `GrrItems.h` | +| class | `GrrInstructionsView` | 32253 | `GrrViewsBots.h` | +| class | `GrrListView` | 32287 | `GrrViewsBots.h` | +| class | `GrrMemoriesView` | 32323 | `GrrViewsBots.h` | +| class | `GrrSkillsView` | 32341 | `GrrViewsBots.h` | +| class | `GrrMemoryView` | 32369 | `GrrViewsBots.h` | +| class | `GrrSkillView` | 32394 | `GrrViewsBots.h` | +| class | `GrrRoutineItem` | 32423 | `GrrItems.h` | +| class | `GrrAutomationsView` | 32454 | `GrrViewsAuto.h` | +| class | `GrrAutomationView` | 32550 | `GrrViewsAuto.h` | +| class | `GrrNewAutomationView` | 32612 | `GrrViewsAuto.h` | +| class | `GrrLibraryView` | 32687 | `GrrViewsLib.h` | +| class | `GrrProjectsView` | 32758 | `GrrViewsLib.h` | +| class | `GrrProjectView` | 32805 | `GrrViewsLib.h` | +| class | `GrrSearchView` | 32856 | `GrrViewsLib.h` | +| class | `GrrSettingsView` | 32941 | `GrrViewsSettings.h` | +| class | `GrrSubView` | 32989 | `GrrViewsSettings.h` | +| class | `GrrAccountView` | 33009 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrUsageView` | 33035 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrRulesView` | 33076 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrRuleView` | 33092 | `GrrViewsSettings.h` | +| class | `GrrAppearanceView` | 33143 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrLanguageView` | 33168 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrHapticsView` | 33179 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrWidgetView` | 33187 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrComputersView` | 33198 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrConnectorsView` | 33232 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrAdvancedView` | 33245 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrBuilderView` | 33297 | declared with the `GRR_SUBVIEW` macro in `GrrViewsSettings.h` | +| class | `GrrFeedbackView` | 33335 | `GrrViewsSettings.h` | +| class | `GrrTimezoneView` | 33369 | `GrrViewsSettings.h` | +| class | `GrrTemplateView` | 33399 | `GrrViewsSettings.h` | +| class | `GrrTemplatePartView` | 33445 | `GrrViewsSettings.h` | +| class | `GrrPublicTemplateView` | 33465 | `GrrViewsSettings.h` | +| class | `GrrShellExtras` | 33485 | `GrrShell::copy_text`, `open_url`, `open_file` in `GrrShell.h` | +| class | `GrrFileViewer` | 33515 | `GrrFileViewer.h` | +| class | `GrrControlsItem` | 33606 | `GrrChatItems.h` | +| class | `GrrMessageItem` | 33628 | `GrrChatItems.h` | +| class | `GrrApprovalItem` | 33740 | `GrrChatItems.h` | +| class | `GrrQuestionItem` | 33793 | `GrrChatItems.h` | +| class | `GrrFileMsgItem` | 33841 | `GrrChatItems.h` | +| class | `GrrFilesMsgItem` | 33870 | `GrrChatItems.h` | +| class | `GrrTemplateMsgItem` | 33909 | `GrrChatItems.h` | +| class | `GrrRemoteTerminal` | 33952 | `GrrChatItems.h` | +| class | `GrrProcessItem` | 34055 | `GrrChatItems.h` | +| class | `GrrComposerBox` | 34229 | `GrrChat.h` | +| class | `GrrChatView` | 34250 | `GrrChat.h`, `GrrChatItems.h`, `GrrPane.h` | +| class | `GrrComputerView` | 34946 | `GrrChat.h` | +| class | `GrrScreenView` | 35011 | `GrrChat.h` | +| class | `GrrPane` | 35044 | `GrrChat.h`, `GrrPane.h` | +| class | `GrrPaneTab` | 35168 | `GrrPane.h` | +| class | `GrrPaneFiles` | 35193 | `GrrPane.h` | +| class | `GrrPanePreview` | 35337 | `GrrPane.h` | +| class | `GrrPaneDeploy` | 35469 | `GrrPane.h` | +| class | `GrrControl` | 35492 | `GrrControl.h`, `GrrShell.h` | +| const | `TUI_FPS` | 22199 | `TuiApp.h` | +| const | `TUI_INPUT_PAINT_GAP` | 22202 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_GEOMETRY` | 22204 | geometry setters of `TuiPanel` (`set_x`, `set_y`, `set_w`, `set_h`) | +| const | `TUI_DECLRMM` | 22205 | `TuiApp.cpp`, `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_CELL_KEY` | 22206 | `TuiCell::key` in `TuiCell.h` | +| const | `TUI_SWITCH_COST` | 22207 | `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_MOVE_COST` | 22208 | `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_SCROLL_SLACK` | 22209 | `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_MIN_ROWS` | 22211 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_MIN_COLS` | 22213 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_LAYERS` | 22215 | layer to z base in `TuiPanel.cpp` (base 0, panel 10, dialog 20, toast 30) | +| const | `TUI_COLORS` | 22217 | color name table in `TuiStyle.cpp` and `TuiVtScreen.cpp` | +| const | `TUI_THEME_ROLES` | 22219 | `TuiTheme.h` | +| const | `TUI_THEME_DEFAULT` | 22224 | `TuiTheme.h` | +| const | `TUI_THEMES` | 22240 | `TuiTheme.h` | +| const | `TUI_THEME` | 22296 | `TuiThemes::current()` / `TuiThemes::set()` in `TuiTheme.h` | +| const | `TUI_OBSERVE_DEPTH` | 22699 | not needed (no observation proxies) | +| const | `TUI_PLAIN` | 22816 | not needed (no observation proxies) | +| const | `TUI_OBSERVING` | 22818 | not needed (no observation proxies) | +| const | `TUI_SYNC_START` | 23572 | `TuiApp.cpp`, `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_SYNC_END` | 23574 | `TuiApp.cpp`, `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_MOUSE_ENABLE` | 23576 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_MOUSE_DISABLE` | 23578 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_ALT_ENTER` | 23580 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_ALT_LEAVE` | 23582 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_KITTY_ENABLE` | 23584 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_KITTY_DISABLE` | 23586 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_PASTE_ENABLE` | 23588 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_PASTE_DISABLE` | 23590 | `TuiApp.cpp`, `TuiApp.h` | +| const | `TUI_PASTE_OPEN` | 23592 | `TuiInput::PASTE_OPEN` | +| const | `TUI_PASTE_CLOSE` | 23594 | `TuiInput::PASTE_CLOSE` | +| const | `TUI_PASTE_CAP` | 23596 | `TuiInput::PASTE_CAP` | +| const | `TUI_COMPLETE_MAXH` | 23598 | `TuiTextBox.cpp` | +| const | `TUI_BLANK_KEY` | 23601 | `TuiCell.h` | +| const | `TUI_BLANK` | 23603 | `TuiCell::blank()` in `TuiCell.h` | +| const | `TUI_POOL_KEYS` | 23605 | omitted with TuiCellPool | +| const | `TUI_POOL_CHARS` | 23607 | omitted with TuiCellPool | +| const | `TUI_SGR_CAP` | 23609 | `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_DAMAGE_RECTS` | 23611 | `TuiApp.cpp`, `TuiCompositor.h` | +| const | `TUI_ROW_MEMO` | 23613 | `TuiLog.cpp`, `TuiLog.h` | +| const | `TUI_SCROLLS` | 23615 | `TuiCompositor.cpp`, `TuiCompositor.h` | +| const | `TUI_CSI_KEYS` | 24114 | `TuiInput::csi_keys()` | +| const | `TUI_TILDE_KEYS` | 24116 | `TuiInput::tilde_keys()` | +| const | `TUI_SS3_KEYS` | 24120 | `TuiInput::ss3_keys()` | +| const | `TUI_KITTY_NAMED` | 24122 | `kitty_named()` in `TuiInput.cpp` | +| const | `TUI_KITTY_MODS` | 24126 | `kitty_mods()` in `TuiInput.cpp` | +| const | `TUI_KITTY_PAD` | 24129 | `kitty_pad()` in `TuiInput.cpp` | +| const | `TUI_KITTY_US_SHIFT` | 24133 | `kitty_us_shift()` in `TuiInput.cpp` | +| const | `TUI_AVATAR_ROLES` | 25348 | `TuiChatList.h` | +| const | `TUI_COMMAND_HELP` | 25450 | `TuiComplete::command_help()` / `reset_command_help()` | +| const | `TUI_TERM_KEYS` | 26190 | `TuiTerminal.h` | +| const | `TUI_CHAT_SIDEBAR_MIN` | 27608 | `TuiChatRows.cpp`, `TuiChatRows.h` | +| const | `TUI_CHAT_SIDEBAR_MAX` | 27609 | `TuiChatRows.cpp`, `TuiChatRows.h` | +| const | `TUI_CHAT_NARROW_COLS` | 27610 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_INPUT_CAP` | 27611 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_HISTORY` | 27612 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_LINE_CAP` | 27613 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_ANSI_RE` | 27615 | `TuiMarkdown.h` | +| const | `TUI_CHAT_BUBBLE_MAX` | 27626 | `TuiChatRows.cpp`, `TuiChatRows.h` | +| const | `TUI_CHAT_SPINNER` | 27627 | `TuiChatRows.h` | +| const | `TUI_CHAT_ONLINE_GLYPH` | 27628 | `TuiChatRows.h` | +| const | `TUI_CHAT_TYPE_ANSWER` | 27629 | `TuiChatRows.h`, `TuiValue.h` | +| const | `TUI_CHAT_PROMPT_TIMEOUT` | 27630 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_PROMPT_CHARS` | 27631 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_REVEAL_TICK` | 27632 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_REVEAL_STEPS` | 27633 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_TICK_SEND` | 27634 | `TuiChatRows.h` | +| const | `TUI_CHAT_TICK_SENT` | 27635 | `TuiChatRows.h` | +| const | `TUI_CHAT_TICK_READ` | 27636 | `TuiChatRows.h` | +| const | `TUI_MD_INLINE_RE` | 27639 | `TuiMarkdown.h` | +| const | `TUI_MD_LIST_RE` | 27640 | `TuiMarkdown.h` | +| const | `TUI_CHAT_PAD` | 27765 | `TuiChatRows.cpp`, `TuiChatRows.h` | +| const | `TUI_STDIO_GUARD` | 28124 | `TuiStdioGuard.h` | +| const | `TUI_CHAT_BLOCK_CAP` | 28125 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `TUI_CHAT_LIVE_ROWS` | 28126 | `TuiChatRows.cpp`, `TuiChatRows.h` | +| const | `TUI_CHAT_LIVE_KEEP` | 28127 | `TuiChatRows.h`, `TuiLiveStream.cpp` | +| const | `TUI_CHAT_LIVE_TICK` | 28128 | `TuiChatRows.h`, `TuiChatScene.cpp` | +| const | `GRR_DEFAULT_URL` | 28869 | `GrrClient.cpp`, `GrrClient.h`, `GrrConst.h` | +| const | `GRR_HTTP_TIMEOUT` | 28870 | `GrrClient.h`, `GrrConst.h` | +| const | `GRR_SSE_TIMEOUT` | 28871 | `GrrConst.h`, `GrrStream.cpp` | +| const | `GRR_SSE_RETRY` | 28872 | `GrrConst.h`, `GrrStream.cpp`, `GrrStream.h` | +| const | `GRR_SSE_LINE_MAX` | 28873 | `GrrConst.h`, `GrrStream.cpp`, `GrrStream.h` | +| const | `GRR_BODY_MAX` | 28874 | `GrrClient.cpp`, `GrrConst.h`, `GrrFileViewer.cpp` | +| const | `GRR_SIDE_W` | 28875 | `GrrConst.h`, `GrrShell.cpp` | +| const | `GRR_SIDE_MIN_COLS` | 28876 | `GrrConst.h`, `GrrShell.cpp` | +| const | `GRR_PANE_MIN_COLS` | 28877 | `GrrConst.h`, `GrrPane.cpp` | +| const | `GRR_PANE_MIN_W` | 28878 | `GrrConst.h`, `GrrPane.cpp` | +| const | `GRR_PANE_MAX_W` | 28879 | `GrrConst.h`, `GrrPane.cpp` | +| const | `GRR_MESSAGE_KEEP` | 28880 | `GrrChat.cpp`, `GrrConst.h` | +| const | `GRR_TOAST_SECS` | 28881 | `GrrConst.h`, `GrrShell.cpp` | +| const | `GRR_BANNER_SECS` | 28882 | `GrrConst.h`, `GrrShell.cpp` | +| const | `GRR_TOAST_MAX` | 28883 | `GrrConst.h`, `GrrShell.cpp` | +| const | `GRR_COMPOSER_MAX_ROWS` | 28884 | `GrrChat.cpp`, `GrrConst.h` | +| const | `GRR_VIEWER_MAX_BYTES` | 28885 | `GrrClient.h`, `GrrConst.h`, `GrrFileViewer.cpp` | +| const | `GRR_PROMPT_QUIET` | 28886 | `GrrChatItems.cpp`, `GrrConst.h` | +| const | `GRR_PROMPT_ASKS` | 28887 | `GrrChatItems.cpp`, `GrrConst.h` | +| const | `GRR_PROMPT_MAX` | 28888 | `GrrChat.cpp`, `GrrConst.h` | +| const | `GRR_DRAFT_KEEP` | 28889 | `GrrChat.cpp`, `GrrConst.h` | +| const | `GRR_LIST_MORE` | 28890 | `GrrConst.h`, `GrrViewsBots.cpp`, `GrrViewsBots.h` | +| const | `GRR_MENU_MAX_H` | 28891 | `GrrConst.h`, `GrrMenu.cpp` | +| const | `GRR_PALETTE_FALLBACK` | 28893 | `GrrConst.h` | +| const | `GRR_SHAPES` | 28907 | `GrrConst.h` | +| const | `GRR_AVATAR_ROWS` | 28910 | `GrrConst.h` | +| const | `GRR_ICONS` | 28925 | `GrrConst.h` | +| const | `GRR_STRING_FALLBACK` | 28953 | `GrrConst.h` | +| const | `GRR_KEYS` | 30635 | `GrrConst.h` | +| const | `GRR_LANG_NAMES` | 31613 | `GrrConst.h` | +| const | `GRR_TEMPLATE_ASK` | 31615 | `GrrConst.h` | +| const | `GRR_FILE_ICON` | 32684 | `GrrConst.h` | +| const | `GRR_TEXT_MIMES` | 33482 | `GrrConst.h` | +| const | `GRR_CONTROL_JOBS` | 35479 | `GrrConst.h`, `GrrControl.h` | +| const | `GRR_CONTROL_LINE` | 35480 | `GrrConst.h` | +| const | `GRR_CONTROL_WAIT` | 35481 | `GrrConst.h` | +| const | `GRR_CONTROL_WAIT_MAX` | 35482 | `GrrConst.h` | +| const | `GRR_CONTROL_KEYS_MAX` | 35483 | `GrrConst.h` | +| const | `GRR_ROUTES` | 35713 | `GrrConst.h`, `GrrViews.cpp` | +| func | `tui_register_theme` | 22243 | `TuiTheme.h` | +| func | `tui_theme` | 22299 | `GrrPalette.cpp`, `GrrPalette.h`, `TuiTheme.h` | +| func | `tui_set_theme` | 22303 | `TuiTheme.h` | +| func | `tui_resolve_theme_name` | 22311 | `TuiTheme.h` | +| func | `tui_cycle_theme_name` | 22320 | `TuiTheme.h` | +| func | `tui_ease` | 22499 | `TuiApp.h` | +| func | `tui_int` | 22607 | `TuiInput.cpp` | +| func | `tui_flatten` | 22614 | `TuiStyle.h` | +| func | `tui_runs` | 22622 | `TuiStyle.h` | +| func | `tui_pad_spans` | 22641 | `TuiStyle.h` | +| func | `tui_span_width` | 22645 | `TuiStyle.h` | +| func | `tui_slice_spans` | 22652 | `TuiStyle.h` | +| func | `tui_watch` | 22726 | `GrrViewsLib.cpp`, `GrrViewsLib.h`, `TuiApp.cpp` | +| func | `tui_observe` | 22821 | replaced by explicit setters (see TuiObserved) | +| func | `tui_item` | 22848 | `GrrChat.cpp`, `GrrChat.h`, `GrrChatItems.h` | +| func | `tui_watchable` | 22859 | replaced by explicit setters (see TuiObserved) | +| func | `tui_utf8_head` | 24093 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_modifiers` | 24138 | `TuiEvent.h`, `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_parse_mouse_sgr` | 24152 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_parse_mouse_legacy` | 24179 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_parse_kitty` | 24193 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_paste_head` | 24260 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_parse_paste` | 24268 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_parse_one` | 24284 | `TuiInput.cpp`, `TuiInput.h` | +| func | `tui_avatar_role` | 25351 | `TuiChatList.h`, `TuiComplete.h` | +| func | `tui_command_help` | 25453 | `TuiComplete.h` | +| func | `tui_complete_mention` | 25466 | `TuiComplete.h` | +| func | `tui_complete_command` | 25489 | `TuiComplete.h` | +| func | `tui_build_demo` | 27413 | `TuiDemo.h` | +| func | `tui_demo` | 27581 | `TuiDemo.h` | +| func | `tui_demo_shot` | 27594 | `TuiDemo.h` | +| func | `tui_strip_ansi` | 27618 | `TuiMarkdown.h` | +| func | `tui_chat_sidebar_w` | 27622 | `TuiChatRows.h` | +| func | `tui_md_inline` | 27643 | `TuiMarkdown.h` | +| func | `tui_md_blocks` | 27663 | `TuiMarkdown.h` | +| func | `tui_wrap_segs` | 27700 | `TuiMarkdown.h` | +| func | `tui_chat_styles` | 27737 | `TuiChatRows.h` | +| func | `tui_chat_body_rows` | 27768 | `TuiChatRows.h` | +| func | `tui_chat_bubble_rows` | 27802 | `TuiChatRows.h` | +| func | `tui_chat_tool_summary` | 27859 | `TuiChatRows.h` | +| func | `tui_chat_tool_row` | 27871 | `TuiChatRows.h` | +| func | `tui_chat_live_rows` | 27887 | `TuiChatRows.h` | +| func | `tui_chat_note_rows` | 27913 | `TuiChatRows.h` | +| func | `tui_chat_pill_row` | 27929 | `TuiChatRows.h` | +| func | `tui_chat_welcome_rows` | 27936 | `TuiChatRows.h` | +| func | `tui_spawn_agent` | 28131 | `TuiChatAgent.h` | +| func | `tui_build_chat` | 28148 | `TuiChatAgent.h`, `TuiChatScene.h` | +| func | `tui_chat` | 28840 | `TuiChatScene.h` | +| func | `tui_merge_spans` | 29248 | `GrrText.h` | +| func | `grr_text` | 29682 | `GrrText.h` | +| func | `grr_clip` | 29687 | `GrrText.h` | +| func | `grr_wrap` | 29694 | `GrrText.h` | +| func | `grr_line` | 29701 | `GrrText.h` | +| func | `grr_margin` | 29705 | `GrrText.h` | +| func | `grr_group` | 29936 | `GrrHome.cpp`, `GrrItems.cpp`, `GrrItems.h` | +| func | `grr_bot_avatar` | 31618 | `GrrAvatar.h` | +| func | `grr_user_avatar` | 31622 | `GrrAvatar.h` | +| func | `grr_group_avatar` | 31630 | `GrrAvatar.h` | +| func | `grr_schedule_dialog` | 32487 | `GrrViewsAuto.h` | +| func | `grr_hero_items` | 33432 | `GrrViewsSettings.cpp`, `GrrViewsSettings.h` | +| func | `grr_bubble_styles` | 33598 | `GrrChatItems.cpp`, `GrrChatItems.h` | +| func | `grr_card_rows` | 33734 | `GrrItem.h` | +| func | `grr_control_path` | 35486 | `GrrControl.h` | +| func | `grr_control_call` | 35689 | `GrrControl.h` | +| func | `grr_shell_call_quiet` | 35726 | `GrrShell::client_call_quiet` | +| func | `grr_router_rewrite` | 35731 | `GrrRouter::rewrite` | +| func | `grr_tui` | 35741 | `GrrTui.h` | + +## Gaps (not verified, listed next to what would close them) + +- Paths with no differential run: approval cards and question cards answered by a + bot, live process cards (`GrrProcessItem` stream, transcript replay, Type and + Expand), file and files messages, the file viewer, the Preview and Files pane + against a bot that has files, template sharing. They compile, and the screens + that open them match, but need a bot that produces them (a real model call). + Closing it: `tests/grr_tui.py` equivalent driven against the C++ binary. +- The remote terminal (`GrrRemoteTerminal`) was driven against a bot computer that + was not running on the test server for the echo step: the typed text never reached + a shell. The two screens matched while it showed the prompt; typing into a live + shell and the resize path are not verified. +- Local chat scene threads: the offload, reveal and prompter paths run on the + loop in the tests with a fake agent. A real agent adapter is not written here. +- Transport error text: `GrrClient::offline_reason` maps libcurl results to python's + wording for timeout, unresolvable host, empty reply and OS errors (refused matched + python on the offline screen). Other curl errors show curl's own text. +- `GrrTimezoneView` reads `/usr/share/zoneinfo` (python: `zoneinfo.available_timezones()`); + verified equal on this machine (486 zones), not on a machine with `tzdata` from pip. +- `GrrFormat`, `GrrText`, `GrrStrings`, `GrrRouter::parse` and `GrrPalette` have no + direct unit diff; they are covered through the screens above. +- Opening a link or file spawns the desktop opener and does not reap it (same as + python, which leaves the child to init). diff --git a/taicpp/docs/wiring/tui.md b/taicpp/docs/wiring/tui.md new file mode 100644 index 0000000..69f66bf --- /dev/null +++ b/taicpp/docs/wiring/tui.md @@ -0,0 +1,115 @@ +# wiring: src/tui + +What the integrator applies to `main.cpp`, the Makefile and the agent module. +`src/tui` edits nothing outside itself. Include `tui.h` for every entry point below. +The python reference is `main()` in tai.py (the block that starts at `if args.demo:`). + +## 1. Order in `main()` (same order as python) + +```cpp +#include "tui.h" +using namespace detz::tui; + +if (opts.demo) return TuiDemo::demo(); // --demo +if (opts.demo_shot) return TuiDemo::demo_shot(std::cout); // --demo-shot (30x100 frame as text) + +std::string theme_name; +try { theme_name = TuiThemes::resolve_name(opts.theme); } // --theme, then TAI_THEME, then the default +catch (const std::invalid_argument& e) { std::cerr << e.what() << "\n"; return 2; } + +// ... --mplex and --plexer-wrap come here, as in python ... + +if (opts.tui) return GrrTui::run(); // --tui: the grr client, needs no adapters + +// ... profile check, boot(args), --env, eval, scheduler, telegram ... + +if (opts.tui_local) { // --tui-local + auto agent = std::make_shared(boot_result); // section 2 + YourChatHost host; + int code = TuiChatScene::run(host, agent, theme_name); + boot_result.store.close(); // python: finally agent.store.close() + return code; +} +``` + +Exit codes: `GrrTui::run()` returns 2 when the terminal is not interactive +(`tui unavailable: ...` on stderr) and the app's own code otherwise. `TuiDemo::demo()` +returns 2 with `demo unavailable: ...` in the same case. Both need a tty on stdin and stdout. + +`--demo-shot` writes one composed frame, so `diff <(./tai.py --demo-shot) <(detz --demo-shot)` +is the acceptance check (identical today at 30x100). + +## 2. Adapters for `--tui-local` (the agent module is another port) + +`TuiChatScene` depends on two interfaces in `TuiChatAgent.h`, never on the agent module: + +| interface | python it replaces | the integrator maps it to | +|---|---|---| +| `TuiChatAgent` | `Agent` as `tui_build_chat` uses it (bot, profile, messages, pins, `store.list_bots`, `load_session`, `save_session`, `switch_bot`, `run_turn(capture=True)`, `checkpoint`, quiet flag, `handle_command`, the `on_phase`, `prompter` and `stream_factory` hooks, `VaultHistory.for_agent`) | `detz::agent::Agent` and `detz::store::Store` | +| `TuiChatHost` | module functions `tui_spawn_agent`, `running_agents`, `VERSION`, `short_error`, `start_scheduler` | free functions of the agent and cli modules | + +Contracts that matter: + +- `run_turn` is called on an `offload` thread and may throw `TuiChatBackendError` (shown as + `error: ...`) or `TuiChatInterrupted`. Everything else it throws is shown with its `what()`. +- `handle_command` returns false to leave the app and fills `output` with what the command printed. +- `set_prompter` hands the agent a function that may be called from the turn thread: it + blocks until the user answers a dialog on the loop. `set_stream_factory` does the same + for a command's live output (`TuiStreamSink::feed`, `pause`, `close`). +- `history_backend()` returns `std::make_shared(load_fn, add_fn, profile, bot_fn)` + with the three store calls injected: load (`input_history` rows of the profile, oldest first), + add (profile, bot, text), and the current bot name. + +## 3. Process-wide tables to fill once at boot (before `TuiChatScene::run`) + +`TuiComplete::sources()` is empty until the integrator fills it; completion and `/help` +read nothing else. Fields, with the python data each comes from: + +| field | python | +|---|---| +| `commands` | `COMMANDS` | +| `help_text` | `HELP_TEXT` (also what `TuiComplete::command_help()` parses) | +| `provider_labels` | `PROVIDER_LABELS` | +| `mention_auto` | `MENTION_AUTO` | +| `default_models` | sorted union of `ZEN_FREE_MODELS`, `POLLINATIONS_MODELS`, `DEVPLACE_FREE_ROUTES` and `OPENROUTER_MODEL` | +| `complete_files` | the path completer (prefix, directory) used by the REPL | +| `dir_key` | the key of the current directory in the vault | + +The grr client (`--tui`) needs none of this. It reads `GRR_URL`, `TAI_HOME` (for `grr.json` +and the control socket `tui.sock`) and `TAI_TUI_SOCK`, like python. + +## 4. Build + +- The top Makefile already finds `src/tui/*.cpp` and links `-lcurl`. `GrrClient` and `GrrStream` + use libcurl; threads come from libc (no `-pthread` needed on glibc 2.34 or later; add it + for the static link line if the linker asks). +- `src/tui/tui.mk` lists nothing; the old stub `tui.cpp` is gone (nothing outside `src/tui` + used `detz::tui::render` or `poll_events`). +- Static build: libcurl needs no extra feature here (plain http to the grr server, https + through the openssl already linked). +- Tests (each is a stand-alone program, run from `taicpp/`; objects of `src/tui` plus + `-lutil -lcurl -pthread`): + +| file | needs | checks | +|---|---|---| +| `tests/tui_spec.cpp` | `../grrtui.md` (`GRRTUI_MD` to move it) | routes, keys, caps, shapes, events, flags against the doc; exit 1 on drift | +| `tests/tui_shot.cpp` | nothing | demo frame as text, sgr and raw bytes for the differential runs | +| `tests/tui_script.cpp` | nothing | scripted input scenarios on a headless app | +| `tests/tui_rows.cpp` | nothing | chat row, markdown and textwrap cases on stdin | +| `tests/tui_chat.cpp` | nothing | chat scene scenarios with a fake agent | +| `tests/tui_grr.cpp` | a grr server | the `--tui` entry; drive it through the control socket | + +`make test` does not run these yet; a rule per file is the same shape as `$(BUILD)/store_test`: +`$(CXX) $(CXXFLAGS) -Isrc/tui tests/tui_spec.cpp src/tui/*.cpp -lutil -lcurl -pthread -o $@`. + +## 5. The control socket (`GrrControl`) + +`GrrTui::run()` starts it. One JSON line in, one out, mode 0600, at `$TAI_TUI_SOCK` or +`$TAI_HOME/tui.sock` (default `~/.tai/tui.sock`). Ops: `screen` (optional `until`, `timeout`), +`key` (`keys`, at most 200), `type` (`text`), `click` (`x`, `y`), `goto` (`route`), `result` (`id`). +Any op takes `async` and `wait`. `GrrControl::call(payload)` is the client side +(python `grr_control_call`). If another TUI already answers on the path, `start()` returns false +and the second TUI runs without a socket. + +If the integrator adds a CLI front for it (python has none in this file), it is +`GrrControl::call(TuiValue::loads(line))` and print `dumps()`. diff --git a/taicpp/src/tui/GrrAvatar.cpp b/taicpp/src/tui/GrrAvatar.cpp new file mode 100644 index 0000000..3140752 --- /dev/null +++ b/taicpp/src/tui/GrrAvatar.cpp @@ -0,0 +1,75 @@ +#include "GrrAvatar.h" +#include "GrrText.h" +namespace detz::tui { + +std::string GrrAvatar::eye_color(const std::string& color) { return GrrPalette::light(color) ? "#1b1812" : "#120f08"; } + +std::string GrrAvatar::color_of(GrrUi& ui, const TuiValue& bot) { + TuiValue c = bot.get("color"); + return c.truthy() ? c.str() : ui.palette.c("accent"); +} + +SpanRows GrrAvatar::rows(GrrUi& ui, const TuiValue& bot, const std::string& bg, bool small, bool hero) { + TuiValue sh = bot.get("shape"); + std::string shape = sh.truthy() ? sh.str() : "blob"; + std::string color = color_of(ui, bot); + StyleP body = mk_style(TuiColor(color), TuiColor(ui.palette.c(bg))); + StyleP dark = mk_style(TuiColor(eye_color(color)), TuiColor(color)); + auto it = GrrConst::avatar_rows().find(shape); + if (it == GrrConst::avatar_rows().end()) it = GrrConst::avatar_rows().find("blob"); + UStr top = it->second.first, bottom = it->second.second; + if (small) { + top = UStr(1, top.front()) + U"E█E" + UStr(1, top.back()); + bottom = UStr(1, bottom.front()) + U"E█E" + UStr(1, bottom.back()); + } + std::vector templates = {top, bottom}; + if (hero) { + UStr mid = U"█" + top.substr(1, top.size() - 2) + U"█"; + templates = {top, mid, mid, bottom}; + } + SpanRows out; + for (auto& tmpl : templates) { + SpanRow spans; + for (char32_t ch : tmpl) spans.push_back(ch == U'E' ? TuiSpan(U"▌", dark) : TuiSpan(UStr(1, ch), body)); + out.push_back(GrrText::merge_spans(spans)); + } + return out; +} + +SpanRow GrrAvatar::chip(GrrUi& ui, const TuiValue& bot, const std::string&) { + std::string color = color_of(ui, bot); + return SpanRow{TuiSpan(U" ▮ ▮ ", mk_style(TuiColor(eye_color(color)), TuiColor(color)))}; +} + +SpanRows GrrAvatar::chip_rows(GrrUi& ui, const TuiValue& bot, const std::string& bg) { + std::string color = color_of(ui, bot); + StyleP body = mk_style(TuiColor(color), TuiColor(ui.palette.c(bg))); + return SpanRows{SpanRow{TuiSpan(U"▟▙", body)}, SpanRow{TuiSpan(U"▜▛", body)}}; +} + +GrrArt GrrAvatar::bot_art(const TuiValue& bot) { + return [bot](GrrUi& ui, const std::string& bg) { return rows(ui, bot, bg, true); }; +} + +GrrArt GrrAvatar::user_art(const TuiValue& account) { + return [account](GrrUi& ui, const std::string&) { + TuiValue name = account.get("username"); + UStr letter = TuiStr::upper(TuiStr::head(name.truthy() ? TuiStr::u(name.str()) : UStr(U"?"), 1)); + StyleP style = ui.st("ink", "rule-strong", true); + return SpanRows{SpanRow{TuiSpan(U" " + letter + U" ", style)}, SpanRow{TuiSpan(U" ", style)}}; + }; +} + +GrrArt GrrAvatar::group_art(std::vector bots) { + return [bots](GrrUi& ui, const std::string& bg) { + SpanRows out(2); + for (size_t i = 0; i < bots.size() && i < 3; i++) { + SpanRows cr = chip_rows(ui, bots[i], bg); + for (size_t pos = 0; pos < 2; pos++) out[pos].insert(out[pos].end(), cr[pos].begin(), cr[pos].end()); + } + for (size_t pos = 0; pos < 2; pos++) out[pos] = TuiSpans::slice(out[pos], 0, 5, ui.st("ink", bg)); + return out; + }; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrAvatar.h b/taicpp/src/tui/GrrAvatar.h new file mode 100644 index 0000000..cc72166 --- /dev/null +++ b/taicpp/src/tui/GrrAvatar.h @@ -0,0 +1,24 @@ +#pragma once +// GrrAvatar: a bot's mark in cells: the character shape with two eyes (tai.py +// GrrAvatar). `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. +#include "GrrUi.h" +#include +namespace detz::tui { + +using GrrArt = std::function; + +class GrrAvatar { +public: + static std::string eye_color(const std::string& color); + static SpanRows rows(GrrUi& ui, const TuiValue& bot, const std::string& bg = "bg", bool small = false, bool hero = false); + static SpanRow chip(GrrUi& ui, const TuiValue& bot, const std::string& bg = "bg"); + static SpanRows chip_rows(GrrUi& ui, const TuiValue& bot, const std::string& bg = "bg"); + static GrrArt bot_art(const TuiValue& bot); // grr_bot_avatar + static GrrArt user_art(const TuiValue& account); // grr_user_avatar + static GrrArt group_art(std::vector bots); // grr_group_avatar +private: + static std::string color_of(GrrUi& ui, const TuiValue& bot); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrBar.cpp b/taicpp/src/tui/GrrBar.cpp new file mode 100644 index 0000000..c3ffbf3 --- /dev/null +++ b/taicpp/src/tui/GrrBar.cpp @@ -0,0 +1,73 @@ +#include "GrrBar.h" +#include "GrrText.h" +#include +namespace detz::tui { + +GrrBar::GrrBar(GrrUiP u, TuiPanelOpts opts) + : TuiPanel([&] { + if (!opts.h) opts.h = TuiSpec(1); + return opts; + }()), + ui(std::move(u)) {} + +void GrrBar::set(std::vector parts, const std::string& bg) { + parts_ = std::move(parts); + bg_ = bg; + touch(); +} + +SpanRows GrrBar::content_rows() { + int width = content_w(); + StyleP base = ui->st("ink", bg_); + regions_.clear(); + struct Sized { const GrrBarPart* part; std::optional spans; long w; }; + std::vector sized; + for (auto& part : parts_) { + std::optional spans = part.spans; + if (!spans && !part.fill) spans = SpanRow{TuiSpan(part.text, ui->st(part.fg, !part.bg.empty() ? part.bg : bg_, part.bold))}; + sized.push_back(Sized{&part, spans, spans ? TuiSpans::width(*spans) : 0}); + } + long fixed = 0; + int fills = 0; + for (auto& s : sized) { + if (!s.part->fill) fixed += s.w + 1; + else fills++; + } + long room = std::max(0L, width - 1 - fixed - (fills ? 1 : 0)); + long each = fills ? room / fills : 0; + SpanRow row{TuiSpan(U" ", base)}; + long x = 1; + for (auto& s : sized) { + long w = s.w; + SpanRow spans; + if (s.part->fill) { + StyleP style = ui->st(s.part->fg, !s.part->bg.empty() ? s.part->bg : bg_, s.part->bold); + UStr text = GrrText::clip(s.part->text, each); + spans = {TuiSpan(s.part->align == "center" ? TuiStr::center(text, each) : TuiStr::ljust(text, each), style)}; + w = each; + } else { + spans = *s.spans; + } + row.insert(row.end(), spans.begin(), spans.end()); + row.push_back(TuiSpan(U" ", base)); + if (s.part->fn) regions_.push_back(Region{static_cast(x), static_cast(x + w), s.part->fn}); + x += w + 1; + } + return {GrrText::line(row, width, base)}; +} + +GrrFn GrrBar::last_fn() const { return regions_.empty() ? GrrFn() : regions_.back().fn; } + +void GrrBar::on_click(TuiClick& event) { + if (!event.pressed) return; + event.stop(); + int x = event.x - ax; + for (auto& r : std::vector(regions_)) { + if (r.x0 <= x && x < r.x1) { + r.fn(); + return; + } + } +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrBar.h b/taicpp/src/tui/GrrBar.h new file mode 100644 index 0000000..2232e9e --- /dev/null +++ b/taicpp/src/tui/GrrBar.h @@ -0,0 +1,36 @@ +#pragma once +// GrrBar: one row of clickable parts placed left to right (tai.py GrrBar). A part +// is 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. +#include "GrrItem.h" +#include "TuiPanel.h" +namespace detz::tui { + +struct GrrBarPart { + std::optional spans; + UStr text; + std::string fg{"ink"}; + std::string bg; // "" takes the bar's own background + bool bold{false}; + bool fill{false}; + std::string align; + GrrFn fn; +}; + +class GrrBar : public TuiPanel, public GrrUiHolder { +public: + explicit GrrBar(GrrUiP ui, TuiPanelOpts opts = {}); + GrrUiP ui; + void set_ui(const GrrUiP& u) override { ui = u; } + void set(std::vector parts, const std::string& bg = "bg"); + SpanRows content_rows() override; + GrrFn last_fn() const; // the action of the right-most clickable part (the More menu) + void on_click(TuiClick& event) override; +private: + std::vector parts_; + std::string bg_{"bg"}; + struct Region { int x0, x1; GrrFn fn; }; + std::vector regions_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrChat.cpp b/taicpp/src/tui/GrrChat.cpp new file mode 100644 index 0000000..32f62bf --- /dev/null +++ b/taicpp/src/tui/GrrChat.cpp @@ -0,0 +1,1232 @@ +#include "GrrChat.h" +#include "GrrAvatar.h" +#include "GrrPane.h" +#include "GrrShell.h" +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +namespace { +UStr su(const TuiValue& v) { return TuiStr::u(v.str()); } +UStr text_of(const TuiValue& v) { return GrrText::text(v); } +std::vector list_of(const TuiValue& v) { return v.is_list() ? v.items() : std::vector{}; } +double number(const TuiValue& v) { return v.is_num() ? v.as_float() : 0.0; } +std::string strip_spaces(const std::string& s) { + std::string out; + for (char c : s) if (c != ' ') out.push_back(c); + return out; +} +std::string lower_ascii(std::string s) { + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} +std::string expanduser(const std::string& path) { + if (!path.empty() && path[0] == '~' && (path.size() == 1 || path[1] == '/')) { + const char* home = std::getenv("HOME"); + return std::string(home ? home : "") + path.substr(1); + } + return path; +} +std::string basename_of(const std::string& path) { + size_t at = path.rfind('/'); + return at == std::string::npos ? path : path.substr(at + 1); +} +} // namespace + +GrrComposerBox::GrrComposerBox(GrrChatView* v, UStr text, CompleteFn complete, CompleteFn mentions, bool send_on_enter, std::function on_send, + long max_chars, UStr placeholder, TuiPanelOpts opts) + : TuiTextBox(std::move(text), std::move(complete), std::move(mentions), send_on_enter, std::move(on_send), max_chars, std::move(placeholder), nullptr, std::move(opts)), view(v) {} + +void GrrComposerBox::on_key(TuiKey& event) { + const std::string& key = event.key; + if ((key == "pageup" || key == "pagedown") && lines.size() <= 1) { + event.stop(); + int step = std::max(1, view->log->content_h() - 1); + view->log->scroll_by(key == "pageup" ? -step : step); + return; + } + if (key == "ctrl+up" || key == "ctrl+down") { + event.stop(); + view->log->scroll_by(key == "ctrl+up" ? -3 : 3); + return; + } + TuiTextBox::on_key(event); +} + +// ---- chat view -------------------------------------------------------------------------------------- + +GrrChatView::~GrrChatView() = default; + +GrrItemP GrrChatView::find_item(const std::string& id) const { + for (auto& p : items) if (p.first == id) return p.second; + return nullptr; +} + +void GrrChatView::put_item(const std::string& id, const GrrItemP& item) { + for (auto& p : items) if (p.first == id) { p.second = item; return; } + items.emplace_back(id, item); +} + +void GrrChatView::open() { + GrrShell& sh = g; + GrrUi& u = *ui; + auto self = std::static_pointer_cast(shared_from_this()); + chat = TuiValue(); + messages.clear(); + items.clear(); + attachments.clear(); + mode = query.get("mode").truthy() ? query.get("mode").str() : "ask"; + tier = sh.settings.get("tier").truthy() ? sh.settings.get("tier").str() : "auto"; + private_ = false; + has_more = false; + busy = false; + new_below = false; + prompts.clear(); + spin = 0; + pane = nullptr; + TuiPanelOpts ro; + ro.w = TuiSpec("1fr"); + ro.h = TuiSpec("100%"); + ro.style = u.st("ink", "bg"); + root = std::make_shared(ro); + col = std::make_shared(ro); + TuiPanelOpts bo; + bo.style = u.st("ink", "bg"); + bar = std::make_shared(ui, bo); + modebar = std::make_shared(ui, bo); + TuiPanelOpts lo; + lo.style = u.st("ink", "bg"); + lo.h = TuiSpec("1fr"); + log = std::make_shared(ui, true, false, lo); + log->on_bottom = [self](bool b) { self->on_bottom(b); }; + TuiPanelOpts wo; + wo.h = TuiSpec(1); + wo.style = u.st("ink", "bg"); + working = std::make_shared(SpanRows{}, wo); + attach_row = std::make_shared(SpanRows{}, wo); + attach_row->set_visible(false); + TuiPanelOpts xo; + xo.h = TuiSpec(1); + xo.border = true; + xo.rounded = true; + xo.padding = TuiPanel::norm_padding(0, 1); + xo.w = TuiSpec("1fr"); + xo.style = u.st("ink", "card"); + xo.border_style = u.st("rule-strong", "bg"); + xo.focus_border_style = u.st("accent", "bg"); + box = std::make_shared( + this, UStr(), nullptr, + [&sh](const UStr& prefix) { + std::vector found; + std::string low = lower_ascii(TuiStr::s(prefix)); + for (auto& b : sh.store.bots) { + std::string name = strip_spaces(b.get("name").str()); + if (lower_ascii(name).compare(0, low.size(), low) == 0) found.push_back({U"@" + TuiStr::u(name), TuiValue(name)}); + } + return found; + }, + true, [self](const UStr&) { self->on_send(); }, u.cap("MESSAGE_MAX_CHARS", 20000), UStr(), xo); + TuiPanelOpts po; + po.border = false; + po.w = TuiSpec(3); + po.h = TuiSpec(1); + po.style = u.st("ink", "card", true); + plus = std::make_shared(u.icon("plus"), [self](TuiEvent&) { self->attach_menu(); }, false, po); + TuiPanelOpts so; + so.border = false; + so.w = TuiSpec(5); + so.h = TuiSpec(1); + so.style = u.st("accent-ink", "accent", true); + send_btn = std::make_shared(UStr(), [self](TuiEvent&) { self->send_action(); }, false, so); + TuiPanelOpts co; + co.h = TuiSpec(3); + co.gap = 1; + co.padding = TuiPanel::norm_padding(1, 1, 0, 1); + co.style = u.st("ink", "bg"); + composer = std::make_shared(co); + composer->add(plus); + composer->add(box); + composer->add(send_btn); + col->add(bar); + col->add(modebar); + col->add(log); + col->add(working); + col->add(attach_row); + col->add(composer); + root->add(col); + box->set_handler("change", [self](TuiEvent&) { self->fit_box(); }); + modebar->set_visible(false); + std::string chat_id_p = params.get("id").str(); + sub(sh.events, "message.new", [self](const TuiValue& m) { self->on_new(m); }); + sub(sh.events, "message.update", [self](const TuiValue& m) { self->on_update(m); }); + sub(sh.events, "status", [self](const TuiValue& s) { self->on_status(s); }); + sub(sh.events, "chat.update", [self](const TuiValue& d) { self->on_chat_update(d); }); + sub(sh.events, "link", [self](const TuiValue& up) { if (up.truthy() && self->chat.truthy()) self->catch_up(); }); + sub(sh.store, "changed", [self](const TuiValue&) { self->refresh_head(); }); + every(0.15, [self] { self->tick(); }); + screen = nullptr; + if (chat_id_p == "new") { + refresh_head(); + paint_all(); + show(root, box.get()); + fit_box(); + set_send_mode(); + } else { + show(root, box.get()); + batch({{"GET", "/api/chats/" + chat_id_p, std::nullopt}, {"GET", "/api/chats/" + chat_id_p + "/messages", std::nullopt}}, [self](const TuiValue& r) { self->loaded(r); }); + } +} + +void GrrChatView::loaded(const TuiValue& res) { + GrrShell& sh = g; + TuiValue c = res.at(0), page = res.at(1); + chat = c; + messages = list_of(page.get("messages")); + has_more = page.get("has_more").truthy(); + mode = c.get("mode").truthy() ? c.get("mode").str() : "ask"; + tier = c.get("tier").truthy() ? c.get("tier").str() : "auto"; + pane = chat_is_bot() ? std::make_shared(this) : nullptr; + refresh_head(); + paint_all(); + fit_box(); + set_send_mode(); + auto self = std::static_pointer_cast(shared_from_this()); + if (c.get("unread").truthy()) { + std::string id = c.get("id").str(); + sh.app->after(1.2, [self, &sh, id] { + if (!self->closed) sh.call("POST", "/api/chats/" + id + "/read", TuiValue::dict(), nullptr, [](std::exception_ptr) {}); + }); + } + if (const UStr* draft = sh.draft(c.get("id").str())) { + if (!draft->empty()) { + box->set_lines(TuiStr::split(*draft, U'\n')); + box->set_cy(static_cast(box->lines.size()) - 1); + box->set_cx(static_cast(box->lines.back().size())); + } + } + log->scroll_to_end(); + restore_pane(); +} + +void GrrChatView::destroy() { + GrrShell& sh = g; + auto copy = prompts; + for (auto& p : copy) sh.close_dialog(p.second, TuiValue(), true); + prompts.clear(); + if (chat.truthy()) { + UStr text = TuiStr::strip(box->value()); + std::string id = chat.get("id").str(); + if (!text.empty()) { + if (static_cast(sh.drafts.size()) >= GRR_DRAFT_KEEP && !sh.drafts.empty()) sh.drafts.erase(sh.drafts.begin()); + sh.set_draft(id, box->value()); + } else { + sh.erase_draft(id); + } + if (chat.get("kind").is_str() && chat.get("kind").as_str() == "private") sh.client_call_quiet("DELETE", "/api/chats/" + id); + else sh.client_call_quiet("POST", "/api/chats/" + id + "/read", TuiValue::dict()); + } + for (auto& p : items) p.second->destroy(); + if (pane) pane->destroy(); + GrrView::destroy(); +} + +bool GrrChatView::chat_is_bot() { + return chat.truthy() && chat.get("kind").is_str() && (chat.get("kind").as_str() == "bot" || chat.get("kind").as_str() == "group") && !bots().empty(); +} +bool GrrChatView::is_general() { return !chat.truthy() || (chat.get("kind").is_str() && (chat.get("kind").as_str() == "general" || chat.get("kind").as_str() == "private")); } +bool GrrChatView::is_private() { return private_ || (chat.truthy() && chat.get("kind").is_str() && chat.get("kind").as_str() == "private"); } + +std::vector GrrChatView::bots() { + std::vector out; + if (!chat.truthy()) return out; + for (auto& id : list_of(chat.get("bot_ids"))) { + TuiValue b = g.store.bot(id); + if (b.truthy()) out.push_back(b); + } + return out; +} + +TuiValue GrrChatView::bot_of(const TuiValue& bot_id) { return bot_id.truthy() ? g.store.bot(bot_id) : TuiValue(); } + +UStr GrrChatView::placeholder() { + GrrShell& sh = g; + if (!is_general()) { + auto b = bots(); + std::string name = chat.get("kind").as_str() == "bot" ? (!b.empty() ? b[0].get("name").str() : std::string()) : chat.get("title", TuiValue("")).str(); + return sh.t("chat.ask_bot", {{"name", name}}); + } + if (is_private()) return sh.t("chat.private_placeholder"); + return sh.t("chat.mode_" + mode + "_ph"); +} + +void GrrChatView::refresh_head() { + GrrShell& sh = g; + GrrUi& u = *ui; + auto self = std::static_pointer_cast(shared_from_this()); + std::vector parts; + { + GrrBarPart back; + back.text = U" " + u.icon("back") + U" "; + back.fn = [&sh] { sh.router.back("/"); }; + back.bg = "card"; + back.bold = true; + parts.push_back(back); + } + TuiValue c = chat; + std::string kind = c.truthy() && c.get("kind").is_str() ? c.get("kind").as_str() : ""; + if (c.truthy() && kind == "bot") { + auto bs = bots(); + TuiValue b = bs.empty() ? TuiValue() : bs[0]; + SpanRow chip = b.truthy() ? GrrAvatar::chip(u, b) : SpanRow{}; + chip.push_back(TuiSpan(U" " + (b.truthy() ? su(b.get("name")) : su(c.get("title", TuiValue("")))) + U" ", u.st("ink", "card", true))); + GrrBarPart f1; + f1.fill = true; + GrrBarPart mid; + SpanRow spans{TuiSpan(U" ", u.st("ink", "card"))}; + spans.insert(spans.end(), chip.begin(), chip.end()); + mid.spans = spans; + if (b.truthy()) { + std::string bid = b.get("id").str(); + mid.fn = [&sh, bid] { sh.router.go("/bot/" + bid); }; + } + GrrBarPart f2; + f2.fill = true; + parts.push_back(f1); + parts.push_back(mid); + parts.push_back(f2); + } else if (c.truthy() && kind == "group") { + SpanRow chip{TuiSpan(U" ", u.st("ink", "card"))}; + auto bs = bots(); + for (size_t i = 0; i < bs.size() && i < 3; i++) { + TuiValue col_v = bs[i].get("color"); + chip.push_back(TuiSpan(U"●", mk_style(TuiColor(col_v.truthy() ? col_v.str() : u.palette.c("accent")), TuiColor(u.palette.c("card"))))); + } + chip.push_back(TuiSpan(U" " + su(c.get("title", TuiValue(""))) + U" ", u.st("ink", "card", true))); + GrrBarPart f1; + f1.fill = true; + GrrBarPart mid; + mid.spans = chip; + mid.fn = [self] { self->members(); }; + GrrBarPart f2; + f2.fill = true; + parts.push_back(f1); + parts.push_back(mid); + parts.push_back(f2); + } else { + GrrBarPart t; + t.text = (c.truthy() && c.get("title").truthy()) ? su(c.get("title")) : (is_private() ? sh.t("chat.private") : UStr()); + t.fill = true; + t.align = "center"; + t.bold = true; + parts.push_back(t); + } + if (is_general() && !c.truthy()) { + GrrBarPart p; + p.text = U" " + u.icon("incognito") + U" "; + p.fn = [self] { self->toggle_private(); }; + p.bg = private_ ? "accent" : "card"; + p.fg = private_ ? "accent-ink" : "ink"; + parts.push_back(p); + } + if (pane) { + GrrBarPart p; + p.text = U" " + u.icon("panel") + U" "; + GrrPane* pp = pane.get(); + p.fn = [pp] { pp->toggle(); }; + p.bg = pane->is_open ? "accent" : "card"; + p.fg = pane->is_open ? "accent-ink" : "ink"; + parts.push_back(p); + } + if (c.truthy()) { + GrrBarPart p; + p.text = U" " + u.icon("more") + U" "; + p.fn = [self] { self->menu(); }; + p.bg = "card"; + parts.push_back(p); + } + bar->set(parts); + refresh_modebar(); + box->set_placeholder(placeholder()); +} + +void GrrChatView::refresh_modebar() { + GrrShell& sh = g; + GrrUi& u = *ui; + auto self = std::static_pointer_cast(shared_from_this()); + bool general = is_general(); + modebar->set_visible(general); + if (!general) return; + std::vector modes, tiers; + if (sh.spec.get("modes").truthy()) for (auto& m : sh.spec.get("modes").items()) modes.push_back(m.str()); + else modes = {"ask", "imagine", "build"}; + if (sh.spec.get("tiers").truthy()) for (auto& m : sh.spec.get("tiers").items()) tiers.push_back(m.str()); + else tiers = {"auto", "fast", "expert"}; + int width = std::max(10, col->aw ? col->aw : sh.app->cols); + long need = 0; + for (auto& m : modes) need += static_cast(sh.t("chat.mode_" + m).size()) + 3; + need += static_cast(sh.t("tier." + tier).size()) + 8; + std::vector parts; + if (need > width) { + GrrBarPart p; + p.text = U" " + sh.t("chat.mode_" + mode) + U" " + u.icon("down") + U" "; + p.fn = [self, &sh, modes] { + std::vector entries; + for (auto& m : modes) { + GrrMenuEntry e; + e.label = sh.t("chat.mode_" + m); + e.checked = m == self->mode; + std::string mm = m; + e.action = [self, mm] { self->set_mode(mm); }; + entries.push_back(e); + } + sh.menu(entries, std::nullopt, true); + }; + p.bg = "card"; + p.bold = true; + parts.push_back(p); + } else { + for (auto& m : modes) { + bool on = m == mode; + GrrBarPart p; + p.text = U" " + sh.t("chat.mode_" + m) + U" "; + std::string mm = m; + p.fn = [self, mm] { self->set_mode(mm); }; + p.bg = on ? "accent" : "bg"; + p.fg = on ? "accent-ink" : "muted"; + p.bold = on; + parts.push_back(p); + } + } + GrrBarPart fill; + fill.fill = true; + parts.push_back(fill); + GrrBarPart tp; + tp.text = U" " + u.icon("sparkle") + U" " + sh.t("tier." + tier) + U" " + u.icon("down") + U" "; + tp.fn = [self, &sh, tiers] { + std::vector entries; + for (auto& x : tiers) { + GrrMenuEntry e; + e.label = sh.t("tier." + x); + e.checked = x == self->tier; + std::string xx = x; + e.action = [self, xx] { self->set_tier(xx); }; + entries.push_back(e); + } + sh.menu(entries); + }; + tp.bg = "card"; + parts.push_back(tp); + modebar->set(parts); +} + +void GrrChatView::set_mode(const std::string& m) { + mode = m; + refresh_head(); + paint_all(); + if (chat.truthy()) call("PATCH", "/api/chats/" + chat.get("id").str(), TuiValue::object({{"mode", m}})); +} + +void GrrChatView::set_tier(const std::string& t) { + tier = t; + refresh_modebar(); + if (chat.truthy()) call("PATCH", "/api/chats/" + chat.get("id").str(), TuiValue::object({{"tier", t}})); +} + +void GrrChatView::toggle_private() { + private_ = !private_; + refresh_head(); + paint_all(); +} + +void GrrChatView::members() { + GrrShell& sh = g; + std::vector> rows; + for (auto& b : bots()) { + GrrRowOpts o; + o.lead = [b](GrrUi& ui_, const std::string& bg) { + SpanRow chip = GrrAvatar::chip(ui_, b, bg); + chip.push_back(TuiSpan(U" ", ui_.st("ink", bg))); + return chip; + }; + o.chevron = "1"; + std::string id = b.get("id").str(); + o.action = [&sh, id] { sh.router.go("/bot/" + id); }; + rows.push_back(std::make_shared(su(b.get("name")), o)); + } + sh.pick(su(chat.get("title", TuiValue(""))), rows, sh.t("chat.members_hint")); +} + +void GrrChatView::menu() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + TuiValue c = chat; + std::vector entries; + auto add = [&](const char* icon, const UStr& label, GrrFn action, bool danger = false) { + GrrMenuEntry e; + e.icon = icon; + e.label = label; + e.action = std::move(action); + e.danger = danger; + entries.push_back(e); + }; + if (c.get("kind").is_str() && c.get("kind").as_str() == "bot") { + auto bs = bots(); + TuiValue b = bs.empty() ? TuiValue() : bs[0]; + add("user", sh.t("chat.view_profile"), [&sh, b] { if (b.truthy()) sh.router.go("/bot/" + b.get("id").str()); }); + add("share", sh.t("profile.share_template"), [self] { self->share_template(); }); + entries.push_back(GrrMenuEntry::sep()); + add("refresh", sh.t("chat.clear"), [self] { self->clear_chat(); }); + add("trash", sh.t("profile.delete_bot"), [self] { self->delete_bot(); }, true); + } else { + if (c.get("kind").is_str() && c.get("kind").as_str() == "group") add("group", sh.t("chat.members"), [self] { self->members(); }); + bool pinned = c.get("pinned").truthy(); + std::string id = c.get("id").str(); + add("pin", pinned ? sh.t("chat.unpin") : sh.t("chat.pin"), [self, id, pinned] { self->call("PATCH", "/api/chats/" + id, TuiValue::object({{"pinned", !pinned}})); }); + add("edit", sh.t("chat.rename"), [self] { self->rename(); }); + entries.push_back(GrrMenuEntry::sep()); + add("trash", sh.t("ui.delete"), [self] { self->delete_chat(); }, true); + } + sh.menu(entries); +} + +void GrrChatView::rename() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + sh.edit(sh.t("chat.rename"), su(chat.get("title", TuiValue(""))), [self](const UStr& v) { + self->call("PATCH", "/api/chats/" + self->chat.get("id").str(), TuiValue::object({{"title", TuiStr::s(v)}}), [self](const TuiValue& c) { + for (auto& p : c.pairs()) self->chat.set(p.first, p.second); + self->refresh_head(); + }); + }); +} + +void GrrChatView::share_template() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + sh.confirm(sh.t("profile.share_title"), sh.t("profile.share_text"), sh.t("ui.continue"), [self] { self->send_text(TuiStr::u(GrrConst::template_ask())); }); +} + +void GrrChatView::clear_chat() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + sh.confirm(sh.t("chat.clear"), sh.t("chat.clear_text"), sh.t("chat.clear"), [self] { + self->call("DELETE", "/api/chats/" + self->chat.get("id").str(), std::nullopt, [self](const TuiValue&) { + self->messages.clear(); + self->paint_all(); + }); + }, true); +} + +void GrrChatView::delete_bot() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + auto bs = bots(); + if (bs.empty()) return; + TuiValue b = bs[0]; + sh.confirm(sh.t("profile.delete_bot"), sh.t("profile.delete_text", {{"name", b.get("name").str()}}), sh.t("ui.delete"), [self, &sh, b] { + self->call("DELETE", "/api/bots/" + b.get("id").str(), std::nullopt, [&sh](const TuiValue&) { + sh.store.refresh([&sh](std::exception_ptr) { sh.router.go("/bots", true); }); + }); + }, true); +} + +void GrrChatView::delete_chat() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + sh.confirm(sh.t("chat.delete_title"), sh.t("chat.delete_text"), sh.t("ui.delete"), [self, &sh] { + std::string cid = self->chat.get("id").str(); + self->chat.set("kind", "x"); + self->call("DELETE", "/api/chats/" + cid, std::nullopt, [&sh](const TuiValue&) { sh.router.go("/", true); }); + }, true); +} + +// ---- messages ------------------------------------------------------------------------------------------- + +GrrItemP GrrChatView::make_item(const TuiValue& m) { + std::string kind = m.get("kind").is_str() ? m.get("kind").as_str() : ""; + if (m.get("role").is_str() && m.get("role").as_str() == "user") return std::make_shared(this, m); + if (kind == "approval") return std::make_shared(this, m); + if (kind == "question") return std::make_shared(this, m); + if (kind == "process") return std::make_shared(this, m); + if (kind == "file") return std::make_shared(this, m); + if (kind == "files") return std::make_shared(this, m); + if (kind == "template") return std::make_shared(this, m); + return std::make_shared(this, m); +} + +int GrrChatView::newest_boundary() { + long long n = chat.truthy() && chat.get("unread").truthy() ? chat.get("unread").as_int() : 0; + if (!n) return -1; + long long count = 0; + for (int i = static_cast(messages.size()) - 1; i >= 0; i--) { + const TuiValue& role = messages[static_cast(i)].get("role"); + if (!(role.is_str() && role.as_str() == "user")) count++; + if (count == n) return i; + } + return -1; +} + +void GrrChatView::paint_all() { + GrrShell& sh = g; + GrrUi& u = *ui; + auto self = std::static_pointer_cast(shared_from_this()); + for (auto& p : items) p.second->destroy(); + items.clear(); + std::vector entries; + if (messages.empty()) { + entries.push_back(empty_item()); + } else { + if (has_more) entries.push_back(std::make_shared(sh.t("chat.older"), [self] { self->older(); }, "ghost", true)); + const TuiValue* prev = nullptr; + int new_at = newest_boundary(); + for (size_t i = 0; i < messages.size(); i++) { + const TuiValue& m = messages[i]; + if (static_cast(i) == new_at) entries.push_back(std::make_shared(sh.t("chat.new"), true)); + if (!prev || number(m.get("ts", TuiValue(0))) - number(prev->get("ts", TuiValue(0))) > 1800) { + entries.push_back(std::make_shared(u.fmt.stamp(number(m.get("ts", TuiValue(0)))))); + } + GrrItemP item = make_item(m); + put_item(m.get("id").str(), item); + entries.push_back(item); + prev = &m; + } + mark_last(); + } + log->set_items(entries); + for (auto& m : std::vector(messages)) prompt_approval(m); +} + +void GrrChatView::prompt_approval(const TuiValue& m) { + GrrShell& sh = g; + TuiValue meta = m.get("meta").truthy() ? m.get("meta") : TuiValue::dict(); + std::string mid = m.get("id").str(); + if (!(m.get("kind").is_str() && m.get("kind").as_str() == "approval") || !(meta.get("state").is_str() && meta.get("state").as_str() == "pending") || prompts.count(mid) || + static_cast(prompts.size()) >= GRR_PROMPT_MAX) return; + auto self = std::static_pointer_cast(shared_from_this()); + TuiValue bot = bot_of(m.get("sender")); + if (!bot.truthy()) { + auto bs = bots(); + bot = bs.empty() ? TuiValue::object({{"name", "Bot"}}) : bs[0]; + } + GrrDialogSpec d; + d.done = [self, &sh, mid](const TuiValue& value, const std::vector&) { + self->prompts.erase(mid); + if (value.truthy()) sh.call("POST", "/api/messages/" + mid + "/decision", TuiValue::object({{"decision", value}})); + }; + std::vector actions = {GrrAction{sh.t("chat.deny"), TuiValue("deny")}}; + if (meta.get("tool").is_str() && meta.get("tool").as_str() == "browser") actions.push_back(GrrAction{sh.t("chat.always_allow"), TuiValue("always")}); + actions.push_back(GrrAction{sh.t("chat.allow_once"), TuiValue("allow"), true}); + d.actions = actions; + d.title = sh.t("chat.needs_approval", {{"name", bot.get("name").str()}}); + auto lines = GrrText::wrap(text_of(m.get("content")), 60); + if (lines.size() > 12) lines.resize(12); + d.text = TuiStr::join(lines, U"\n"); + prompts[mid] = sh.dialog(std::move(d)); + log->scroll_to_end(); +} + +GrrItemP GrrChatView::empty_item() { + GrrShell& sh = g; + if (is_general()) { + bool priv = is_private(); + std::string icon = priv ? "incognito" : (mode == "imagine" ? "image" : (mode == "build" ? "terminal" : "sparkle")); + UStr title = priv ? sh.t("chat.private") : sh.t("chat.empty_" + mode); + UStr sub = priv ? sh.t("chat.private_text") : sh.t("chat.empty_" + mode + "_sub"); + return std::make_shared(icon, title, sub); + } + auto bs = bots(); + TuiValue bot = bs.empty() ? TuiValue() : bs[0]; + class Intro : public GrrItem { + public: + Intro(GrrChatView* v, TuiValue b) : view(v), bot(std::move(b)) {} + SpanRows build(GrrUi& ui_, int width, bool) override { + SpanRows out; + for (int i = 0; i < 2; i++) out.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), ui_.st())}); + if (bot.truthy()) { + for (auto& row : GrrAvatar::rows(ui_, bot, "bg", false, true)) { + SpanRow r{TuiSpan(UStr(static_cast(std::max(0, (width - TuiSpans::width(row)) / 2)), U' '), ui_.st())}; + r.insert(r.end(), row.begin(), row.end()); + out.push_back(GrrText::line(r, width, ui_.st())); + } + } + out.push_back(SpanRow{TuiSpan(TuiStr::center(GrrText::clip(TuiStr::u(view->chat.get("title", TuiValue("")).str()), width - 4), width), ui_.st("ink", "bg", true))}); + UStr desc = bot.truthy() ? TuiStr::u(bot.get("description").str()) : view->g.t("chat.group_empty"); + auto lines = GrrText::wrap(desc, width - 8); + if (lines.size() > 4) lines.resize(4); + for (auto& line : lines) out.push_back(SpanRow{TuiSpan(TuiStr::center(line, width), ui_.st("muted", "bg"))}); + return out; + } + GrrChatView* view; + TuiValue bot; + }; + return std::make_shared(this, bot); +} + +void GrrChatView::mark_last() { + std::string last_id; + for (auto it = messages.rbegin(); it != messages.rend(); ++it) { + bool user = it->get("role").is_str() && it->get("role").as_str() == "user"; + if (!user && it->get("kind").is_str() && it->get("kind").as_str() == "text") { + last_id = it->get("id").str(); + break; + } + if (user) break; + } + for (auto& p : items) { + if (auto* mi = dynamic_cast(p.second.get())) mi->set_last(p.first == last_id); + } +} + +void GrrChatView::on_new(const TuiValue& m) { + if (!chat.truthy() || !(m.get("chat_id") == chat.get("id")) || find_item(m.get("id").str())) return; + if (messages.empty()) { + messages.push_back(m); + paint_all(); + return; + } + TuiValue prev = messages.back(); + messages.push_back(m); + if (static_cast(messages.size()) > GRR_MESSAGE_KEEP) { + TuiValue drop = messages.front(); + messages.erase(messages.begin()); + std::string did = drop.get("id").str(); + for (auto it = items.begin(); it != items.end(); ++it) { + if (it->first == did) { + it->second->destroy(); + items.erase(it); + break; + } + } + has_more = true; + } + if (number(m.get("ts", TuiValue(0))) - number(prev.get("ts", TuiValue(0))) > 1800) log->append(std::make_shared(ui->fmt.stamp(number(m.get("ts", TuiValue(0)))))); + GrrItemP item = make_item(m); + put_item(m.get("id").str(), item); + log->append(item); + mark_last(); + prompt_approval(m); + bool user = m.get("role").is_str() && m.get("role").as_str() == "user"; + if (user || log->follow) log->scroll_to_end(); + else new_below = true; + if (!user) g.client_call_quiet("POST", "/api/chats/" + chat.get("id").str() + "/read", TuiValue::dict()); +} + +void GrrChatView::on_update(const TuiValue& m) { + if (!chat.truthy() || !(m.get("chat_id") == chat.get("id"))) return; + bool found = false; + for (auto& old : messages) if (old.get("id") == m.get("id")) { old = m; found = true; break; } + if (!found) return; + std::string id = m.get("id").str(); + GrrItemP item = find_item(id); + auto it = prompts.find(id); + std::shared_ptr dlg; + if (it != prompts.end()) { + dlg = it->second; + prompts.erase(it); + } + TuiValue meta = m.get("meta").truthy() ? m.get("meta") : TuiValue::dict(); + if (dlg && !(meta.get("state").is_str() && meta.get("state").as_str() == "pending")) g.close_dialog(dlg, TuiValue(), true); + else if (dlg) prompts[id] = dlg; + if (auto* pi = dynamic_cast(item.get())) { + pi->update(m); + return; + } + if (!item) return; + GrrItemP fresh = make_item(m); + put_item(id, fresh); + std::vector entries; + for (auto& e : log->items()) entries.push_back(e == item ? fresh : e); + log->set_items(entries); + mark_last(); +} + +void GrrChatView::on_status(const TuiValue& s) { + if (chat.truthy() && s.get("chat_id") == chat.get("id")) set_send_mode(); +} + +void GrrChatView::on_chat_update(const TuiValue& d) { + if (chat.truthy() && d.truthy() && d.get("id") == chat.get("id")) { + auto self = std::static_pointer_cast(shared_from_this()); + get("/api/chats/" + chat.get("id").str(), [self](const TuiValue& c) { + for (auto& p : c.pairs()) self->chat.set(p.first, p.second); + self->set_send_mode(); + self->refresh_head(); + }); + } +} + +void GrrChatView::on_bottom(bool at_bottom) { + if (at_bottom) new_below = false; +} + +void GrrChatView::catch_up() { + auto self = std::static_pointer_cast(shared_from_this()); + get("/api/chats/" + chat.get("id").str() + "/messages", [self](const TuiValue& page) { + for (auto& m : list_of(page.get("messages"))) { + if (!self->find_item(m.get("id").str())) self->on_new(m); + } + }); +} + +void GrrChatView::older() { + if (messages.empty()) return; + auto self = std::static_pointer_cast(shared_from_this()); + get("/api/chats/" + chat.get("id").str() + "/messages?before=" + messages.front().get("id").str(), [self](const TuiValue& page) { + std::vector merged = list_of(page.get("messages")); + merged.insert(merged.end(), self->messages.begin(), self->messages.end()); + self->messages = merged; + self->has_more = page.get("has_more").truthy(); + self->paint_all(); + }); +} + +// ---- working row ------------------------------------------------------------------------------------------- + +std::vector GrrChatView::active() { return chat.truthy() ? g.store.active_in(chat.get("id").str()) : std::vector{}; } + +bool GrrChatView::is_busy() { return chat.truthy() && (chat.get("busy").truthy() || !active().empty()); } + +void GrrChatView::tick() { + spin++; + paint_working(); +} + +void GrrChatView::paint_working() { + GrrShell& sh = g; + GrrUi& u = *ui; + auto act = active(); + if (act.empty() && !(chat.truthy() && chat.get("busy").truthy())) { + UStr hint = new_below ? U" ⌄ " + sh.t("chat.to_bottom") + U" (End)" : UStr(); + if (!hint.empty()) working->set_row(SpanRow{TuiSpan(hint, u.st("accent", "bg"))}); + else working->set(SpanRows{}); + return; + } + TuiValue s = act.empty() ? TuiValue() : act[0]; + TuiValue bot; + if (s.truthy()) bot = bot_of(s.get("bot_id")); + else { auto bs = bots(); bot = bs.empty() ? TuiValue() : bs[0]; } + UStr name = bot.truthy() ? su(bot.get("name")) : UStr(U"grr"); + static const UStr frames = U"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + UStr frame(1, frames[static_cast(spin % static_cast(frames.size()))]); + UStr label = s.truthy() && s.get("label").truthy() ? su(s.get("label")) : UStr(); + UStr is_working = sh.t("chat.is_working", {{"name", TuiStr::s(name)}}); + UStr text = is_working + (!label.empty() ? U" · " + TuiStr::head(label, 60) : UStr()); + if (label.empty()) text = is_working + U" " + TuiStr::repeat(U'.', 1 + (spin / 3) % 3); + SpanRow row{TuiSpan(U" " + frame + U" ", u.st("accent", "bg", true))}; + if (bot.truthy()) { + SpanRow chip = GrrAvatar::chip(u, bot); + row.insert(row.end(), chip.begin(), chip.end()); + } + row.push_back(TuiSpan(U" " + text, u.st("muted", "bg"))); + working->set_row(row); +} + +// ---- composer ------------------------------------------------------------------------------------------------ + +void GrrChatView::fit_box() { + int rows = std::max(1, std::min(GRR_COMPOSER_MAX_ROWS, static_cast(box->lines.size()))); + if (!(box->h().is_int() && box->h().n == rows)) { + box->set_h(TuiSpec(rows)); + plus->set_h(TuiSpec(rows)); + send_btn->set_h(TuiSpec(rows)); + composer->set_h(TuiSpec(rows + 3)); + } + set_send_mode(); +} + +void GrrChatView::set_send_mode() { + bool is_b = is_busy(); + bool has = !TuiStr::strip(box->value()).empty() || !attachments.empty(); + send_btn->set_label(U" " + ui->icon(is_b ? "stop" : (has ? "send" : "wave")) + U" "); + send_mode = is_b ? "stop" : (has ? "send" : "voice"); + paint_attach(); +} + +void GrrChatView::send_action() { + if (send_mode == "stop") { + if (chat.truthy()) call("POST", "/api/chats/" + chat.get("id").str() + "/stop", TuiValue::dict()); + } else if (send_mode == "voice") { + g.toast(U"Voice needs a microphone: use the web app for spoken conversations."); + } else { + on_send(); + } +} + +void GrrChatView::on_send() { + if (is_busy() && TuiStr::strip(box->value()).empty()) return; + send_text(TuiStr::strip(box->value())); +} + +void GrrChatView::paint_attach() { + GrrUi& u = *ui; + attach_row->set_visible(!attachments.empty()); + if (!attachments.empty()) { + SpanRow spans{TuiSpan(U" ", u.st())}; + for (auto& a : attachments) { + spans.push_back(TuiSpan(U" " + u.icon("file") + U" " + su(a.get("name")) + (a.get("uploading").truthy() ? U"…" : U"") + U" ", u.st("soft", "card"))); + spans.push_back(TuiSpan(U" ", u.st())); + } + attach_row->set_row(spans); + } +} + +void GrrChatView::attach_menu() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + auto mk = [](const char* icon, const UStr& label, GrrFn action) { + GrrMenuEntry e; + e.icon = icon; + e.label = label; + e.action = std::move(action); + return e; + }; + sh.menu({mk("image", sh.t("chat.gallery"), [self] { self->pick_file(); }), mk("file", sh.t("chat.files"), [self] { self->pick_file(); }), GrrMenuEntry::sep(), + mk("skills", sh.t("chat.skills"), [self] { self->skills_sheet(); }), mk("plug", sh.t("chat.connectors"), [&sh] { sh.router.go("/settings/connectors"); })}, + std::nullopt, true); +} + +void GrrChatView::pick_file() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + sh.edit(sh.t("chat.files"), UStr(), [self](const UStr& p) { self->add_file(p); }, false, 6, 0, UStr(U"Path of a file on this machine")); +} + +void GrrChatView::add_file(const UStr& raw) { + GrrShell& sh = g; + std::string path = expanduser(TuiStr::s(TuiStr::strip(raw))); + if (path.empty()) return; + std::string data; + { + struct stat st{}; + if (::stat(path.c_str(), &st) != 0) { + sh.toast(TuiStr::u("[Errno " + std::to_string(errno) + "] " + std::strerror(errno) + ": '" + path + "'"), "bad"); + return; + } + long long cap = sh.ui->cap("UPLOAD_MAX_BYTES", 20971520); + if (st.st_size > cap) { + sh.toast(sh.t("err.too_big", {{"max", TuiStr::s(GrrFormat::bytes(cap))}}), "bad"); + return; + } + std::ifstream in(path, std::ios::binary); + if (!in) { + sh.toast(TuiStr::u("[Errno " + std::to_string(errno) + "] " + std::strerror(errno) + ": '" + path + "'"), "bad"); + return; + } + std::stringstream ss; + ss << in.rdbuf(); + data = ss.str(); + } + std::string name = basename_of(path); + long chip_id = ++upload_seq_; + attachments.push_back(TuiValue::object({{"name", name}, {"uploading", true}, {"seq", static_cast(chip_id)}})); + set_send_mode(); + std::string chat_id_s = chat.truthy() ? chat.get("id").str() : ""; + std::string bot_id_s = chat.truthy() && chat.get("kind").is_str() && chat.get("kind").as_str() == "bot" ? chat.get("bot_ids").at(0).str() : ""; + auto self = std::static_pointer_cast(shared_from_this()); + sh.app->offload([&sh, name, data, chat_id_s, bot_id_s]() -> TuiValue { return sh.client.upload(name, data, chat_id_s, bot_id_s); }, + [self, &sh, chip_id](const TuiValue& item, std::exception_ptr err) { + auto it = std::find_if(self->attachments.begin(), self->attachments.end(), [&](const TuiValue& a) { return a.get("seq").as_int() == chip_id; }); + if (err || self->closed) { + if (it != self->attachments.end()) self->attachments.erase(it); + if (err) sh.error(err); + } else if (it != self->attachments.end()) { + it->set("id", item.get("id")); + it->set("uploading", false); + } + self->set_send_mode(); + }); +} + +void GrrChatView::skills_sheet() { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + auto bs = sh.store.bots; + if (bs.empty()) return; + std::vector>> calls; + for (auto& b : bs) calls.emplace_back("GET", "/api/bots/" + b.get("id").str() + "/skills", std::nullopt); + batch(calls, [self, &sh, bs](const TuiValue& res) { + std::vector> rows; + for (size_t i = 0; i < bs.size() && i < res.items().size(); i++) { + TuiValue b = bs[i]; + for (auto& s : list_of(res.at(i).get("skills"))) { + GrrRowOpts o; + o.sub = su(s.get("description", TuiValue(""))); + o.lead = [b](GrrUi& ui_, const std::string& bg) { + SpanRow chip = GrrAvatar::chip(ui_, b, bg); + chip.push_back(TuiSpan(U" ", ui_.st("ink", bg))); + return chip; + }; + TuiValue skill = s; + o.action = [self, b, skill] { self->insert_skill(b, skill); }; + rows.push_back(std::make_shared(su(s.get("name")), o)); + } + } + if (!rows.empty()) { + sh.pick(sh.t("chat.skills"), rows); + } else { + GrrDialogSpec d; + d.title = sh.t("chat.no_skills"); + d.text = sh.t("chat.no_skills_sub"); + sh.dialog(std::move(d)); + } + }); +} + +void GrrChatView::insert_skill(const TuiValue& bot, const TuiValue& skill) { + UStr text = TuiStr::strip(box->value() + U" @" + TuiStr::u(strip_spaces(bot.get("name").str())) + U" " + g.t("chat.use_skill", {{"name", skill.get("name").str()}})); + box->set_lines(TuiStr::split(text, U'\n')); + box->set_cy(static_cast(box->lines.size()) - 1); + box->set_cx(static_cast(box->lines.back().size())); + box->touch(); + g.app->set_focus(box.get()); +} + +void GrrChatView::ensure_chat(std::function then) { + GrrShell& sh = g; + if (chat.truthy()) { + then(chat); + return; + } + if (creating) return; + creating = true; + TuiValue body = TuiValue::object({{"kind", private_ ? "private" : "general"}, {"mode", mode}, {"tier", tier}}); + if (query.get("project").truthy()) body.set("project_id", query.get("project")); + auto self = std::static_pointer_cast(shared_from_this()); + call("POST", "/api/chats", body, [self, &sh, then](const TuiValue& c) { + self->creating = false; + self->chat = c; + self->params.set("id", c.get("id")); + sh.router.rewrite("/chat/" + c.get("id").str()); + sh.store.soon(); + self->refresh_head(); + then(c); + }, [self, &sh](std::exception_ptr e) { + self->creating = false; + sh.error(e); + }); +} + +void GrrChatView::send_text(const UStr& text) { + GrrShell& sh = g; + if (text.empty() && attachments.empty()) return; + for (auto& a : attachments) if (a.get("uploading").truthy()) { sh.toast(sh.t("chat.uploading")); return; } + TuiValue files = TuiValue::list(); + for (auto& a : attachments) if (a.get("id").truthy()) files.push(a.get("id")); + std::vector kept = attachments; + auto self = std::static_pointer_cast(shared_from_this()); + ensure_chat([self, &sh, text, files, kept](const TuiValue& c) { + TuiValue body = TuiValue::object({{"text", TuiStr::s(text)}, {"files", files}}); + if (self->is_general()) { + body.set("mode", self->mode); + body.set("tier", self->tier); + } + self->box->set_lines({UStr()}); + self->box->set_cx(0); + self->box->set_cy(0); + self->box->touch(); + self->attachments.clear(); + std::string cid = c.get("id").str(); + self->call("POST", "/api/chats/" + cid + "/messages", body, [self, cid](const TuiValue& res) { + TuiValue msg = res.get("message"); + TuiValue merged = TuiValue::object({{"chat_id", cid}}); + for (auto& p : msg.pairs()) merged.set(p.first, p.second); + self->on_new(merged); + self->chat.set("busy", true); + self->set_send_mode(); + self->log->scroll_to_end(); + }, [self, &sh, kept, text](std::exception_ptr err) { + self->attachments = kept; + auto ge = GrrError::from(err); + if (ge && (ge->code == "busy" || ge->code == "chat_busy")) sh.toast(ge->text()); + else sh.error(err); + if (self->box->value().empty()) { + self->box->set_lines(TuiStr::split(text, U'\n')); + self->box->set_cy(static_cast(self->box->lines.size()) - 1); + self->box->set_cx(static_cast(self->box->lines.back().size())); + } + self->set_send_mode(); + }); + }); +} + +void GrrChatView::answer(const TuiValue& m, const UStr& text) { + call("POST", "/api/messages/" + m.get("id").str() + "/answer", TuiValue::object({{"text", TuiStr::s(text)}})); +} + +void GrrChatView::rate(const TuiValue& m, const std::string& kind) { + GrrShell& sh = g; + UStr content = TuiStr::head(text_of(m.get("content")), 500); + TuiValue body = TuiValue::object({{"text", TuiStr::s((kind == "up" ? UStr(U"[good] ") : UStr(U"[bad] ")) + content)}, {"bot_id", m.get("sender")}}); + call("POST", "/api/feedback", body, [&sh](const TuiValue&) { sh.toast(sh.t("chat.thanks")); }); +} + +void GrrChatView::regenerate() { + for (auto it = messages.rbegin(); it != messages.rend(); ++it) { + if (it->get("role").is_str() && it->get("role").as_str() == "user" && it->get("content").truthy()) { + send_text(su(it->get("content"))); + return; + } + } +} + +bool GrrChatView::hotkey(const std::string& key) { + if (key == "ctrl+p" && pane) { + pane->toggle(); + return true; + } + return false; +} + +void GrrChatView::restore_pane() { + if (pane) pane->restore(); +} + +bool GrrChatView::on_escape() { + if (pane && pane->is_open && pane->layer_mode()) { + pane->hide(); + return true; + } + g.router.back("/"); + return true; +} + +// ---- computer --------------------------------------------------------------------------------------------------- + +void GrrComputerView::open() { + GrrUi& u = *ui; + started_ = false; + TuiPanelOpts ro; + ro.w = TuiSpec("1fr"); + ro.h = TuiSpec("100%"); + ro.style = u.st("ink", "bg"); + root_ = std::make_shared(ro); + TuiPanelOpts bo; + bo.style = u.st("ink", "bg"); + bar_ = std::make_shared(ui, bo); + root_->add(bar_); + term_ = nullptr; + show(root_); + auto self = std::static_pointer_cast(shared_from_this()); + get("/api/bots/" + params.get("id").str(), [self](const TuiValue& b) { self->loaded(b); }); +} + +void GrrComputerView::loaded(const TuiValue& bot) { + GrrShell& sh = g; + GrrUi& u = *ui; + auto self = std::static_pointer_cast(shared_from_this()); + bot_ = bot; + std::string path = "/api/bots/" + bot.get("id").str() + "/computer"; + TuiPanelOpts to; + to.w = TuiSpec("100%"); + to.h = TuiSpec("1fr"); + to.border = false; + to.style = u.st("ink", "sunken"); + term_ = std::make_shared(sh, path + "/stream?from=0", path + "/input", to); + root_->add(term_); + term_->on_leave = [self] { self->done(); }; + TuiPanelOpts fo; + fo.style = u.st("ink", "bg"); + foot_ = std::make_shared(ui, fo); + root_->add(foot_); + auto part = [&](const UStr& text, GrrFn fn) { + GrrBarPart p; + p.text = text; + p.fn = std::move(fn); + p.bg = "card"; + return p; + }; + GrrBarPart fill; + fill.fill = true; + GrrRemoteTerminal* t = term_.get(); + foot_->set({part(U" Ctrl+] " + sh.t("computer.done") + U" ", [self] { self->done(); }), fill, part(U" ^C ", [t] { t->queue("\x03"); }), part(U" ^L ", [t] { t->queue("\x0c"); }), + part(U" ^D ", [t] { t->queue("\x04"); })}); + GrrBarPart back = part(U" " + u.icon("back") + U" ", [self] { self->done(); }); + back.bold = true; + GrrBarPart who; + SpanRow spans{TuiSpan(U" ", u.st("ink", "card"))}; + SpanRow chip = GrrAvatar::chip(u, bot); + spans.insert(spans.end(), chip.begin(), chip.end()); + spans.push_back(TuiSpan(U" " + TuiStr::u(bot.get("name").str()) + U" ", u.st("ink", "card", true))); + who.spans = spans; + bar_->set({back, who, fill, part(U" " + u.icon("help") + U" ", [self] { self->help(); })}); + call("POST", path + "/input", TuiValue::object({{"start", true}, {"cols", 80}, {"rows", 24}}), [self](const TuiValue&) { self->attached(); }, [&sh](std::exception_ptr e) { sh.error(e); }); + sh.app->set_focus(term_.get()); +} + +void GrrComputerView::attached() { + started_ = true; + term_->attach(); + term_->send_size(); +} + +void GrrComputerView::help() { + GrrShell& sh = g; + GrrDialogSpec d; + d.title = sh.t("computer.help_title"); + d.text = sh.t("computer.help_scroll_t") + U"\n\n" + sh.t("computer.help_keyboard_t") + U"\n\n" + sh.t("computer.help_done_t") + U"\n\nCtrl+] " + sh.t("computer.done"); + d.w = 70; + sh.dialog(std::move(d)); +} + +void GrrComputerView::done() { + GrrShell& sh = g; + if (started_) sh.client_call_quiet("POST", "/api/bots/" + bot_.get("id").str() + "/computer/input", TuiValue::object({{"release", true}})); + if (term_) term_->detach(); + sh.router.back("/bot/" + params.get("id").str() + "?tab=computer"); +} + +void GrrComputerView::destroy() { + if (term_) term_->detach(); + if (started_) g.client_call_quiet("POST", "/api/bots/" + bot_.get("id").str() + "/computer/input", TuiValue::object({{"release", true}})); + GrrView::destroy(); +} + +// ---- screen ------------------------------------------------------------------------------------------------------- + +void GrrScreenView::open() { + GrrShell& sh = g; + GrrScreenOpts so; + so.back = "/bot/" + params.get("id").str() + "?tab=computer"; + standard(sh.t("screen.title"), so); + sub(sh.events, "preview.ready", [](const TuiValue&) {}); + reload(); +} + +void GrrScreenView::reload() { + auto self = std::static_pointer_cast(shared_from_this()); + get("/api/bots/" + params.get("id").str() + "/desktop", [self](const TuiValue& d) { self->paint(d); }); +} + +void GrrScreenView::paint(const TuiValue& d) { + GrrShell& sh = g; + auto self = std::static_pointer_cast(shared_from_this()); + std::string bid = params.get("id").str(); + std::string url = sh.client.base + "/#/bot/" + bid + "/screen"; + std::vector items; + UStr sub_text = d.get("running").truthy() ? UStr(U"running") : TuiStr::u(TuiStr::s(sh.t("screen.starting")).substr(0, TuiStr::s(sh.t("screen.starting")).find_last_not_of('.') + 1)); + items.push_back(std::make_shared("monitor", sh.t("screen.title"), sub_text)); + items.push_back(std::make_shared(std::vector>{ + {U"Display", TuiStr::u(d.get("width").str() + "x" + d.get("height").str())}, + {U"Screens", TuiStr::u(d.get("clients", TuiValue(0)).str() + " / " + d.get("max").str())}, + {U"Browser", d.get("browser").truthy() ? UStr(U"yes") : sh.t("screen.no_browser")}})); + if (d.get("running").truthy()) { + items.push_back(std::make_shared(sh.t("screen.stop"), [self, &sh, bid] { + sh.confirm(sh.t("screen.stop"), sh.t("screen.stop_text"), sh.t("screen.stop"), [self, bid] { + self->call("POST", "/api/bots/" + bid + "/desktop/stop", TuiValue::dict(), [self](const TuiValue&) { self->reload(); }); + }, true); + }, "secondary", true)); + } else { + items.push_back(std::make_shared(sh.t("profile.take_control"), [self, bid] { + self->call("POST", "/api/bots/" + bid + "/desktop/start", TuiValue::dict(), [self](const TuiValue&) { self->reload(); }); + }, "primary", true)); + } + items.push_back(std::make_shared(UStr(U"Open in browser"), [&sh, url] { sh.open_url(url); }, "secondary", true, "external")); + items.push_back(std::make_shared(UStr(U"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."), true)); + screen->items(items); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrChat.h b/taicpp/src/tui/GrrChat.h new file mode 100644 index 0000000..6da9130 --- /dev/null +++ b/taicpp/src/tui/GrrChat.h @@ -0,0 +1,156 @@ +#pragma once +// Chat of the grr client (tai.py Part F): message items, the composer, the +// working row, live process cards, the remote terminal of a bot's computer, the +// chat view, the computer view and the screen view. +#include "GrrBar.h" +#include "GrrChatItems.h" +#include "GrrItems.h" +#include "GrrPage.h" +#include "GrrView.h" +#include "TuiButton.h" +#include "TuiChatRows.h" +#include "TuiRich.h" +#include "TuiTerminal.h" +#include "TuiTextBox.h" +namespace detz::tui { + +class GrrPane; +class GrrChatView; + +// The chat input: PageUp and PageDown scroll the conversation when the message is a single line. +class GrrComposerBox : public TuiTextBox { +public: + GrrComposerBox(GrrChatView* view, UStr text, CompleteFn complete, CompleteFn mentions, bool send_on_enter, std::function on_send, + long max_chars, UStr placeholder, TuiPanelOpts opts); + void on_key(TuiKey& event) override; + GrrChatView* view; +}; + +// Bot chats, group chats and general chats (ask, imagine, build, private) with +// the live message stream, composer and side pane. +class GrrChatView : public GrrView { +public: + using GrrView::GrrView; + ~GrrChatView() override; + void open() override; + void destroy() override; + bool on_escape() override; + bool hotkey(const std::string& key) override; + GrrBar* header_bar() override { return bar.get(); } + bool is_chat() const override { return true; } + std::string chat_id() const override { return chat.truthy() ? chat.get("id").str() : std::string(); } + + // used by the message items + TuiValue bot_of(const TuiValue& bot_id); + std::vector bots(); + void rate(const TuiValue& m, const std::string& kind); + void regenerate(); + void answer(const TuiValue& m, const UStr& text); + + // state (python attributes) + TuiValue chat; + std::vector messages; + std::vector> items; // insertion ordered dict + std::vector attachments; + std::string mode, tier; + bool private_{false}, has_more{false}, busy{false}, new_below{false}; + std::map> prompts; + long spin{0}; + std::shared_ptr pane; + std::shared_ptr root; + std::shared_ptr col; + std::shared_ptr bar, modebar; + std::shared_ptr log; + std::shared_ptr working, attach_row; + std::shared_ptr box; + std::shared_ptr plus, send_btn; + std::shared_ptr composer; + std::string send_mode; + bool creating{false}; + long upload_seq_{0}; + void refresh_head(); + +private: + GrrItemP find_item(const std::string& id) const; + void put_item(const std::string& id, const GrrItemP& item); + void loaded(const TuiValue& res); + bool chat_is_bot(); + bool is_general(); + bool is_private(); + UStr placeholder(); + void refresh_modebar(); + void set_mode(const std::string& m); + void set_tier(const std::string& t); + void toggle_private(); + void members(); + void menu(); + void rename(); + void share_template(); + void clear_chat(); + void delete_bot(); + void delete_chat(); + GrrItemP make_item(const TuiValue& m); + int newest_boundary(); + void paint_all(); + void prompt_approval(const TuiValue& m); + GrrItemP empty_item(); + void mark_last(); + void on_new(const TuiValue& m); + void on_update(const TuiValue& m); + void on_status(const TuiValue& s); + void on_chat_update(const TuiValue& d); + void on_bottom(bool at_bottom); + void catch_up(); + void older(); + std::vector active(); + bool is_busy(); + void tick(); + void paint_working(); + void fit_box(); + void set_send_mode(); + void send_action(); + void on_send(); + void paint_attach(); + void attach_menu(); + void pick_file(); + void add_file(const UStr& path); + void skills_sheet(); + void insert_skill(const TuiValue& bot, const TuiValue& skill); + void ensure_chat(std::function then); + void send_text(const UStr& text); + void restore_pane(); + void queue_read(); + friend class GrrPane; +}; + +class GrrComputerView : public GrrView { +public: + using GrrView::GrrView; + void open() override; + void destroy() override; + bool on_escape() override { return true; } + GrrBar* header_bar() override { return bar_.get(); } +private: + void loaded(const TuiValue& bot); + void attached(); + void help(); + void done(); + bool started_{false}; + std::shared_ptr root_; + std::shared_ptr bar_, foot_; + std::shared_ptr term_; + TuiValue bot_; +}; + +// 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. +class GrrScreenView : public GrrView { +public: + using GrrView::GrrView; + void open() override; +private: + void reload(); + void paint(const TuiValue& d); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrChatItems.cpp b/taicpp/src/tui/GrrChatItems.cpp new file mode 100644 index 0000000..2fc2552 --- /dev/null +++ b/taicpp/src/tui/GrrChatItems.cpp @@ -0,0 +1,747 @@ +#include "GrrChatItems.h" +#include "GrrChat.h" +#include "GrrShell.h" +#include +#include +namespace detz::tui { + +namespace { +UStr spaces(long n) { return UStr(static_cast(std::max(0L, n)), U' '); } +UStr su(const TuiValue& v) { return TuiStr::u(v.str()); } +UStr text_of(const TuiValue& v) { return GrrText::text(v); } +TuiValue meta_of(const TuiValue& m) { return m.get("meta").truthy() ? m.get("meta") : TuiValue::dict(); } +std::vector list_of(const TuiValue& v) { return v.is_list() ? v.items() : std::vector{}; } +std::string state_name(const TuiValue& v) { return v.is_str() ? v.as_str() : v.is_null() ? std::string("None") : v.str(); } +} // namespace + +TuiChatRows::Styles grr_bubble_styles(GrrUi& ui, const std::string& side) { + std::string bg = side == "out" ? "bubble-user" : "bubble-bot"; + TuiChatRows::Styles s; + s["base"] = ui.st("ink", "bg"); + s["bubble"] = ui.st("ink", bg); + s["meta"] = ui.st("muted", bg); + s["tick"] = ui.st("muted", bg); + s["name"] = ui.st("muted", bg, true); + s["b"] = ui.st("ink", bg, true); + s["i"] = ui.st("soft", bg); + s["c"] = ui.st("soft", "code-bg"); + s["code"] = ui.st("accent", "code-bg"); + s["h"] = ui.st("accent", bg, true); + s["bullet"] = ui.st("accent", bg, true); + s["quote"] = ui.st("muted", bg); + s["bar"] = ui.st("accent", bg); + return s; +} + +bool GrrControlsItem::nav(const std::string& direction) { + if ((direction == "left" || direction == "right") && controls.size() > 1) { + int nxt = std::max(0, std::min(static_cast(controls.size()) - 1, stop + (direction == "right" ? 1 : -1))); + if (nxt != stop) { + stop = nxt; + touch(); + } + return true; + } + return false; +} + +void GrrControlsItem::activate() { + if (!controls.empty()) controls[static_cast(std::min(stop, static_cast(controls.size()) - 1))].second(); +} + +// ---- message ------------------------------------------------------------------------------------ + +GrrMessageItem::GrrMessageItem(GrrChatView* v, TuiValue msg, bool last_flag) : view(v), m(std::move(msg)), last(last_flag) { + selectable = true; + key = m.get("id").str(); + rebuild_controls(); +} + +void GrrMessageItem::rebuild_controls() { + GrrShell& g = view->g; + controls.clear(); + if (m.get("role").is_str() && m.get("role").as_str() == "user") return; + TuiValue meta = meta_of(m); + if (!list_of(meta.get("steps")).empty()) controls.emplace_back(U"steps", [this] { toggle_steps(); }); + TuiValue mm = m; + controls.emplace_back(U"copy", [&g, mm] { g.copy_text(text_of(mm.get("content"))); }); + GrrChatView* v = view; + controls.emplace_back(U"good", [v, mm] { v->rate(mm, "up"); }); + controls.emplace_back(U"bad", [v, mm] { v->rate(mm, "down"); }); + if (last) controls.emplace_back(U"again", [v] { v->regenerate(); }); + stops = static_cast(controls.size()); +} + +void GrrMessageItem::set_last(bool l) { + if (l != last) { + last = l; + rebuild_controls(); + touch(); + } +} + +void GrrMessageItem::toggle_steps() { + open_steps = !open_steps; + touch(); +} + +SpanRow GrrMessageItem::control_row(GrrUi& ui, int width, const std::vector>& parts, bool selected, int row) { + int mar = GrrText::margin(width); + StyleP page_style = ui.st("ink", "bg"); + SpanRow spans{TuiSpan(spaces(mar + 1), page_style)}; + int x = mar + 1; + for (auto& part : parts) { + int idx = -1; + for (size_t i = 0; i < controls.size(); i++) if (controls[i].first == part.second) { idx = static_cast(i); break; } + bool on = selected && idx == stop; + UStr label = U" " + part.first + U" "; + spans.push_back(TuiSpan(label, ui.st(on ? "ink" : "muted", on ? "rule-strong" : "bg", on))); + spans.push_back(TuiSpan(U" ", page_style)); + if (idx >= 0) hit(row, x, x + static_cast(label.size()), controls[static_cast(idx)].second); + x += static_cast(label.size()) + 1; + } + return GrrText::line(spans, width, page_style); +} + +SpanRows GrrMessageItem::build(GrrUi& ui, int width, bool selected) { + GrrChatView* v = view; + GrrShell& g = v->g; + TuiValue meta = meta_of(m); + int mar = GrrText::margin(width); + int inner = width - 2 * mar; + StyleP page_style = ui.st("ink", "bg"); + SpanRows out; + bool user = m.get("role").is_str() && m.get("role").as_str() == "user"; + std::string side = user ? "out" : "in"; + TuiChatRows::Styles styles = grr_bubble_styles(ui, side); + UStr sender; + if (!user && v->chat.truthy() && v->chat.get("kind").is_str() && v->chat.get("kind").as_str() == "group") { + TuiValue bot = v->bot_of(m.get("sender")); + sender = bot.truthy() ? su(bot.get("name")) : UStr(); + } + if (user) { + for (auto& fid : list_of(meta.get("files"))) { + UStr chip = U" " + ui.icon("image") + U" " + g.t("chat.attachment") + U" "; + out.push_back(GrrText::line({TuiSpan(spaces(width - mar - static_cast(chip.size())), page_style), TuiSpan(chip, ui.st("soft", "bubble-user"))}, width, page_style)); + TuiValue file = TuiValue::object({{"id", fid}, {"name", ""}, {"mime", "image/*"}}); + hit(static_cast(out.size()) - 1, 0, width, [&g, file] { g.open_file(file); }); + } + } + UStr text = text_of(m.get("content")); + if (user && meta.get("routine").truthy()) text = g.t("chat.routine") + U"\n" + text; + if (!text.empty()) { + if (m.get("kind").is_str() && m.get("kind").as_str() == "text" && !user && meta.get("error").truthy()) styles["bubble"] = ui.st("danger", "bubble-bot"); + SpanRows rows = TuiChatRows::bubble_rows(text, inner, side, styles, UStr(), UStr(), false, sender, !user, true); + for (auto& row : rows) { + SpanRow r{TuiSpan(spaces(mar), page_style)}; + r.insert(r.end(), row.begin(), row.end()); + r.push_back(TuiSpan(spaces(mar), page_style)); + out.push_back(GrrText::line(r, width, page_style)); + } + } + if (!user) { + auto steps = list_of(meta.get("steps")); + if (!steps.empty()) { + UStr label = (open_steps ? UStr(U"▾") : UStr(U"▸")) + U" " + g.t("chat.steps", {{"n", std::to_string(steps.size())}}); + out.push_back(control_row(ui, width, {{label, U"steps"}}, selected, static_cast(out.size()))); + if (open_steps) { + for (auto& s : steps) { + UStr mark = s.get("ok").truthy() ? ui.icon("check") : UStr(U"!"); + UStr line = U" " + mark + U" " + GrrText::clip(su(s.get("summary", TuiValue(""))), inner - 12) + U" (" + su(s.get("secs", TuiValue(""))) + U"s)"; + out.push_back(GrrText::line({TuiSpan(spaces(mar) + line, ui.st(s.get("ok").truthy() ? "success" : "danger", "bg"))}, width, page_style)); + } + } + } + if (meta.get("smile").is_str() && meta.get("smile").truthy()) { + out.push_back(GrrText::line({TuiSpan(spaces(mar + 1) + GrrText::clip(su(meta.get("smile")), inner - 2), ui.st("faint", "bg"))}, width, page_style)); + } + if ((last || selected) && m.get("kind").is_str() && m.get("kind").as_str() == "text") { + static const std::map glyph = {{"copy", "copy"}, {"good", "thumbup"}, {"bad", "thumbdown"}, {"again", "refresh"}}; + std::vector> bar; + for (auto& c : controls) { + auto it = glyph.find(TuiStr::s(c.first)); + if (it != glyph.end()) bar.emplace_back(ui.icon(it->second), c.first); + } + out.push_back(control_row(ui, width, bar, selected, static_cast(out.size()))); + } + } + return out; +} + +// ---- approval ---------------------------------------------------------------------------------------- + +GrrApprovalItem::GrrApprovalItem(GrrChatView* v, TuiValue msg) : view(v), m(std::move(msg)) { + key = m.get("id").str(); + setup(); +} + +void GrrApprovalItem::setup() { + GrrShell& g = view->g; + TuiValue meta = meta_of(m); + controls.clear(); + if (meta.get("state").is_str() && meta.get("state").as_str() == "pending") { + std::string id = m.get("id").str(); + auto decide = [&g, id](const std::string& d) { g.call("POST", "/api/messages/" + id + "/decision", TuiValue::object({{"decision", d}})); }; + controls.emplace_back(g.t("chat.allow_once"), [decide] { decide("allow"); }); + controls.emplace_back(g.t("chat.deny"), [decide] { decide("deny"); }); + if (meta.get("tool").is_str() && meta.get("tool").as_str() == "browser") controls.emplace_back(g.t("chat.always_allow"), [decide] { decide("always"); }); + } + stops = std::max(1, static_cast(controls.size())); + selectable = !controls.empty(); +} + +SpanRows GrrApprovalItem::build(GrrUi& ui, int width, bool selected) { + GrrChatView* v = view; + GrrShell& g = v->g; + TuiValue meta = meta_of(m); + TuiValue bot = v->bot_of(m.get("sender")); + if (!bot.truthy()) { + auto bs = v->bots(); + bot = !bs.empty() ? bs[0] : TuiValue::object({{"name", "Bot"}}); + } + int cw = GrrCard::inner(width); + SpanRows lines; + lines.push_back(SpanRow{TuiSpan(U" " + GrrText::clip(g.t("chat.needs_approval", {{"name", bot.get("name").str()}}), cw), ui.st("ink", "card", true))}); + auto body = GrrText::wrap(text_of(m.get("content")), cw - 1); + if (body.size() > 8) body.resize(8); + for (auto& line : body) lines.push_back(SpanRow{TuiSpan(U" " + line, ui.st("soft", "card"))}); + lines.push_back(SpanRow{TuiSpan(U" ", ui.st("ink", "card"))}); + if (meta.get("state").is_str() && meta.get("state").as_str() == "pending") { + SpanRow spans{TuiSpan(U" ", ui.st("ink", "card"))}; + int x = 2 + GrrText::margin(width); + for (size_t pos = 0; pos < controls.size(); pos++) { + bool on = selected && static_cast(pos) == stop; + UStr text = U" " + controls[pos].first + U" "; + bool primary = pos == 0; + std::string bg = on && primary ? "ink" : (on ? "rule-strong" : (primary ? "accent" : "rule-strong")); + spans.push_back(TuiSpan(text, ui.st(primary && !on ? "accent-ink" : "ink", bg, true))); + spans.push_back(TuiSpan(U" ", ui.st("ink", "card"))); + hit(static_cast(lines.size()) + 1, x, x + static_cast(text.size()), controls[pos].second); + x += static_cast(text.size()) + 1; + } + lines.push_back(spans); + } else { + std::string state = meta.get("auto").truthy() ? "auto" : state_name(meta.get("state")); + bool ok = meta.get("state").is_str() && meta.get("state").as_str() == "allowed"; + lines.push_back(SpanRow{TuiSpan(U" " + ui.icon(ok ? "check" : "close") + U" " + g.t("chat.state_" + state), ui.st(ok ? "success" : "danger", "card"))}); + } + return GrrCard::card_rows(ui, width, lines); +} + +// ---- question ---------------------------------------------------------------------------------------- + +GrrQuestionItem::GrrQuestionItem(GrrChatView* v, TuiValue msg) : view(v), m(std::move(msg)) { + key = m.get("id").str(); + TuiValue meta = meta_of(m); + answer = meta.get("answer"); + options = list_of(meta.get("options")); + GrrShell& g = v->g; + if (answer.is_null()) { + for (auto& o : options) { + TuiValue opt = o; + TuiValue mm = m; + controls.emplace_back(su(o), [v, mm, opt] { v->answer(mm, TuiStr::u(opt.str())); }); + } + TuiValue mm = m; + controls.emplace_back(g.t("chat.skip"), [v, mm] { v->answer(mm, U"(skipped)"); }); + } + stops = std::max(1, static_cast(controls.size())); + selectable = answer.is_null(); +} + +bool GrrQuestionItem::nav(const std::string& direction) { + if ((direction == "up" || direction == "down") && controls.size() > 1) { + int nxt = stop + (direction == "down" ? 1 : -1); + if (0 <= nxt && nxt < static_cast(controls.size())) { + stop = nxt; + touch(); + return true; + } + } + return false; +} + +SpanRows GrrQuestionItem::build(GrrUi& ui, int width, bool selected) { + GrrShell& g = view->g; + int cw = GrrCard::inner(width); + SpanRows lines; + lines.push_back(SpanRow{TuiSpan(U" " + GrrText::clip(text_of(m.get("content")), cw), ui.st("ink", "card", true))}); + if (answer.is_null()) { + for (size_t pos = 0; pos < options.size(); pos++) { + bool on = selected && static_cast(pos) == stop; + std::string bg = on ? "rule-strong" : "sunken"; + SpanRow row{TuiSpan(U" ", ui.st("ink", "card")), TuiSpan(U" " + UStr(1, static_cast(65 + pos)) + U" ", ui.st("muted", bg)), + TuiSpan(U" " + GrrText::clip(su(options[pos]), cw - 6), ui.st("ink", bg))}; + lines.push_back(GrrText::line(row, cw, ui.st("ink", bg))); + hit(static_cast(lines.size()), 0, width, controls[pos].second); + } + bool skip = selected && stop == static_cast(options.size()); + lines.push_back(SpanRow{TuiSpan(U" " + ui.icon("close") + U" " + g.t("chat.skip"), ui.st(skip ? "ink" : "muted", skip ? "rule-strong" : "card"))}); + hit(static_cast(lines.size()), 0, width, controls.back().second); + lines.push_back(SpanRow{TuiSpan(U" " + g.t("chat.answer_below"), ui.st("faint", "card"))}); + } else { + lines.push_back(SpanRow{TuiSpan(U" ", ui.st("ink", "card")), TuiSpan(U" " + GrrText::clip(su(answer), cw - 6) + U" " + ui.icon("check") + U" ", ui.st("ink", "sunken"))}); + } + return GrrCard::card_rows(ui, width, lines); +} + +// ---- files -------------------------------------------------------------------------------------------- + +GrrFileMsgItem::GrrFileMsgItem(GrrChatView* v, TuiValue msg) : view(v), m(std::move(msg)) { + selectable = true; + key = m.get("id").str(); + action = [this] { open(); }; +} + +void GrrFileMsgItem::open() { + TuiValue meta = meta_of(m); + view->g.open_file(TuiValue::object({{"id", meta.get("file_id")}, {"name", meta.get("name")}, {"mime", meta.get("mime")}, {"kind", meta.get("file_kind")}, + {"caption", TuiStr::s(text_of(m.get("content")))}})); +} + +SpanRows GrrFileMsgItem::build(GrrUi& ui, int width, bool selected) { + TuiValue meta = meta_of(m); + std::string bg = selected ? "rule-strong" : "card"; + int cw = GrrCard::inner(width); + std::string mime = meta.get("mime").truthy() ? meta.get("mime").str() : ""; + std::string icon = mime.compare(0, 6, "image/") == 0 ? "image" : (mime.compare(0, 6, "audio/") == 0 ? "mic" : "file"); + SpanRows lines; + lines.push_back(SpanRow{TuiSpan(U" " + ui.icon(icon) + U" " + GrrText::clip(text_of(meta.get("name")), cw - 4), ui.st("ink", bg, true))}); + lines.push_back(SpanRow{TuiSpan(U" " + TuiStr::u(mime) + U" · " + GrrFormat::bytes(meta.get("size", TuiValue(0)).as_int()), ui.st("muted", bg))}); + if (m.get("content").truthy()) lines.push_back(SpanRow{TuiSpan(U" " + GrrText::clip(su(m.get("content")), cw - 4), ui.st("soft", bg))}); + SpanRows padded; + for (auto& l : lines) padded.push_back(GrrText::line(l, cw, ui.st("ink", bg))); + return GrrCard::card_rows(ui, width, padded, bg); +} + +GrrFilesMsgItem::GrrFilesMsgItem(GrrChatView* v, TuiValue msg) : view(v), m(std::move(msg)) { + selectable = true; + key = m.get("id").str(); + files = list_of(meta_of(m).get("files")); + GrrShell& g = v->g; + for (auto& f : files) { + TuiValue file = TuiValue::object({{"id", f.get("file_id")}, {"name", f.get("name")}, {"mime", f.get("mime")}, {"kind", f.get("file_kind")}}); + controls.emplace_back(text_of(f.get("name", TuiValue(""))), [&g, file] { g.open_file(file); }); + } + stops = std::max(1, static_cast(files.size())); +} + +bool GrrFilesMsgItem::nav(const std::string& direction) { + if ((direction == "up" || direction == "down") && controls.size() > 1) { + int nxt = stop + (direction == "down" ? 1 : -1); + if (0 <= nxt && nxt < static_cast(controls.size())) { + stop = nxt; + touch(); + return true; + } + } + return false; +} + +SpanRows GrrFilesMsgItem::build(GrrUi& ui, int width, bool selected) { + int cw = GrrCard::inner(width); + SpanRows lines; + for (size_t pos = 0; pos < files.size(); pos++) { + auto& f = files[pos]; + bool on = selected && static_cast(pos) == stop; + std::string bg = on ? "rule-strong" : "card"; + std::string mime = f.get("mime").truthy() ? f.get("mime").str() : ""; + SpanRow row{TuiSpan(U" " + ui.icon(mime.compare(0, 6, "image/") == 0 ? "image" : "file") + U" " + GrrText::clip(su(f.get("name", TuiValue(""))), cw - 14), ui.st("ink", bg, on))}; + row = GrrText::line(row, cw - 10, ui.st("ink", bg)); + row.push_back(TuiSpan(TuiStr::rjust(GrrFormat::bytes(f.get("size", TuiValue(0)).as_int()), 10), ui.st("muted", bg))); + lines.push_back(row); + hit(static_cast(pos) + 1, 0, width, controls[pos].second); + } + if (m.get("content").truthy()) lines.push_back(SpanRow{TuiSpan(U" " + GrrText::clip(su(m.get("content")), cw - 2), ui.st("soft", "card"))}); + return GrrCard::card_rows(ui, width, lines); +} + +GrrTemplateMsgItem::GrrTemplateMsgItem(GrrChatView* v, TuiValue msg) : view(v), m(std::move(msg)) { + selectable = true; + key = m.get("id").str(); + GrrShell& g = v->g; + TuiValue meta = meta_of(m); + if (meta.get("state").is_str() && meta.get("state").as_str() == "published") { + std::string slug = meta.get("slug").str(); + controls.emplace_back(g.t("template.copy_link"), [&g, slug] { g.copy_text(TuiStr::u(g.client.base + "/t/" + slug)); }); + } else { + std::string tid = meta.get("template_id").str(); + controls.emplace_back(g.t("template.publish"), [&g, tid] { g.call("POST", "/api/templates/" + tid + "/publish", TuiValue::dict()); }); + } + std::string tid = meta.get("template_id").str(); + controls.emplace_back(g.t("template.details"), [&g, tid] { g.router.go("/template/" + tid); }); + stops = 2; +} + +SpanRows GrrTemplateMsgItem::build(GrrUi& ui, int width, bool selected) { + GrrChatView* v = view; + GrrShell& g = v->g; + TuiValue meta = meta_of(m); + TuiValue bot = v->bot_of(m.get("sender")); + if (!bot.truthy()) { auto bs = v->bots(); bot = !bs.empty() ? bs[0] : TuiValue(); } + bool live = meta.get("state").is_str() && meta.get("state").as_str() == "published"; + int cw = GrrCard::inner(width); + SpanRows lines; + for (auto& row : GrrAvatar::rows(ui, bot, "card")) { + SpanRow r{TuiSpan(spaces((cw - TuiSpans::width(row)) / 2), ui.st("ink", "card"))}; + r.insert(r.end(), row.begin(), row.end()); + lines.push_back(r); + } + UStr badge = live ? g.t("template.published") : g.t("template.unpublished"); + lines.push_back(SpanRow{TuiSpan(U" " + GrrText::clip(text_of(m.get("content")), cw - static_cast(badge.size()) - 4), ui.st("ink", "card", true)), + TuiSpan(U" " + badge, ui.st(live ? "success" : "muted", "card"))}); + auto desc = GrrText::wrap(text_of(meta.get("description")), cw - 2); + if (desc.size() > 3) desc.resize(3); + for (auto& line : desc) lines.push_back(SpanRow{TuiSpan(U" " + line, ui.st("muted", "card"))}); + SpanRow spans{TuiSpan(U" ", ui.st("ink", "card"))}; + int x = 2 + GrrText::margin(width); + for (size_t pos = 0; pos < controls.size(); pos++) { + bool on = selected && static_cast(pos) == stop; + UStr text = U" " + controls[pos].first + U" "; + spans.push_back(TuiSpan(text, ui.st(pos == 0 ? "accent-ink" : "ink", on && pos == 0 ? "ink" : ((on || pos) ? "rule-strong" : "accent"), true))); + spans.push_back(TuiSpan(U" ", ui.st("ink", "card"))); + hit(static_cast(lines.size()) + 1, x, x + static_cast(text.size()), controls[pos].second); + x += static_cast(text.size()) + 1; + } + lines.push_back(spans); + return GrrCard::card_rows(ui, width, lines); +} + +// ---- remote terminal ------------------------------------------------------------------------------------ + +GrrRemoteTerminal::GrrRemoteTerminal(GrrShell& sh, std::string sp, std::string ip, TuiPanelOpts opts) + : TuiTerminal({"true"}, true, std::move(opts)), shell(&sh), stream_path(std::move(sp)), input_path(std::move(ip)) {} + +GrrRemoteTerminal::~GrrRemoteTerminal() { detach(); } + +void GrrRemoteTerminal::attach() { + std::weak_ptr weak = weak_from_this(); + stream = std::make_unique( + &shell->client, stream_path, + [weak](const std::string& name, const TuiValue& data) { + if (auto p = weak.lock()) static_cast(p.get())->on_stream(name, data); + }, + [this](std::function fn) { shell->app->run_on_loop(std::move(fn)); }, nullptr, false); + stream->start(); +} + +void GrrRemoteTerminal::detach() { + if (stream) { + stream->stop(); + stream.reset(); + } +} + +static std::string b64decode(const std::string& in) { + std::string out; + int val = 0, bits = -8; + for (unsigned char c : in) { + int d; + if (c >= 'A' && c <= 'Z') d = c - 'A'; + else if (c >= 'a' && c <= 'z') d = c - 'a' + 26; + else if (c >= '0' && c <= '9') d = c - '0' + 52; + else if (c == '+') d = 62; + else if (c == '/') d = 63; + else continue; + val = (val << 6) | d; + bits += 6; + if (bits >= 0) { + out.push_back(static_cast((val >> bits) & 0xFF)); + bits -= 8; + } + } + return out; +} + +void GrrRemoteTerminal::on_stream(const std::string& name, const TuiValue& data) { + if (name == "out") { + if (data.get("skipped").truthy()) screen.feed("\r\n\x1b[2m[" + TuiStr::s(shell->t("proc.skipped", {{"n", data.get("skipped").str()}})) + "]\x1b[0m\r\n"); + if (data.get("d").truthy()) screen.feed(b64decode(data.get("d").str())); + connecting = false; + } else if (name == "exit") { + running = false; + if (on_exit) on_exit(); + } + touch(); +} + +void GrrRemoteTerminal::apply_winsize() { + if (size_timer) shell->app->cancel_timer(size_timer); + std::weak_ptr weak = weak_from_this(); + size_timer = shell->app->after(0.15, [weak] { + if (auto p = weak.lock()) static_cast(p.get())->send_size(); + }); +} + +void GrrRemoteTerminal::send_size() { + size_timer.reset(); + shell->call("POST", input_path, TuiValue::object({{"cols", static_cast(screen.cols)}, {"rows", static_cast(screen.rows)}}), nullptr, [](std::exception_ptr) {}); +} + +void GrrRemoteTerminal::on_key(TuiKey& event) { + if (event.key == "ctrl+]" || event.key == "ctrl+}") { + if (on_leave) { + event.stop(); + on_leave(); + return; + } + } + if (!running) return; + std::string data = TuiTerminal::key_bytes(event); + if (!data.empty()) { + event.stop(); + queue(data); + } +} + +void GrrRemoteTerminal::on_paste(TuiPaste& event) { + event.stop(); + UStr t = event.text; + for (auto& c : t) if (c == U'\n') c = U'\r'; + queue(TuiStr::s(t)); +} + +void GrrRemoteTerminal::send_text(const UStr& text) { queue(TuiStr::s(text)); } + +void GrrRemoteTerminal::queue(const std::string& data) { + sent += data; + if (!flush_timer) { + std::weak_ptr weak = weak_from_this(); + flush_timer = shell->app->after(0.025, [weak] { + if (auto p = weak.lock()) static_cast(p.get())->flush(); + }); + } +} + +void GrrRemoteTerminal::flush() { + flush_timer.reset(); + std::string chunk = sent.substr(0, 4096); + sent.erase(0, chunk.size()); + if (!chunk.empty()) shell->call("POST", input_path, TuiValue::object({{"data", TuiStr::s(TuiStr::u(chunk))}}), nullptr, [](std::exception_ptr) {}); + if (!sent.empty()) { + std::weak_ptr weak = weak_from_this(); + flush_timer = shell->app->after(0.025, [weak] { + if (auto p = weak.lock()) static_cast(p.get())->flush(); + }); + } +} + +// ---- process card ----------------------------------------------------------------------------------------- + +GrrProcessItem::GrrProcessItem(GrrChatView* v, TuiValue msg) : view(v), m(std::move(msg)), screen(std::make_unique(ROWS_TALL, 80)), alive(std::make_shared(true)) { + selectable = true; + key = m.get("id").str(); + rebuild_controls(); + sync(); +} + +bool GrrProcessItem::running() const { + TuiValue meta = meta_of(m); + return meta.get("state").is_str() && meta.get("state").as_str() == "running"; +} + +TuiValue GrrProcessItem::pid() const { return meta_of(m).get("pid"); } + +void GrrProcessItem::rebuild_controls() { + GrrShell& g = view->g; + TuiValue p = pid(); + controls.clear(); + if (running()) { + controls.emplace_back(U"^C", [&g, p] { g.call("POST", "/api/processes/" + p.str() + "/input", TuiValue::object({{"data", "\x03"}}), nullptr, [](std::exception_ptr) {}); }); + controls.emplace_back(g.t("proc.stop"), [&g, p] { g.call("POST", "/api/processes/" + p.str() + "/input", TuiValue::object({{"kill", true}})); }); + controls.emplace_back(U"Type", [this] { type_in(); }); + } + controls.emplace_back(g.t("proc.expand"), [this] { toggle_tall(); }); + stops = static_cast(controls.size()); +} + +void GrrProcessItem::update(const TuiValue& msg) { + bool was = running(); + m = msg; + rebuild_controls(); + if (was && !running()) finish(); + touch(); +} + +void GrrProcessItem::sync() { + if (running()) follow(); + else replay(); +} + +void GrrProcessItem::follow() { + if (stream) return; + GrrShell& g = view->g; + std::shared_ptr flag = alive; + stream = std::make_unique( + &g.client, "/api/processes/" + pid().str() + "/stream?from=0", + [this, flag](const std::string& name, const TuiValue& data) { + if (*flag) on_event(name, data); + }, + [&g](std::function fn) { g.app->run_on_loop(std::move(fn)); }, nullptr, false); + stream->start(); +} + +void GrrProcessItem::on_event(const std::string& name, const TuiValue& data) { + GrrShell& g = view->g; + if (name == "out") { + if (data.get("skipped").truthy()) screen->feed("\r\n[" + TuiStr::s(g.t("proc.skipped", {{"n", data.get("skipped").str()}})) + "]\r\n"); + if (data.get("d").truthy()) screen->feed(b64decode(data.get("d").str())); + fed = true; + touch(); + gen++; + long my_gen = gen; + std::shared_ptr flag = alive; + g.app->after(GRR_PROMPT_QUIET, [this, flag, my_gen] { + if (*flag) check_prompt(my_gen); + }); + } else if (name == "exit") { + stop_stream(); + } +} + +bool GrrProcessItem::prompt_tail(const std::string& row_in) { + std::string row; + for (char c : row_in) row.push_back(static_cast(std::tolower(static_cast(c)))); + while (!row.empty() && std::isspace(static_cast(row.back()))) row.pop_back(); + if (row.empty()) return false; + if (row.back() == '?' || row.back() == ':') return true; + if (row.find("password") != std::string::npos || row.find("passphrase") != std::string::npos) return true; + // [\[(][yn](es)?/[yn](o)?[\])] at the end + for (size_t start = 0; start < row.size(); start++) { + if (row[start] != '[' && row[start] != '(') continue; + size_t i = start + 1; + if (i >= row.size() || (row[i] != 'y' && row[i] != 'n')) continue; + i++; + if (row.compare(i, 2, "es") == 0) i += 2; + if (i >= row.size() || row[i] != '/') continue; + i++; + if (i >= row.size() || (row[i] != 'y' && row[i] != 'n')) continue; + i++; + if (i < row.size() && row[i] == 'o') i++; + if (i < row.size() && (row[i] == ']' || row[i] == ')') && i + 1 == row.size()) return true; + } + return false; +} + +void GrrProcessItem::check_prompt(long g_) { + if (g_ != gen || g_ == asked_gen || !running() || typing || asks >= GRR_PROMPT_ASKS || view->g.view.get() != static_cast(view)) return; + std::vector used; + for (long r = 0; r < screen->rows; r++) { + for (auto& c : screen->grid[static_cast(r)]) if (c.set && c.ch != U' ') { used.push_back(static_cast(r)); break; } + } + if (used.empty()) return; + UStr row; + for (auto& c : screen->grid[static_cast(used.back())]) row.push_back(c.set ? c.ch : U' '); + if (!prompt_tail(TuiStr::s(TuiStr::rstrip(row)))) return; + asked_gen = g_; + asks++; + type_in(); +} + +void GrrProcessItem::stop_stream() { + if (stream) { + stream->stop(); + stream.reset(); + } +} + +void GrrProcessItem::finish() { + stop_stream(); + if (!fed) replay(); +} + +void GrrProcessItem::replay() { + screen = std::make_unique(ROWS_TALL, cols); + screen->feed(meta_of(m).get("transcript").truthy() ? meta_of(m).get("transcript").str() : std::string()); +} + +void GrrProcessItem::destroy() { stop_stream(); } + +void GrrProcessItem::toggle_tall() { + tall = !tall; + touch(); +} + +void GrrProcessItem::type_in() { + GrrShell& g = view->g; + std::string p = pid().str(); + TuiPanelOpts to; + to.w = TuiSpec(std::min(g.app->cols - 6, 100)); + to.h = TuiSpec(std::min(g.app->rows - 8, 28)); + to.border = true; + to.style = g.ui->st("ink", "sunken"); + to.border_style = g.ui->st("accent", "bg"); + auto term = std::make_shared(g, "/api/processes/" + p + "/stream?from=0", "/api/processes/" + p + "/input", to); + typing = true; + GrrDialogSpec d; + d.title = text_of(m.get("content")); + d.text = U"Esc leaves, Ctrl+C is sent to the command."; + d.body = term; + d.w = std::min(g.app->cols - 2, 104); + d.actions = std::vector{GrrAction{g.t("ui.done"), TuiValue("done"), true}}; + std::shared_ptr flag = alive; + d.done = [this, flag, term](const TuiValue&, const std::vector&) { + if (*flag) typing = false; + term->detach(); + }; + auto dlg = g.dialog(std::move(d)); + std::weak_ptr wd = dlg; + GrrShell* gp = &g; + term->on_exit = [gp, wd] { + if (auto dd = wd.lock()) gp->close_dialog(dd, TuiValue("done")); + }; + term->attach(); + g.app->set_focus(term.get()); +} + +SpanRows GrrProcessItem::build(GrrUi& ui, int width, bool selected) { + GrrShell& g = view->g; + TuiValue meta = meta_of(m); + std::string state = meta.get("state").truthy() ? meta.get("state").str() : "running"; + int cw = GrrCard::inner(width); + TuiValue exit_code = meta.get("exit"); + UStr status = g.t("proc.state_" + state, {{"code", exit_code.is_null() ? std::string("?") : exit_code.str()}}); + std::string tone = state == "running" ? "accent" : (state == "exited" && exit_code.truthy() ? "danger" : "muted"); + SpanRow head{TuiSpan(U" " + ui.icon("terminal") + U" ", ui.st("accent", "card")), + TuiSpan(GrrText::clip(text_of(m.get("content")), cw - static_cast(status.size()) - 6), ui.st("ink", "card", true))}; + head = GrrText::line(head, cw - static_cast(status.size()) - 1, ui.st("ink", "card")); + head.push_back(TuiSpan(status + U" ", ui.st(tone, "card"))); + SpanRows lines{head}; + int rows = tall ? ROWS_TALL : ROWS; + StyleP base = ui.st("ink", "sunken"); + std::vector used; + for (long r = 0; r < screen->rows; r++) { + for (auto& c : screen->grid[static_cast(r)]) if (c.set && c.ch != U' ') { used.push_back(static_cast(r)); break; } + } + int last_row = used.empty() ? -1 : used.back(); + int first = std::max(0, last_row - rows + 1); + for (int r = first; r < first + rows; r++) { + if (r >= screen->rows) { + lines.push_back(SpanRow{TuiSpan(spaces(cw), base)}); + continue; + } + TuiSpans::Cells cells; + for (long col = 0; col < std::min(cw - 1, screen->cols); col++) { + const TuiVtCell& cell = screen->grid[static_cast(r)][static_cast(col)]; + cells.emplace_back(cell.set ? cell.ch : U' ', TuiVtScreen::style_for(cell, *base)); + } + SpanRow row{TuiSpan(U" ", ui.st("ink", "card"))}; + SpanRow body = GrrText::line(TuiSpans::runs(cells), cw - 1, base); + row.insert(row.end(), body.begin(), body.end()); + lines.push_back(row); + } + SpanRow spans{TuiSpan(U" ", ui.st("ink", "card"))}; + int x = 2 + GrrText::margin(width); + for (size_t pos = 0; pos < controls.size(); pos++) { + bool on = selected && static_cast(pos) == stop; + UStr text = U" " + controls[pos].first + U" "; + spans.push_back(TuiSpan(text, ui.st(controls[pos].first == g.t("proc.stop") ? "danger" : "ink", on ? "rule-strong" : "sunken", on))); + spans.push_back(TuiSpan(U" ", ui.st("ink", "card"))); + hit(static_cast(lines.size()) + 1, x, x + static_cast(text.size()), controls[pos].second); + x += static_cast(text.size()) + 1; + } + lines.push_back(spans); + return GrrCard::card_rows(ui, width, lines); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrChatItems.h b/taicpp/src/tui/GrrChatItems.h new file mode 100644 index 0000000..d1ee8bd --- /dev/null +++ b/taicpp/src/tui/GrrChatItems.h @@ -0,0 +1,167 @@ +#pragma once +// Message items of a grr chat (tai.py grr_bubble_styles, GrrControlsItem, +// GrrMessageItem, GrrApprovalItem, GrrQuestionItem, GrrFileMsgItem, +// GrrFilesMsgItem, GrrTemplateMsgItem, GrrRemoteTerminal, GrrProcessItem). +#include "GrrDialog.h" +#include "GrrItem.h" +#include "TuiApp.h" +#include "GrrStream.h" +#include "TuiChatRows.h" +#include "TuiTerminal.h" +#include "TuiVtScreen.h" +namespace detz::tui { + +class GrrChatView; +class GrrShell; + +TuiChatRows::Styles grr_bubble_styles(GrrUi& ui, const std::string& side); + +// An item with a row of small controls that Left and Right walk through. +class GrrControlsItem : public GrrItem { +public: + GrrControlsItem() { selectable = false; } + bool nav(const std::string& direction) override; + void activate() override; + std::vector> controls; +}; + +// 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). +class GrrMessageItem : public GrrControlsItem { +public: + GrrMessageItem(GrrChatView* view, TuiValue m, bool last = false); + void rebuild_controls(); + void set_last(bool last); + void toggle_steps(); + SpanRows build(GrrUi& ui, int width, bool selected) override; + SpanRow control_row(GrrUi& ui, int width, const std::vector>& parts, bool selected, int row); + GrrChatView* view; + TuiValue m; + bool last; + bool open_steps{false}; +}; + +// A card that asks the owner to allow or deny what a bot wants to do. +class GrrApprovalItem : public GrrControlsItem { +public: + GrrApprovalItem(GrrChatView* view, TuiValue m); + void setup(); + SpanRows build(GrrUi& ui, int width, bool selected) override; + GrrChatView* view; + TuiValue m; +}; + +// A question card with lettered options; answering is a click or Enter. +class GrrQuestionItem : public GrrControlsItem { +public: + GrrQuestionItem(GrrChatView* view, TuiValue m); + bool nav(const std::string& direction) override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + GrrChatView* view; + TuiValue m; + TuiValue answer; + std::vector options; +}; + +// A file a bot shares: name, size, caption. Enter opens the viewer. +class GrrFileMsgItem : public GrrItem { +public: + GrrFileMsgItem(GrrChatView* view, TuiValue m); + void open(); + SpanRows build(GrrUi& ui, int width, bool selected) override; + GrrChatView* view; + TuiValue m; +}; + +// Several files in one message: a list the arrows walk through. +class GrrFilesMsgItem : public GrrControlsItem { +public: + GrrFilesMsgItem(GrrChatView* view, TuiValue m); + bool nav(const std::string& direction) override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + GrrChatView* view; + TuiValue m; + std::vector files; +}; + +class GrrTemplateMsgItem : public GrrControlsItem { +public: + GrrTemplateMsgItem(GrrChatView* view, TuiValue m); + SpanRows build(GrrUi& ui, int width, bool selected) override; + GrrChatView* view; + TuiValue m; +}; + +// 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. +class GrrRemoteTerminal : public TuiTerminal { +public: + GrrRemoteTerminal(GrrShell& shell, std::string stream_path, std::string input_path, TuiPanelOpts opts); + ~GrrRemoteTerminal() override; + bool spawn() override { return false; } + void attach(); + void detach(); + void on_stream(const std::string& name, const TuiValue& data); + void apply_winsize() override; + void send_size(); + std::vector fds() const override { return {}; } + std::optional poll_exit() override { return std::nullopt; } + void terminate() override { detach(); } + void drain_pending() override {} + void on_key(TuiKey& event) override; + void on_paste(TuiPaste& event) override; + void send_text(const UStr& text) override; + void queue(const std::string& data); + void flush(); + GrrShell* shell; + std::string stream_path, input_path; + std::unique_ptr stream; + std::string sent; + TuiApp::TimerP flush_timer, size_timer; + bool running{true}; + bool connecting{true}; + std::function on_exit, on_leave; +}; + +// 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. +class GrrProcessItem : public GrrControlsItem { +public: + static constexpr int ROWS = 8; + static constexpr int ROWS_TALL = 24; + GrrProcessItem(GrrChatView* view, TuiValue m); + ~GrrProcessItem() override { *alive = false; stop_stream(); } + bool running() const; + TuiValue pid() const; + void rebuild_controls(); + void update(const TuiValue& m); + void sync(); + void follow(); + void on_event(const std::string& name, const TuiValue& data); + void check_prompt(long gen); + void stop_stream(); + void finish(); + void replay(); + void destroy() override; + void toggle_tall(); + void type_in(); + SpanRows build(GrrUi& ui, int width, bool selected) override; + static bool prompt_tail(const std::string& row); + GrrChatView* view; + TuiValue m; + bool tall{false}; + int cols{80}; + std::unique_ptr screen; + std::unique_ptr stream; + bool fed{false}; + long gen{0}; + long asked_gen{-1}; + int asks{0}; + bool typing{false}; + std::shared_ptr alive; // timers that outlive the card check this +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrClient.cpp b/taicpp/src/tui/GrrClient.cpp new file mode 100644 index 0000000..257c4bc --- /dev/null +++ b/taicpp/src/tui/GrrClient.cpp @@ -0,0 +1,244 @@ +#include "GrrClient.h" +#include "GrrFormat.h" +#include +#include +#include +#include +#include +namespace detz::tui { + +namespace { +void curl_init_once() { + static std::once_flag flag; + std::call_once(flag, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); +} + +struct Sink { + std::string body; + long limit; + bool too_big{false}; +}; +size_t write_cb(char* ptr, size_t size, size_t nmemb, void* user) { + auto* sink = static_cast(user); + size_t n = size * nmemb; + if (static_cast(sink->body.size() + n) > sink->limit + 1) { + size_t room = static_cast(sink->limit + 1) > sink->body.size() ? static_cast(sink->limit + 1) - sink->body.size() : 0; + sink->body.append(ptr, room); + sink->too_big = true; + return 0; // stop: python reads at most limit + 1 bytes + } + sink->body.append(ptr, n); + return n; +} +size_t header_cb(char* ptr, size_t size, size_t nmemb, void* user) { + auto* type = static_cast(user); + std::string line(ptr, size * nmemb); + if (line.size() > 13 && (line[0] == 'C' || line[0] == 'c')) { + std::string lower; + for (char c : line.substr(0, 13)) lower.push_back(static_cast(std::tolower(static_cast(c)))); + if (lower == "content-type:") { + std::string v = line.substr(13); + size_t b = v.find_first_not_of(" \t"); + size_t e = v.find_last_not_of(" \t\r\n"); + *type = b == std::string::npos ? "" : v.substr(b, e - b + 1); + } + } + return size * nmemb; +} +} // namespace + +std::string GrrClient::quote(const std::string& s, const char* safe) { + static const char* hex = "0123456789ABCDEF"; + std::string out; + for (unsigned char c : s) { + if (std::isalnum(c) || c == '_' || c == '.' || c == '-' || c == '~' || (c != 0 && std::strchr(safe, c))) out.push_back(static_cast(c)); + else { out.push_back('%'); out.push_back(hex[c >> 4]); out.push_back(hex[c & 15]); } + } + return out; +} +std::string GrrClient::quote_plus(const std::string& s) { + std::string q = quote(s, " "); + for (auto& c : q) if (c == ' ') c = '+'; + return q; +} +std::string GrrClient::unquote(const std::string& in, bool plus) { + std::string s = in; + if (plus) for (auto& c : s) if (c == '+') c = ' '; + std::string out; + for (size_t i = 0; i < s.size(); i++) { + if (s[i] == '%' && i + 2 < s.size() && std::isxdigit(static_cast(s[i + 1])) && std::isxdigit(static_cast(s[i + 2]))) { + out.push_back(static_cast(std::stoi(s.substr(i + 1, 2), nullptr, 16))); + i += 2; + } else { + out.push_back(s[i]); + } + } + // python decodes the bytes as utf-8 with errors="replace" + return TuiStr::s(TuiStr::u(out)); +} + +GrrClient::GrrClient(std::string b, std::string tok, std::shared_ptr s, int to) + : base(b.empty() ? GRR_DEFAULT_URL : std::move(b)), token(std::move(tok)), strings(s ? std::move(s) : std::make_shared()), timeout(to) { + while (!base.empty() && base.back() == '/') base.pop_back(); +} + +GrrClient::Target GrrClient::target() const { + Target t; + std::string rest = base; + size_t sep = rest.find("://"); + t.scheme = sep == std::string::npos ? "" : rest.substr(0, sep); + if (sep != std::string::npos) rest = rest.substr(sep + 3); + size_t slash = rest.find_first_of("/?#"); + std::string netloc = slash == std::string::npos ? rest : rest.substr(0, slash); + size_t at = netloc.rfind('@'); + if (at != std::string::npos) netloc = netloc.substr(at + 1); + std::string host; + int port = 0; + if (!netloc.empty() && netloc[0] == '[') { + size_t close = netloc.find(']'); + host = close == std::string::npos ? "" : netloc.substr(1, close - 1); + if (close != std::string::npos && close + 1 < netloc.size() && netloc[close + 1] == ':') port = std::atoi(netloc.c_str() + close + 2); + } else { + size_t colon = netloc.find(':'); + host = colon == std::string::npos ? netloc : netloc.substr(0, colon); + if (colon != std::string::npos) port = std::atoi(netloc.c_str() + colon + 1); + } + for (auto& c : host) if (c >= 'A' && c <= 'Z') c = static_cast(c + 32); + t.host = host.empty() ? "127.0.0.1" : host; + t.port = port ? port : (t.scheme == "https" ? 443 : 80); + return t; +} + +std::string GrrClient::full_url(const std::string& path) const { + Target t = target(); + std::string host = t.host.find(':') != std::string::npos ? "[" + t.host + "]" : t.host; + return std::string(t.scheme == "https" ? "https" : "http") + "://" + host + ":" + std::to_string(t.port) + path; +} + +std::string GrrClient::offline_reason(int code, int os_errno, const char* message) { + if (code == CURLE_OPERATION_TIMEDOUT) return "timed out"; + if (code == CURLE_COULDNT_RESOLVE_HOST) return "[Errno -2] Name or service not known"; + if (code == CURLE_GOT_NOTHING) return "Remote end closed connection without response"; + if (os_errno) return "[Errno " + std::to_string(os_errno) + "] " + std::strerror(os_errno); + return message ? message : "error"; +} + +GrrClient::Response GrrClient::perform(const std::string& method, const std::string& path, const std::vector& headers, + const std::string* payload, long body_limit, int timeout_seconds) { + curl_init_once(); + CURL* curl = curl_easy_init(); + Sink sink; + sink.limit = body_limit; + std::string ctype; + curl_slist* list = nullptr; + for (auto& h : headers) list = curl_slist_append(list, h.c_str()); + list = curl_slist_append(list, "Accept-Encoding: identity"); + list = curl_slist_append(list, "Expect:"); + curl_easy_setopt(curl, CURLOPT_URL, full_url(path).c_str()); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method.c_str()); + if (method == "HEAD") curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, static_cast(timeout_seconds)); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, static_cast(timeout_seconds)); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header_cb); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctype); + if (payload) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload->data()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(payload->size())); + } + CURLcode rc = curl_easy_perform(curl); + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + long os_errno = 0; + curl_easy_getinfo(curl, CURLINFO_OS_ERRNO, &os_errno); + std::string msg = curl_easy_strerror(rc); + curl_slist_free_all(list); + curl_easy_cleanup(curl); + Response r; + if (rc != CURLE_OK && !(sink.too_big && rc == CURLE_WRITE_ERROR)) { + throw GrrError(0, "offline", strings->ts("err.offline"), "", [&] { + TuiValue v = TuiValue::dict(); + v.set("reason", offline_reason(static_cast(rc), static_cast(os_errno), msg.c_str())); + return v; + }()); + } + r.status = status; + r.body = std::move(sink.body); + r.content_type = ctype; + r.too_big = sink.too_big; + return r; +} + +TuiValue GrrClient::request(const std::string& method, const std::string& path, const std::optional& body, const std::optional& raw, + const std::string& ctype, int timeout_override, bool quiet) { + std::vector headers = {"Accept: application/json"}; + if (!token.empty()) headers.push_back("Authorization: Bearer " + token); + std::string payload; + bool has_payload = false; + if (raw) { + payload = *raw; + has_payload = true; + headers.push_back("Content-Type: " + (ctype.empty() ? std::string("application/octet-stream") : ctype)); + } else if (body) { + payload = body->dumps(true, false); + has_payload = true; + headers.push_back("Content-Type: application/json"); + } + Response res = perform(method, path, headers, has_payload ? &payload : nullptr, GRR_BODY_MAX, timeout_override ? timeout_override : timeout); + if (res.too_big || static_cast(res.body.size()) > GRR_BODY_MAX) { + throw GrrError(static_cast(res.status), "too_big", "The answer was larger than " + TuiStr::s(GrrFormat::bytes(GRR_BODY_MAX)) + "."); + } + if (res.status == 204) return TuiValue::dict(); + bool ok = false; + TuiValue parsed; + bool parsed_none = true; + if (res.body.empty()) { + parsed = TuiValue::dict(); + parsed_none = false; + } else { + parsed = TuiValue::loads(res.body, &ok); + parsed_none = !ok; + } + if (res.status >= 400) { + if (res.status == 401 && !quiet && on_unauthorized) on_unauthorized(); + throw strings->explain(static_cast(res.status), parsed_none ? TuiValue() : parsed); + } + return parsed_none ? TuiValue::dict() : parsed; +} + +TuiValue GrrClient::upload(const std::string& name, const std::string& data, const std::string& chat, const std::string& bot) { + std::string query; + for (auto& kv : {std::make_pair("name", name), std::make_pair("chat", chat), std::make_pair("bot", bot)}) { + if (kv.second.empty()) continue; + if (!query.empty()) query += "&"; + query += std::string(kv.first) + "=" + quote_plus(kv.second); + } + return request("POST", "/api/uploads?" + query, std::nullopt, data); +} + +std::string GrrClient::url(const std::string& path) const { + return base + path + (path.find('?') != std::string::npos ? "&" : "?") + "token=" + quote(token); +} + +std::string GrrClient::file_url(const std::string& file_id, bool download) const { + return url("/api/files/" + file_id + (download ? "?download=1" : "")); +} + +GrrClient::Fetched GrrClient::fetch(const std::string& path, long limit) { + std::vector headers; + if (!token.empty()) headers.push_back("Authorization: Bearer " + token); + Response res = perform("GET", path, headers, nullptr, limit, timeout); + if (res.status >= 400) throw GrrError(static_cast(res.status), "http", "HTTP " + std::to_string(res.status)); + Fetched f; + f.cut = static_cast(res.body.size()) > limit; + f.data = res.body.substr(0, static_cast(limit)); + f.content_type = res.content_type; + return f; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrClient.h b/taicpp/src/tui/GrrClient.h new file mode 100644 index 0000000..04ef232 --- /dev/null +++ b/taicpp/src/tui/GrrClient.h @@ -0,0 +1,62 @@ +#pragma once +// GrrClient: blocking JSON client of the grr REST API (tai.py GrrClient). Each +// call opens its own connection, so any thread may use it. Call it from a worker +// (TuiApp::offload), never from the loop. libcurl does the transport (http and +// https); the request shape (headers, host from the base url, no redirects, the +// body cap) follows python's http.client usage. +#include "GrrConst.h" +#include "GrrError.h" +#include "GrrStrings.h" +#include +#include +#include +#include +namespace detz::tui { + +class GrrClient { +public: + struct Fetched { + std::string data; + bool cut{false}; + std::string content_type; + }; + struct Response { + long status{0}; + std::string body; + std::string content_type; + bool too_big{false}; + }; + + GrrClient(std::string base = GRR_DEFAULT_URL, std::string token = "", std::shared_ptr strings = nullptr, int timeout = GRR_HTTP_TIMEOUT); + std::string base; + std::string token; + std::shared_ptr strings; + int timeout; + std::function on_unauthorized; + + TuiValue request(const std::string& method, const std::string& path, const std::optional& body = std::nullopt, + const std::optional& raw = std::nullopt, const std::string& ctype = "", int timeout_override = 0, bool quiet = false); + TuiValue get(const std::string& path, bool quiet = false) { return request("GET", path, std::nullopt, std::nullopt, "", 0, quiet); } + TuiValue post(const std::string& path, const std::optional& body = std::nullopt) { return request("POST", path, body ? body : std::optional(TuiValue::dict())); } + TuiValue patch(const std::string& path, const TuiValue& body) { return request("PATCH", path, body); } + TuiValue put(const std::string& path, const TuiValue& body) { return request("PUT", path, body); } + TuiValue del(const std::string& path, const std::optional& body = std::nullopt) { return request("DELETE", path, body); } + TuiValue upload(const std::string& name, const std::string& data, const std::string& chat = "", const std::string& bot = ""); + std::string url(const std::string& path) const; + std::string file_url(const std::string& file_id, bool download = false) const; + Fetched fetch(const std::string& path, long limit = GRR_VIEWER_MAX_BYTES); + + // pieces the event stream shares + struct Target { std::string scheme, host; int port; }; + Target target() const; + std::string full_url(const std::string& path) const; + static std::string offline_reason(int curl_code, int os_errno, const char* message); + static std::string quote(const std::string& s, const char* safe = "/"); // urllib.parse.quote + static std::string quote_plus(const std::string& s); + static std::string unquote(const std::string& s, bool plus = false); +private: + Response perform(const std::string& method, const std::string& path, const std::vector& headers, const std::string* payload, + long body_limit, int timeout_seconds); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrConst.cpp b/taicpp/src/tui/GrrConst.cpp new file mode 100644 index 0000000..5403662 --- /dev/null +++ b/taicpp/src/tui/GrrConst.cpp @@ -0,0 +1,138 @@ +#include "GrrConst.h" +namespace detz::tui { + +static TuiValue colors(std::initializer_list> kv) { + TuiValue d = TuiValue::dict(); + for (auto& p : kv) d.set(p.first, p.second); + return d; +} + +const TuiValue& GrrConst::palette_fallback() { + static const TuiValue v = [] { + TuiValue root = TuiValue::dict(); + root.set("night", colors({{"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"}})); + root.set("day", colors({{"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"}})); + TuiValue variants = TuiValue::dict(); + TuiValue black = TuiValue::dict(); + black.set("night", TuiValue()); + black.set("day", TuiValue()); + variants.set("black", black); + TuiValue blue = TuiValue::dict(); + blue.set("night", colors({{"accent", "#7f9eb0"}, {"accent-ink", "#0a1015"}})); + blue.set("day", colors({{"accent", "#41697f"}, {"accent-ink", "#f6f1e6"}})); + variants.set("blue", blue); + root.set("variants", variants); + TuiValue bots = TuiValue::list(); + for (const char* c : {"#ffffff", "#9a6a3a", "#e5484d", "#f76b15", "#d4a020", "#30a46c", "#12a594", "#3e63dd", "#8e4ec6", "#e93d82", "#8c857a"}) bots.push(c); + root.set("bot_colors", bots); + return root; + }(); + return v; +} + +const std::vector& GrrConst::shapes() { + static const std::vector v = {"blob", "cloud", "tilt", "plus", "flower", "leaf", "pebble", "oval", "play", "gem", "cog", "hex"}; + return v; +} + +const std::map>& GrrConst::avatar_rows() { + static const std::map> m = { + {"blob", {U"▗█E█E█▖", U"▝█E█E█▘"}}, + {"cloud", {U"▟█E█E█▙", U"▜█E█E█▛"}}, + {"tilt", {U" ▟E█E█▘", U"▗▜E█E█ "}}, + {"plus", {U"▄▟E█E█▄", U"▀▜E█E█▀"}}, + {"flower", {U"▗▜E█E█▖", U"▝▟E█E█▘"}}, + {"leaf", {U"▗█E█E█▛", U"▜█E█E█▖"}}, + {"pebble", {U"▗█E█E█▖", U"▝█E█E█▘"}}, + {"oval", {U" ▟E█E▙ ", U" ▜E█E▛ "}}, + {"play", {U"▛█E█E█▙", U"▙█E█E█▛"}}, + {"gem", {U"▗▟E█E█▖", U" ▜E█E█▘"}}, + {"cog", {U"▟▀E▀E▀▙", U"▜▄E▄E▄▛"}}, + {"hex", {U"▟█E█E█▙", U"▜█E█E█▛"}}, + }; + return m; +} + +const std::map& GrrConst::icons() { + static const std::map m = { + {"back", U"‹"}, {"close", U"✕"}, {"plus", U"+"}, {"search", U"⌕"}, {"gear", U"⚙"}, {"compose", U"✎"}, {"more", U"⋯"}, {"moreV", U"⋮"}, {"share", U"⇧"}, + {"check", U"✓"}, {"right", U"›"}, {"down", U"▾"}, {"up", U"▴"}, {"trash", U"✗"}, {"copy", U"❐"}, {"mic", U"●"}, {"wave", U"≈"}, {"send", U"↑"}, {"stop", U"■"}, + {"camera", U"◘"}, {"image", U"▨"}, {"file", U"▤"}, {"skills", U"▦"}, {"plug", U"◈"}, {"incognito", U"◐"}, {"keyboard", U"▭"}, {"clipboard", U"▥"}, + {"help", U"?"}, {"bell", U"♪"}, {"sun", U"☼"}, {"moon", U"☾"}, {"contrast", U"◑"}, {"globe", U"◍"}, {"key", U"⌘"}, {"user", U"☺"}, {"folder", U"▣"}, + {"box", U"▢"}, {"automation", U"◷"}, {"star", U"★"}, {"download", U"⇩"}, {"forward", U"↪"}, {"tune", U"≡"}, {"filter", U"▽"}, {"terminal", U"❯"}, + {"link", U"∞"}, {"external", U"↗"}, {"sparkle", U"✦"}, {"thumbup", U"▲"}, {"thumbdown", U"▼"}, {"speaker", U"♫"}, {"refresh", U"↻"}, {"hash", U"#"}, + {"group", U"☻"}, {"pause", U"‖"}, {"play", U"▶"}, {"clock", U"◷"}, {"pin", U"⚑"}, {"edit", U"✎"}, {"eye", U"◉"}, {"inbox", U"▥"}, {"rocket", U"➤"}, + {"panel", U"◨"}, {"phone", U"▯"}, {"code", U"‹"}, {"expand", U"⇱"}, {"shrink", U"⇲"}, {"bolt", U"ϟ"}, {"monitor", U"▭"}, {"list", U"≣"}, {"cpu", U"▦"}, + {"warn", U"!"}, {"dot", U"●"}, + }; + return m; +} + +const std::map& GrrConst::string_fallback() { + static const std::map m = {{"library.sort_new", "Newest first"}, {"library.sort_old", "Oldest first"}, + {"library.sort_name", "Name"}, {"library.sort_size", "Largest first"}}; + return m; +} + +const std::vector>& GrrConst::keys() { + static const std::vector> v = { + {"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"}, + }; + return v; +} + +const std::map& GrrConst::lang_names() { + static const std::map m = {{"zh-CN", U"简体中文"}, {"zh-TW", U"繁體中文"}, {"en", U"English"}, {"hi", U"हिन्दी"}, {"pl", U"Polski"}, + {"es", U"Español"}, {"pt", U"Português"}, {"ja", U"日本語"}, {"fr", U"Français"}, {"ko", U"한국어"}, + {"de", U"Deutsch"}, {"nl", U"Nederlands"}}; + return m; +} + +const std::vector>& GrrConst::routes() { + static const std::vector> v = { + {"/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"}, + }; + return v; +} + +const std::map& GrrConst::file_icon() { + static const std::map m = {{"media", "image"}, {"app", "terminal"}, {"audio", "mic"}, {"file", "file"}}; + return m; +} + +const std::vector& GrrConst::text_mimes() { + static const std::vector v = {"text/", "application/json", "application/xml", "application/javascript", "application/x-sh", "application/yaml", "application/toml"}; + return v; +} + +const char* GrrConst::template_ask() { return "Create a template of yourself that I can share with somebody else."; } + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrConst.h b/taicpp/src/tui/GrrConst.h new file mode 100644 index 0000000..aad0293 --- /dev/null +++ b/taicpp/src/tui/GrrConst.h @@ -0,0 +1,58 @@ +#pragma once +// GrrConst: the constants and fixed tables of the grr terminal client (tai.py +// GRR_* module data). grrtui.md is the spec; GrrSpecTests-style drift checks +// live in tests/tui_spec.cpp. Nothing here is a server string, color or limit: +// the fallbacks are what python carries for a server that cannot be reached. +#include "TuiStr.h" +#include "TuiValue.h" +#include +#include +#include +#include +namespace detz::tui { + +constexpr const char* GRR_DEFAULT_URL = "http://127.0.0.1:8765"; +constexpr int GRR_HTTP_TIMEOUT = 30; +constexpr int GRR_SSE_TIMEOUT = 60; +constexpr double GRR_SSE_RETRY = 2.0; +constexpr long GRR_SSE_LINE_MAX = 1L << 20; +constexpr long GRR_BODY_MAX = 32L << 20; +constexpr int GRR_SIDE_W = 34; +constexpr int GRR_SIDE_MIN_COLS = 100; +constexpr int GRR_PANE_MIN_COLS = 150; +constexpr int GRR_PANE_MIN_W = 40; +constexpr int GRR_PANE_MAX_W = 90; +constexpr int GRR_MESSAGE_KEEP = 400; +constexpr double GRR_TOAST_SECS = 3.2; +constexpr double GRR_BANNER_SECS = 5.2; +constexpr int GRR_TOAST_MAX = 4; +constexpr int GRR_COMPOSER_MAX_ROWS = 6; +constexpr long GRR_VIEWER_MAX_BYTES = 2L << 20; +constexpr double GRR_PROMPT_QUIET = 0.8; // seconds of silence after a prompt-like last row before the command is offered for typing +constexpr int GRR_PROMPT_ASKS = 20; // times one process card opens its terminal by itself +constexpr int GRR_PROMPT_MAX = 8; // pending approvals asked at once as dialogs in one chat +constexpr int GRR_DRAFT_KEEP = 64; +constexpr int GRR_LIST_MORE = 12; +constexpr int GRR_MENU_MAX_H = 14; +constexpr int GRR_CONTROL_JOBS = 64; +constexpr int GRR_CONTROL_LINE = 65536; +constexpr int GRR_CONTROL_WAIT = 10; +constexpr int GRR_CONTROL_WAIT_MAX = 60; +constexpr int GRR_CONTROL_KEYS_MAX = 200; + +class GrrConst { +public: + static const TuiValue& palette_fallback(); // GRR_PALETTE_FALLBACK + static const std::vector& shapes(); // GRR_SHAPES + static const std::map>& avatar_rows(); // GRR_AVATAR_ROWS + static const std::map& icons(); // GRR_ICONS + static const std::map& string_fallback(); // GRR_STRING_FALLBACK + static const std::vector>& keys(); // GRR_KEYS + static const std::map& lang_names(); // GRR_LANG_NAMES + static const std::vector>& routes(); // GRR_ROUTES (pattern, view name) + static const std::map& file_icon(); // GRR_FILE_ICON + static const std::vector& text_mimes(); // GRR_TEXT_MIMES + static const char* template_ask(); // GRR_TEMPLATE_ASK +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrControl.cpp b/taicpp/src/tui/GrrControl.cpp new file mode 100644 index 0000000..006a099 --- /dev/null +++ b/taicpp/src/tui/GrrControl.cpp @@ -0,0 +1,408 @@ +#include "GrrControl.h" +#include "GrrShell.h" +#include "TuiStr.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +namespace { +double now_seconds() { + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +// python float(value) for the numbers a request may carry +double as_number(const TuiValue& v, double dflt) { + if (v.is_null()) throw std::invalid_argument("float() argument must be a string or a real number, not 'NoneType'"); + if (v.is_num()) return v.as_float(dflt); + if (v.is_str()) { + const std::string& s = v.as_str(); + char* end = nullptr; + double d = std::strtod(s.c_str(), &end); + if (s.empty() || (end && *end)) throw std::invalid_argument("could not convert string to float: '" + s + "'"); + return d; + } + throw std::invalid_argument("float() argument must be a string or a real number"); +} + +bool is_alpha_ascii(char32_t c) { return (c >= U'a' && c <= U'z') || (c >= U'A' && c <= U'Z'); } + +// python type(obj).__name__: the demangled class name without its namespace +std::string class_name(const TuiPanel* panel) { + int status = 0; + char* raw = abi::__cxa_demangle(typeid(*panel).name(), nullptr, nullptr, &status); + std::string name = status == 0 && raw ? raw : typeid(*panel).name(); + std::free(raw); + size_t at = name.rfind("::"); + return at == std::string::npos ? name : name.substr(at + 2); +} + +struct RequestError : std::runtime_error { + using std::runtime_error::runtime_error; +}; +} // namespace + +bool GrrControl::Job::wait(double seconds) { + std::unique_lock g(lock); + return event.wait_for(g, std::chrono::duration(seconds), [this] { return set; }); +} + +std::string GrrControl::default_path() { + const char* tai_home = std::getenv("TAI_HOME"); + std::string home = tai_home && *tai_home ? tai_home : std::string(std::getenv("HOME") ? std::getenv("HOME") : "") + "/.tai"; + const char* sock = std::getenv("TAI_TUI_SOCK"); + return sock && *sock ? std::string(sock) : home + "/tui.sock"; +} + +TuiKey GrrControl::key_event(const std::string& name) { + UStr full = TuiStr::u(name); + std::vector parts = TuiStr::split(full, U'+'); + UStr base = parts.back(); + std::vector mods; + for (size_t i = 0; i + 1 < parts.size(); i++) mods.push_back(TuiStr::s(parts[i])); + UStr ch; + if (base == U"space") ch = U" "; + else if (base == U"enter") ch = U"\n"; + else if (base == U"tab") ch = U"\t"; + else if (base.size() == 1) ch = base; + if (!mods.empty() && base.size() == 1 && std::find(mods.begin(), mods.end(), "ctrl") != mods.end()) { + if (is_alpha_ascii(base[0])) ch = UStr(1, static_cast(TuiStr::lower(base)[0] & 0x1f)); + else ch = UStr(); + } + std::string key = (base.size() > 1 || !mods.empty()) ? TuiStr::s(TuiStr::lower(full)) : TuiStr::s(base); + return TuiKey(key, ch, mods); +} + +TuiValue GrrControl::call(const TuiValue& payload, const std::string& path_in, double timeout) { + std::string target = path_in.empty() ? default_path() : path_in; + auto fail = [&](const std::string& why) { + return TuiValue::object({{"state", "error"}, {"error", "no running grr TUI answers on " + target + " (" + why + ")"}}); + }; + int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return fail(std::strerror(errno)); + struct timeval tv; + tv.tv_sec = static_cast(timeout); + tv.tv_usec = static_cast((timeout - static_cast(tv.tv_sec)) * 1e6); + ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + if (target.size() >= sizeof addr.sun_path) { + ::close(fd); + return fail("AF_UNIX path too long"); + } + std::memcpy(addr.sun_path, target.c_str(), target.size() + 1); + if (::connect(fd, reinterpret_cast(&addr), sizeof addr) != 0) { + std::string why = "[Errno " + std::to_string(errno) + "] " + std::strerror(errno); + ::close(fd); + return fail(why); + } + std::string out = payload.dumps() + "\n"; + size_t sent = 0; + while (sent < out.size()) { + ssize_t n = ::send(fd, out.data() + sent, out.size() - sent, MSG_NOSIGNAL); + if (n <= 0) { + std::string why = n < 0 ? std::strerror(errno) : "closed"; + ::close(fd); + return fail(why); + } + sent += static_cast(n); + } + std::string data; + char buf[65536]; + while (data.empty() || data.back() != '\n') { + ssize_t n = ::recv(fd, buf, sizeof buf, 0); + if (n < 0) { + std::string why = errno == EAGAIN || errno == EWOULDBLOCK ? "timed out" : std::strerror(errno); + ::close(fd); + return fail(why); + } + if (n == 0) break; + data.append(buf, static_cast(n)); + } + ::close(fd); + bool ok = true; + TuiValue reply = TuiValue::loads(data, &ok); + if (!ok) return TuiValue::object({{"state", "error"}, {"error", "unreadable reply"}}); + return reply; +} + +GrrControl::GrrControl(GrrShell& sh, std::string p) : shell(&sh), path(p.empty() ? default_path() : std::move(p)) {} + +GrrControl::~GrrControl() { stop(); } + +bool GrrControl::start() { + std::error_code ec; + if (std::filesystem::exists(path, ec)) { + int probe = ::socket(AF_UNIX, SOCK_STREAM, 0); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, path.c_str(), sizeof addr.sun_path - 1); + struct timeval tv{1, 0}; + ::setsockopt(probe, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + bool alive = ::connect(probe, reinterpret_cast(&addr), sizeof addr) == 0; + ::close(probe); + if (alive) return false; + ::unlink(path.c_str()); + } + std::filesystem::path parent = std::filesystem::path(path).parent_path(); + if (!parent.empty()) std::filesystem::create_directories(parent); + sock = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (sock < 0) throw std::system_error(errno, std::generic_category(), "socket"); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + if (path.size() >= sizeof addr.sun_path) { + ::close(sock); + sock = -1; + throw std::system_error(ENAMETOOLONG, std::generic_category(), "AF_UNIX path too long"); + } + std::memcpy(addr.sun_path, path.c_str(), path.size() + 1); + if (::bind(sock, reinterpret_cast(&addr), sizeof addr) != 0) { + int e = errno; + ::close(sock); + sock = -1; + throw std::system_error(e, std::generic_category(), path); + } + ::chmod(path.c_str(), 0600); + ::listen(sock, 8); + stopped = false; + thread = std::thread([this] { accept_loop(); }); + return true; +} + +void GrrControl::stop() { + stopped = true; + if (thread.joinable()) thread.join(); + if (sock >= 0) { + ::close(sock); + ::unlink(path.c_str()); + sock = -1; + } +} + +void GrrControl::accept_loop() { + while (!stopped) { + pollfd p{sock, POLLIN, 0}; + int r = ::poll(&p, 1, 100); + if (r <= 0) continue; + int conn = ::accept(sock, nullptr, nullptr); + if (conn < 0) continue; + std::thread([this, conn] { serve(conn); }).detach(); + } +} + +void GrrControl::serve(int conn) { + struct timeval tv; + tv.tv_sec = static_cast(WAIT_MAX) + 5; + tv.tv_usec = 0; + ::setsockopt(conn, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + std::string data; + char buf[4096]; + bool failed = false; + while (data.find('\n') == std::string::npos && data.size() < LINE) { + ssize_t n = ::recv(conn, buf, sizeof buf, 0); + if (n < 0) { failed = true; break; } + if (n == 0) break; + data.append(buf, static_cast(n)); + } + if (failed) { + ::close(conn); + return; + } + TuiValue reply; + bool ok = true; + TuiValue request = TuiValue::loads(data, &ok); + if (!ok) { + reply = TuiValue::object({{"state", "error"}, {"error", "bad request: unreadable json"}}); + } else { + try { + reply = handle(request); + } catch (const std::exception& e) { + reply = TuiValue::object({{"state", "error"}, {"error", std::string("bad request: ") + e.what()}}); + } + } + std::string out = reply.dumps() + "\n"; + size_t sent = 0; + while (sent < out.size()) { + ssize_t n = ::send(conn, out.data() + sent, out.size() - sent, MSG_NOSIGNAL); + if (n <= 0) break; + sent += static_cast(n); + } + ::close(conn); +} + +TuiValue GrrControl::handle(const TuiValue& request) { + if (!request.is_dict()) throw std::invalid_argument("request must be an object"); + TuiValue op_v = request.get("op"); + std::string op = op_v.truthy() ? op_v.str() : std::string(); + double wait = std::max(0.0, std::min(as_number(request.get("wait", TuiValue(WAIT)), WAIT), WAIT_MAX)); + JobP job; + if (op == "result") { + std::lock_guard g(lock); + auto it = jobs.find(request.get("id", TuiValue(0)).as_int()); + if (it == jobs.end()) return TuiValue::object({{"state", "error"}, {"error", "no such job"}}); + job = it->second; + } else { + if (op != "screen" && op != "key" && op != "type" && op != "click" && op != "goto") + return TuiValue::object({{"state", "error"}, {"error", "unknown op " + TuiValue(op).repr()}}); + job = submit(op, request); + } + if (request.get("async").truthy() && op != "result") { + std::lock_guard g(job->lock); + return TuiValue::object({{"id", job->id}, {"state", job->state}}); + } + job->wait(wait); + return view(job); +} + +TuiValue GrrControl::view(const JobP& job) { + std::lock_guard g(job->lock); + TuiValue out = TuiValue::object({{"id", job->id}, {"state", job->state}}); + if (job->state == "done") out.set("result", job->result); + else if (job->state == "error") out.set("error", job->error); + return out; +} + +GrrControl::JobP GrrControl::submit(const std::string& op, const TuiValue& request) { + auto job = std::make_shared(); + { + std::lock_guard g(lock); + job->id = ++seq; + job->op = op; + jobs[job->id] = job; + order.push_back(job); + while (static_cast(order.size()) > JOBS) { + jobs.erase(order.front()->id); + order.pop_front(); + } + } + shell->app->run_on_loop([this, job, request] { run(job, request); }); + return job; +} + +void GrrControl::finish(const JobP& job, const TuiValue& result, const std::string& error) { + { + std::lock_guard g(job->lock); + job->result = result; + job->error = error; + job->state = error.empty() ? "done" : "error"; + job->set = true; + } + job->event.notify_all(); +} + +void GrrControl::run(const JobP& job, const TuiValue& request) { + std::optional outcome; + try { + if (job->op == "screen") outcome = op_screen(job, request); + else if (job->op == "key") outcome = op_key(request); + else if (job->op == "type") outcome = op_type(request); + else if (job->op == "click") outcome = op_click(request); + else outcome = op_goto(request); + } catch (const RequestError& e) { + finish(job, TuiValue(), e.what()); + return; + } catch (const std::exception& e) { + finish(job, TuiValue(), std::string("Exception: ") + e.what()); + return; + } + if (outcome) finish(job, *outcome); +} + +TuiValue GrrControl::screen_info() { + TuiApp& app = *shell->app; + app.layout(); + TuiPanel* focus = app.focused; + TuiValue out = TuiValue::dict(); + out.set("route", shell->router.current()); + out.set("cols", app.cols); + out.set("rows", app.rows); + out.set("signed_in", shell->signed_in); + out.set("focus", focus ? class_name(focus) : std::string()); + out.set("screen", app.render_text()); + return out; +} + +std::optional GrrControl::op_screen(const JobP& job, const TuiValue& request) { + TuiValue until = request.get("until"); + if (!until.truthy()) return screen_info(); + std::string needle = until.str(); + double deadline = now_seconds() + std::max(0.0, std::min(as_number(request.get("timeout", TuiValue(WAIT)), WAIT), WAIT_MAX)); + auto look = std::make_shared>(); + *look = [this, job, needle, deadline, look] { + TuiValue info = screen_info(); + bool found = info.get("screen").str().find(needle) != std::string::npos; + if (found || now_seconds() >= deadline) { + TuiValue done = info; + done.set("matched", found); + finish(job, done); + *look = nullptr; // break the self-reference + } else { + auto again = look; + shell->app->after(0.2, [again] { if (*again) (*again)(); }); + } + }; + (*look)(); + return std::nullopt; +} + +TuiValue GrrControl::op_key(const TuiValue& request) { + TuiValue keys = request.get("keys"); + if (!keys.truthy()) keys = TuiValue::list(); + if (!keys.is_list() || keys.items().size() > KEYS_MAX) throw RequestError("ValueError: keys must be a list of at most " + std::to_string(KEYS_MAX) + " names"); + for (auto& name : keys.items()) { + TuiKey event = key_event(name.str()); + shell->app->dispatch(event); + } + shell->app->wake(); + return TuiValue::object({{"sent", static_cast(keys.items().size())}}); +} + +TuiValue GrrControl::op_type(const TuiValue& request) { + TuiValue t = request.get("text"); + UStr text = t.truthy() ? TuiStr::u(t.str()) : UStr(); + text = TuiStr::head(text, static_cast(KEYS_MAX * 20)); + for (char32_t c : text) { + std::string name = c == U'\n' ? "enter" : (c == U' ' ? "space" : TuiStr::s(c)); + TuiKey event(name, c == U'\n' ? UStr(U"\n") : UStr(1, c), {}); + shell->app->dispatch(event); + } + shell->app->wake(); + return TuiValue::object({{"typed", static_cast(text.size())}}); +} + +TuiValue GrrControl::op_click(const TuiValue& request) { + if (!request.has("x")) throw RequestError("KeyError: 'x'"); + if (!request.has("y")) throw RequestError("KeyError: 'y'"); + int x = static_cast(request.get("x").as_int()); + int y = static_cast(request.get("y").as_int()); + TuiApp& app = *shell->app; + app.layout(); + TuiClick down(x, y, 0, {}, true); + app.dispatch(down); + TuiClick up(x, y, 0, {}, false); + app.dispatch(up); + app.wake(); + return TuiValue::object({{"clicked", TuiValue::array({TuiValue(x), TuiValue(y)})}}); +} + +TuiValue GrrControl::op_goto(const TuiValue& request) { + TuiValue r = request.get("route"); + std::string route = r.truthy() ? r.str() : std::string("/"); + if (route.empty() || route[0] != '/') throw RequestError("ValueError: a route starts with /"); + shell->router.go(route); + return TuiValue::object({{"route", shell->router.current()}}); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrControl.h b/taicpp/src/tui/GrrControl.h new file mode 100644 index 0000000..a0163c1 --- /dev/null +++ b/taicpp/src/tui/GrrControl.h @@ -0,0 +1,85 @@ +#pragma once +// GrrControl: lets agents drive the running grr TUI over a local unix socket +// (mode 0600), one JSON line in and one out (tai.py GRR_CONTROL_*, grr_control_path, +// GrrControl, grr_control_call). An operation is a job on the UI loop: screen, key, +// type, click, goto and result. Every one can be async: the call returns a job id at +// once and `result` collects it (with a wait). `screen` may wait for text to appear +// (`until`). At most GRR_CONTROL_JOBS finished jobs are kept. +#include "TuiValue.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +class GrrShell; +class TuiKey; + +class GrrControl { +public: + static constexpr int JOBS = 64; + static constexpr size_t LINE = 65536; + static constexpr double WAIT = 10; + static constexpr double WAIT_MAX = 60; + static constexpr size_t KEYS_MAX = 200; + + // one request in flight: shared with the connection thread that waits on it + struct Job { + long long id{0}; + std::string op; + std::string state{"running"}; + TuiValue result; + std::string error; + std::mutex lock; + std::condition_variable event; + bool set{false}; + bool wait(double seconds); + }; + using JobP = std::shared_ptr; + + static std::string default_path(); // grr_control_path: TAI_TUI_SOCK or /tui.sock + static TuiKey key_event(const std::string& name); + static TuiValue call(const TuiValue& payload, const std::string& path = std::string(), double timeout = WAIT_MAX + 10); // grr_control_call + + explicit GrrControl(GrrShell& shell, std::string path = std::string()); + ~GrrControl(); + GrrControl(const GrrControl&) = delete; + GrrControl& operator=(const GrrControl&) = delete; + + bool start(); // false when another TUI already answers on the path; throws std::system_error otherwise + void stop(); + TuiValue handle(const TuiValue& request); + TuiValue view(const JobP& job); + TuiValue screen_info(); + + GrrShell* shell; + std::string path; + int sock{-1}; + long long seq{0}; + std::deque order; // oldest first, for the cap + std::map jobs; + std::mutex lock; + std::thread thread; + std::atomic stopped{false}; + +private: + JobP submit(const std::string& op, const TuiValue& request); + void finish(const JobP& job, const TuiValue& result, const std::string& error = std::string()); + void run(const JobP& job, const TuiValue& request); + void accept_loop(); + void serve(int conn); + // an op returns its result, or nullopt when it finishes the job itself later (python PENDING) + std::optional op_screen(const JobP& job, const TuiValue& request); + TuiValue op_key(const TuiValue& request); + TuiValue op_type(const TuiValue& request); + TuiValue op_click(const TuiValue& request); + TuiValue op_goto(const TuiValue& request); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrDialog.cpp b/taicpp/src/tui/GrrDialog.cpp new file mode 100644 index 0000000..a184ef1 --- /dev/null +++ b/taicpp/src/tui/GrrDialog.cpp @@ -0,0 +1,13 @@ +#include "GrrDialog.h" +namespace detz::tui { + +void GrrDialog::on_key(TuiKey& event) { + if (event.key == "ctrl+s") { + event.stop(); + if (pick) pick(primary); + return; + } + TuiDialog::on_key(event); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrDialog.h b/taicpp/src/tui/GrrDialog.h new file mode 100644 index 0000000..90a56f1 --- /dev/null +++ b/taicpp/src/tui/GrrDialog.h @@ -0,0 +1,50 @@ +#pragma once +// GrrDialog: the modal dialog of the grr scene (tai.py GrrShell.dialog builds a +// TuiDialog and hangs grr_done, grr_fields, grr_actions, grr_open and grr_page +// on it; here those are members). Ctrl+S picks the primary action, Esc closes. +#include "GrrBar.h" +#include "GrrPage.h" +#include "GrrSecretBox.h" +#include "TuiDialog.h" +namespace detz::tui { + +struct GrrField { + UStr value, label, placeholder; + int rows{1}; + int max{0}; + bool secret{false}; + bool multiline{false}; +}; +struct GrrAction { + UStr label; + TuiValue value; + bool primary{false}; + bool danger{false}; +}; +using GrrDone = std::function& texts)>; + +struct GrrDialogSpec { + UStr title, text; + std::vector fields; + std::optional> actions; + GrrDone done; + int w{0}; // 0 is python's None (64) + PanelP body; + std::optional> rows; + int page_h{0}; +}; + +class GrrDialog : public TuiDialog { +public: + explicit GrrDialog(TuiPanelOpts opts) : TuiDialog(std::move(opts)) {} + void on_key(TuiKey& event) override; + GrrDone done; // grr_done + std::vector> fields; // grr_fields + std::vector actions; // grr_actions + bool open{true}; // grr_open + std::shared_ptr page; // grr_page + GrrAction primary; + std::function pick; // dialog_pick bound to this dialog +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrEmitter.cpp b/taicpp/src/tui/GrrEmitter.cpp new file mode 100644 index 0000000..5427160 --- /dev/null +++ b/taicpp/src/tui/GrrEmitter.cpp @@ -0,0 +1,29 @@ +#include "GrrEmitter.h" +#include +namespace detz::tui { + +std::function GrrEmitter::on(const std::string& kind, Handler fn) { + long id = ++next_id_; + handlers_[kind].emplace_back(id, std::move(fn)); + return [this, kind, id] { + auto it = handlers_.find(kind); + if (it == handlers_.end()) return; + auto& v = it->second; + v.erase(std::remove_if(v.begin(), v.end(), [id](const std::pair& p) { return p.first == id; }), v.end()); + }; +} + +void GrrEmitter::emit(const std::string& kind, const TuiValue& data) { + auto it = handlers_.find(kind); + if (it == handlers_.end()) return; + auto copy = it->second; + for (auto& p : copy) { + try { + p.second(data); + } catch (const std::exception&) { + errors++; + } + } +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrEmitter.h b/taicpp/src/tui/GrrEmitter.h new file mode 100644 index 0000000..42887cc --- /dev/null +++ b/taicpp/src/tui/GrrEmitter.h @@ -0,0 +1,24 @@ +#pragma once +// GrrEmitter: tiny publish and subscribe. A handler that throws is counted in +// `errors` and never stops the others (tai.py GrrEmitter). +#include "TuiValue.h" +#include +#include +#include +#include +namespace detz::tui { + +class GrrEmitter { +public: + using Handler = std::function; + virtual ~GrrEmitter() = default; + // returns the function that removes this handler again (python: the lambda `on` returns) + std::function on(const std::string& kind, Handler fn); + void emit(const std::string& kind, const TuiValue& data = TuiValue()); + long errors{0}; +private: + long next_id_{0}; + std::map>> handlers_; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrError.cpp b/taicpp/src/tui/GrrError.cpp new file mode 100644 index 0000000..33ec301 --- /dev/null +++ b/taicpp/src/tui/GrrError.cpp @@ -0,0 +1,20 @@ +#include "GrrError.h" +namespace detz::tui { + +UStr GrrError::text() const { + std::string joined = message_ + (hint.empty() ? "" : " " + hint); + return TuiStr::strip(TuiStr::u(joined)); +} + +std::optional GrrError::from(std::exception_ptr error) { + if (!error) return std::nullopt; + try { + std::rethrow_exception(error); + } catch (const GrrError& e) { + return e; + } catch (...) { + return std::nullopt; + } +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrError.h b/taicpp/src/tui/GrrError.h new file mode 100644 index 0000000..24e24a2 --- /dev/null +++ b/taicpp/src/tui/GrrError.h @@ -0,0 +1,26 @@ +#pragma once +// GrrError: 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 (tai.py GrrError). +#include "TuiStr.h" +#include "TuiValue.h" +#include +#include +#include +#include +namespace detz::tui { + +class GrrError : public std::runtime_error { +public: + GrrError(int status, std::string code, const std::string& message, std::string hint = "", TuiValue vars = TuiValue::dict()) + : std::runtime_error(message), status(status), code(std::move(code)), message_(message), hint(std::move(hint)), vars(std::move(vars)) {} + UStr text() const; // (message + (" " + hint if hint)).strip() + // the GrrError inside an exception_ptr, if it is one + static std::optional from(std::exception_ptr error); + int status; + std::string code; + std::string message_; + std::string hint; + TuiValue vars; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrFileViewer.cpp b/taicpp/src/tui/GrrFileViewer.cpp new file mode 100644 index 0000000..72179cd --- /dev/null +++ b/taicpp/src/tui/GrrFileViewer.cpp @@ -0,0 +1,117 @@ +#include "GrrFileViewer.h" +#include "GrrShell.h" +#include "TuiLog.h" +#include +#include +#include +#include +namespace detz::tui { + +void GrrFileViewer::open() { + bool external = f.get("url").truthy(); + std::string path = external ? f.get("url").str() : "/api/files/" + f.get("id").str(); + auto self = shared_from_this(); + g->app->offload( + [self, external, path]() -> TuiValue { + if (external) return TuiValue(); + auto r = self->g->client.fetch(path, GRR_VIEWER_MAX_BYTES); + TuiValue v = TuiValue::dict(); + v.set("data", r.data); + v.set("cut", r.cut); + v.set("type", r.content_type); + return v; + }, + [self](const TuiValue& res, std::exception_ptr err) { + if (err) { + self->g->error(err); + return; + } + self->show(res); + }); +} + +void GrrFileViewer::show(const TuiValue& res) { + GrrShell& g_ = *g; + UStr name = f.get("name").truthy() ? TuiStr::u(f.get("name").str()) : TuiStr::u(f.get("id").str()); + std::string data = res.is_dict() ? res.get("data").str() : ""; + bool cut = res.is_dict() && res.get("cut").truthy(); + std::string ctype = res.is_dict() ? res.get("type").str() : ""; + std::string mime = f.get("mime").truthy() ? f.get("mime").str() : ctype; + size_t semi = mime.find(';'); + if (semi != std::string::npos) mime = mime.substr(0, semi); + bool is_text = false; + for (auto& prefix : GrrConst::text_mimes()) if (mime.compare(0, prefix.size(), prefix) == 0) is_text = true; + if (!is_text && mime.empty() && data.substr(0, 2048).find('\0') == std::string::npos) is_text = true; + auto self = shared_from_this(); + std::vector actions = {GrrAction{g_.t("ui.close"), TuiValue("close"), true}, GrrAction{g_.t("ui.download"), TuiValue("save")}}; + if (is_text && !data.empty()) { + UStr text = TuiStr::u(data); + auto lines = TuiStr::split(text, U'\n'); + if (lines.size() > 5000) lines.resize(5000); + UStr numbered; + for (size_t i = 0; i < lines.size(); i++) { + if (i) numbered += U"\n"; + char buf[32]; + std::snprintf(buf, sizeof buf, "%4zu ", i + 1); + numbered += TuiStr::u(buf) + lines[i]; + } + TuiPanelOpts lo; + lo.h = TuiSpec(std::max(6, g_.app->rows - 12)); + lo.style = g_.ui->st("ink", "code-bg"); + lo.focusable = true; + auto body = std::make_shared(lo); + for (auto& line : TuiStr::split(numbered, U'\n')) body->append(line, g_.ui->st("ink", "code-bg")); + GrrDialogSpec d; + d.title = name; + d.text = cut ? g_.t("files.cut", {{"shown", "2 MB"}, {"total", ""}}) : UStr(); + d.w = std::min(g_.app->cols - 2, 110); + d.body = body; + d.actions = actions; + d.done = [self, data](const TuiValue& v, const std::vector&) { + if (v.is_str() && v.as_str() == "save") self->save(data); + }; + g_.dialog(std::move(d)); + } else { + GrrDialogSpec d; + d.title = name; + long long size = f.get("size").truthy() ? f.get("size").as_int() : static_cast(data.size()); + d.text = TuiStr::u(mime.empty() ? "file" : mime) + U" · " + GrrFormat::bytes(size); + d.actions = std::vector{GrrAction{g_.t("ui.close"), TuiValue("close")}, GrrAction{g_.t("viewer.save"), TuiValue("save"), true}}; + d.done = [self, data](const TuiValue& v, const std::vector&) { + if (v.is_str() && v.as_str() == "save") self->save(data, true); + }; + g_.dialog(std::move(d)); + } +} + +void GrrFileViewer::save(const std::string& data, bool open_after) { + GrrShell& g_ = *g; + std::string raw_name = f.get("name").truthy() ? f.get("name").str() : f.get("id").str(); + std::string name; + for (char c : raw_name) name.push_back((std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-') ? c : '_'); + if (name.size() > 80) name.resize(80); + if (name.empty()) name = "file"; + auto self = shared_from_this(); + TuiValue file_id = f.get("id"); + g_.app->offload( + [self, data, name, file_id]() -> TuiValue { + std::string body = data; + if (body.empty() && file_id.truthy()) body = self->g->client.fetch("/api/files/" + file_id.str() + "?download=1", GRR_BODY_MAX).data; + const char* tmp = std::getenv("TMPDIR"); + std::string path = std::string(tmp && *tmp ? tmp : "/tmp") + "/grr-" + name; + std::ofstream out(path, std::ios::binary); + if (!out) throw std::runtime_error("cannot write " + path); + out.write(body.data(), static_cast(body.size())); + return TuiValue(path); + }, + [self, open_after](const TuiValue& path, std::exception_ptr err) { + if (err) { + self->g->error(err); + return; + } + self->g->toast(U"Saved " + TuiStr::u(path.str())); + if (open_after) self->g->open_path(path.str()); + }); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrFileViewer.h b/taicpp/src/tui/GrrFileViewer.h new file mode 100644 index 0000000..c3bc68e --- /dev/null +++ b/taicpp/src/tui/GrrFileViewer.h @@ -0,0 +1,22 @@ +#pragma once +// 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) +// (tai.py GrrFileViewer). +#include "GrrDialog.h" +#include "TuiValue.h" +#include +namespace detz::tui { + +class GrrShell; + +class GrrFileViewer : public std::enable_shared_from_this { +public: + GrrFileViewer(GrrShell& shell, TuiValue file) : g(&shell), f(std::move(file)) {} + void open(); + void show(const TuiValue& res); + void save(const std::string& data, bool open_after = false); + GrrShell* g; + TuiValue f; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrFormat.cpp b/taicpp/src/tui/GrrFormat.cpp new file mode 100644 index 0000000..351ab5d --- /dev/null +++ b/taicpp/src/tui/GrrFormat.cpp @@ -0,0 +1,168 @@ +#include "GrrFormat.h" +#include +#include +#include +#include +namespace detz::tui { + +std::function GrrFormat::now_fn; + +double GrrFormat::now() { + if (now_fn) return now_fn(); + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +namespace { +std::tm local_tm(double ts) { + std::time_t t = static_cast(std::floor(ts)); + std::tm tm{}; + localtime_r(&t, &tm); + return tm; +} +// naive "wall clock" seconds of a local time (python subtracts naive datetimes) +long long naive_secs(const std::tm& tm_in) { + std::tm tm = tm_in; + return static_cast(timegm(&tm)); +} +bool same_date(const std::tm& a, const std::tm& b) { return a.tm_year == b.tm_year && a.tm_yday == b.tm_yday; } +std::tm minus_one_day(const std::tm& now_tm) { + std::tm tm = now_tm; + long long secs = naive_secs(tm) - 86400; + std::time_t t = static_cast(secs); + gmtime_r(&t, &tm); + return tm; +} +long long floor_days(long long delta_secs) { + long long d = delta_secs / 86400; + if (delta_secs % 86400 != 0 && delta_secs < 0) d -= 1; + return d; +} +std::string fmt(const std::tm& tm, const char* pattern) { + char buf[96]; + std::strftime(buf, sizeof buf, pattern, &tm); + return buf; +} +} // namespace + +UStr GrrFormat::clock(double ts) const { return TuiStr::u(fmt(local_tm(ts), "%H:%M")); } + +UStr GrrFormat::list_time(const TuiValue& ts) const { + if (!ts.truthy()) return UStr(); + return list_time(ts.as_float()); +} + +UStr GrrFormat::list_time(double ts) const { + if (ts == 0.0) return UStr(); + std::tm d = local_tm(ts), nowtm = local_tm(now()); + if (same_date(d, nowtm)) return clock(ts); + if (same_date(d, minus_one_day(nowtm))) return s->t("time.yesterday"); + if (floor_days(naive_secs(nowtm) - naive_secs(d)) < 6) return TuiStr::u(fmt(d, "%A")); + return TuiStr::u(std::to_string(d.tm_mday) + " " + fmt(d, "%b")); +} + +UStr GrrFormat::stamp(double ts) const { + std::tm d = local_tm(ts), nowtm = local_tm(now()); + if (same_date(d, nowtm)) return s->t("time.today") + U" " + clock(ts); + if (same_date(d, minus_one_day(nowtm))) return s->t("time.yesterday") + U" " + clock(ts); + if (floor_days(naive_secs(nowtm) - naive_secs(d)) < 6) return TuiStr::u(fmt(d, "%A")) + U" " + clock(ts); + return TuiStr::u(std::to_string(d.tm_mday) + " " + fmt(d, "%b")) + U" " + clock(ts); +} + +UStr GrrFormat::short_date(double ts) const { + std::tm d = local_tm(ts); + return TuiStr::u(std::to_string(d.tm_mday) + " " + fmt(d, "%b")); +} + +UStr GrrFormat::ago(double ts) const { + long secs = static_cast(ts - now()); // int(): truncates toward zero + static const std::pair units[] = {{"day", 86400}, {"hour", 3600}, {"minute", 60}}; + for (auto& u : units) { + if (std::labs(secs) >= u.second) { + long n = static_cast(std::nearbyint(static_cast(secs) / static_cast(u.second))); + if (std::string(u.first) == "day" && n == -1) return U"yesterday"; + std::string word = std::to_string(std::labs(n)) + " " + u.first + (std::labs(n) == 1 ? "" : "s"); + return TuiStr::u(n < 0 ? word + " ago" : "in " + word); + } + } + return U"now"; +} + +UStr GrrFormat::bytes(long long n) { + char buf[48]; + if (n < 1024) { std::snprintf(buf, sizeof buf, "%lld B", n); return TuiStr::u(buf); } + if (n < 1048576) { + std::snprintf(buf, sizeof buf, n < 10240 ? "%.1f kB" : "%.0f kB", n / 1024.0); + return TuiStr::u(buf); + } + if (n < 1073741824LL) { std::snprintf(buf, sizeof buf, "%.1f MB", n / 1048576.0); return TuiStr::u(buf); } + std::snprintf(buf, sizeof buf, "%.2f GB", n / 1073741824.0); + return TuiStr::u(buf); +} + +UStr GrrFormat::num(long long n) { + std::string digits = std::to_string(n < 0 ? -n : n); + std::string out; + for (size_t i = 0; i < digits.size(); i++) { + if (i && (digits.size() - i) % 3 == 0) out.push_back(','); + out.push_back(digits[i]); + } + return TuiStr::u(n < 0 ? "-" + out : out); +} + +UStr GrrFormat::weekday(long i) { + static const char32_t* names[] = {U"Monday", U"Tuesday", U"Wednesday", U"Thursday", U"Friday", U"Saturday", U"Sunday"}; + long k = ((i % 7) + 7) % 7; + return names[k]; +} + +UStr GrrFormat::schedule(const TuiValue& sc) const { + if (!sc.truthy()) return UStr(); + std::string kind = sc.get("kind").is_str() ? sc.get("kind").as_str() : ""; + if (kind == "interval") { + long long sec = sc.get("every_seconds").truthy() ? sc.get("every_seconds").as_int() : 0; + if (sec && sec % 86400 == 0) return s->t("sched.every_days", {{"n", std::to_string(sec / 86400)}}); + if (sec && sec % 3600 == 0) return s->t("sched.every_hours", {{"n", std::to_string(sec / 3600)}}); + if (sec && sec % 60 == 0) return s->t("sched.every_minutes", {{"n", std::to_string(sec / 60)}}); + return s->t("sched.every_seconds", {{"n", std::to_string(sec)}}); + } + if (kind == "daily") return s->t("sched.daily", {{"time", sc.get("time", TuiValue("")).str()}}); + return s->t("sched.weekly", {{"day", TuiStr::s(weekday(sc.get("day", TuiValue(0)).as_int()))}, {"time", sc.get("time", TuiValue("")).str()}}); +} + +UStr GrrFormat::next_run(const TuiValue& ts) { + if (!ts.truthy()) return U"-"; + return TuiStr::u(fmt(local_tm(ts.as_float()), "%a %d %b %H:%M")); +} + +UStr GrrFormat::domain(const std::string& url) { + // urllib.parse.urlsplit(url).hostname or url + std::string rest = url; + size_t colon = rest.find(':'); + if (colon != std::string::npos && colon > 0 && std::isalpha(static_cast(rest[0]))) { + bool scheme = true; + for (size_t i = 0; i < colon; i++) { + unsigned char ch = static_cast(rest[i]); + if (!(std::isalnum(ch) || ch == '+' || ch == '-' || ch == '.')) { scheme = false; break; } + } + if (scheme) rest = rest.substr(colon + 1); + } + std::string host; + if (rest.compare(0, 2, "//") == 0) { + size_t end = rest.find_first_of("/?#", 2); + std::string netloc = rest.substr(2, end == std::string::npos ? std::string::npos : end - 2); + size_t at = netloc.rfind('@'); + if (at != std::string::npos) netloc = netloc.substr(at + 1); + if (!netloc.empty() && netloc[0] == '[') { + size_t close = netloc.find(']'); + host = close == std::string::npos ? std::string() : netloc.substr(1, close - 1); + } else { + size_t p = netloc.find(':'); + host = p == std::string::npos ? netloc : netloc.substr(0, p); + } + } + for (auto& ch : host) if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + 32); + if (host.empty()) return TuiStr::u(url); + return TuiStr::u(host.compare(0, 4, "www.") == 0 ? host.substr(4) : host); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrFormat.h b/taicpp/src/tui/GrrFormat.h new file mode 100644 index 0000000..50e723f --- /dev/null +++ b/taicpp/src/tui/GrrFormat.h @@ -0,0 +1,33 @@ +#pragma once +// GrrFormat: times, sizes and schedules in the words of the server's string +// table (tai.py GrrFormat). English day and month names come from the C locale, +// like python's strftime without setlocale. +#include "GrrStrings.h" +#include +namespace detz::tui { + +class GrrFormat { +public: + explicit GrrFormat(const GrrStrings* strings) : s(strings) {} + void rebind(const GrrStrings* strings) { s = strings; } + UStr clock(double ts) const; + UStr list_time(const TuiValue& ts) const; // "" for a missing ts + UStr list_time(double ts) const; + UStr stamp(double ts) const; + UStr short_date(double ts) const; + UStr ago(double ts) const; + static UStr bytes(long long n); + static UStr num(long long n); + static UStr weekday(long i); + static UStr clock_label(const UStr& hhmm) { return hhmm; } + UStr schedule(const TuiValue& sched) const; + static UStr next_run(const TuiValue& ts); // "-" when empty + static UStr domain(const std::string& url); + // clock sources (tests inject a fixed one); null means the system clock + static std::function now_fn; + static double now(); +private: + const GrrStrings* s; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrHome.cpp b/taicpp/src/tui/GrrHome.cpp new file mode 100644 index 0000000..494dfdd --- /dev/null +++ b/taicpp/src/tui/GrrHome.cpp @@ -0,0 +1,299 @@ +#include "GrrHome.h" +#include "GrrShell.h" +#include +namespace detz::tui { + +namespace { +double number(const TuiValue& v) { return v.is_num() ? v.as_float() : 0.0; } +TuiValue or_dict(const TuiValue& v) { return v.truthy() ? v : TuiValue::dict(); } +GrrArt blank_art() { + return [](GrrUi& ui, const std::string& bg) { return SpanRows{SpanRow{TuiSpan(U" ", ui.st("ink", bg))}, SpanRow{TuiSpan(U" ", ui.st("ink", bg))}}; }; +} +} // namespace + +GrrHome::GrrHome(GrrShell& shell) : g(&shell) { + GrrUiP ui = shell.ui; + TuiPanelOpts ro; + ro.w = TuiSpec("100%"); + ro.h = TuiSpec("100%"); + ro.style = ui->st("ink", "bg"); + root = std::make_shared(ro); + TuiPanelOpts po; + po.style = ui->st("ink", "bg"); + po.h = TuiSpec("1fr"); + page = std::make_shared(ui, false, true, po); + TuiPanelOpts bo; + bo.style = ui->st("ink", "bg"); + bar = std::make_shared(ui, bo); + root->add(page); + root->add(bar); + offs.push_back(shell.store.on("changed", [this](const TuiValue&) { paint(); })); + offs.push_back(shell.store.on("status", [this](const TuiValue&) { paint(); })); + paint(); +} + +void GrrHome::destroy() { + for (auto& off : offs) off(); + offs.clear(); +} + +void GrrHome::select(const std::string& p) { + path = p; + paint(); +} + +void GrrHome::latest() { + std::vector chats; + for (auto& c : g->store.chats) if (!(c.get("kind").is_str() && c.get("kind").as_str() == "private")) chats.push_back(c); + std::stable_sort(chats.begin(), chats.end(), [](const TuiValue& a, const TuiValue& b) { return -number(a.get("updated")) < -number(b.get("updated")); }); + g->router.go(!chats.empty() ? "/chat/" + chats[0].get("id").str() : std::string("/chat/new")); +} + +void GrrHome::paint() { + GrrShell& sh = *g; + GrrUiP ui = sh.ui; + auto& store = sh.store; + auto go = [&sh](const std::string& to) { return [&sh, to] { sh.router.go(to); }; }; + std::vector bots = store.bots; + std::stable_sort(bots.begin(), bots.end(), [](const TuiValue& a, const TuiValue& b) { + auto key = [](const TuiValue& bot) { + TuiValue chat = or_dict(bot.get("chat")); + TuiValue u = chat.get("updated"); + return -(u.truthy() ? number(u) : number(bot.get("updated", TuiValue(0)))); + }; + return key(a) < key(b); + }); + std::vector pinned, groups; + for (auto& c : store.chats) { + std::string kind = c.get("kind").is_str() ? c.get("kind").as_str() : ""; + if (c.get("pinned").truthy() && (kind == "general" || kind == "group")) pinned.push_back(c); + if (kind == "group" && !c.get("pinned").truthy()) groups.push_back(c); + } + TuiValue me = or_dict(sh.me); + std::vector items; + { + GrrConvOpts o; + o.avatar = GrrAvatar::user_art(me); + o.preview = U"grr " + TuiStr::u(me.get("version", TuiValue("")).str()); + o.action = [this] { latest(); }; + o.key = "me"; + items.push_back(std::make_shared(TuiStr::u(me.get("username", TuiValue("")).str()), o)); + } + items.push_back(std::make_shared(1)); + { + std::vector> rows; + GrrRowOpts a; + a.icon = "automation"; a.action = go("/automations"); a.key = "n-auto"; + rows.push_back(std::make_shared(sh.t("home.automations"), a)); + GrrRowOpts b; + b.icon = "box"; b.action = go("/library"); b.key = "n-lib"; + rows.push_back(std::make_shared(sh.t("home.library"), b)); + GrrRowOpts c; + c.icon = "folder"; c.action = go("/projects"); c.key = "n-proj"; + rows.push_back(std::make_shared(sh.t("home.projects"), c)); + for (auto& r : grr_group(rows)) items.push_back(r); + } + items.push_back(std::make_shared(1)); + items.push_back(std::make_shared(sh.t("home.bots"), "plus", [this] { plus_menu(); })); + for (size_t i = 0; i < bots.size() && i < 3; i++) { + const TuiValue& bot = bots[i]; + TuiValue chat = or_dict(bot.get("chat")); + TuiValue working = store.is_working(bot.get("id").str()); + GrrConvOpts o; + o.avatar = GrrAvatar::bot_art(bot); + o.time_text = ui->fmt.list_time(chat.get("updated")); + if (working.truthy()) o.preview = working.get("label").truthy() ? TuiStr::u(working.get("label").str()) : sh.t("chat.working"); + else o.preview = chat.get("preview").truthy() ? TuiStr::u(chat.get("preview").str()) : TuiStr::u(bot.get("description", TuiValue("")).str()); + o.dot = chat.get("unread").truthy(); + o.current = path == "/chat/" + (chat.has("id") ? chat.get("id").str() : std::string("-")); + o.busy = working.truthy(); + std::string cid = chat.get("id").str(); + o.action = [&sh, cid] { sh.router.go("/chat/" + cid); }; + o.key = "bot-" + bot.get("id").str(); + items.push_back(std::make_shared(TuiStr::u(bot.get("name").str()), o)); + } + for (size_t i = 0; i < groups.size() && i < 2; i++) { + const TuiValue& chat = groups[i]; + std::vector members; + for (auto& id : chat.get("bot_ids").items()) { + TuiValue m = store.bot(id); + if (m.truthy()) members.push_back(m); + } + GrrConvOpts o; + o.avatar = GrrAvatar::group_art(members); + o.time_text = ui->fmt.list_time(chat.get("updated")); + o.preview = chat.get("preview").truthy() ? TuiStr::u(chat.get("preview").str()) : sh.t("bots.group"); + o.dot = chat.get("unread").truthy(); + o.current = path == "/chat/" + chat.get("id").str(); + std::string cid = chat.get("id").str(); + o.action = [&sh, cid] { sh.router.go("/chat/" + cid); }; + o.key = "grp-" + chat.get("id").str(); + items.push_back(std::make_shared(TuiStr::u(chat.get("title", TuiValue("")).str()), o)); + } + if (bots.size() + groups.size() > 3) { + GrrRowOpts o; + o.link = true; o.action = go("/bots"); o.key = "more"; o.pos = "only"; + items.push_back(std::make_shared(sh.t("home.show_more"), o)); + } + if (bots.empty()) items.push_back(std::make_shared(sh.t("home.no_bots"))); + if (!pinned.empty()) { + items.push_back(std::make_shared(1)); + items.push_back(std::make_shared(sh.t("home.pinned"), pinned_open ? "up" : "down", [this] { toggle_pinned(); })); + if (pinned_open) { + for (auto& chat : pinned) { + GrrConvOpts o; + o.avatar = blank_art(); + o.preview = ui->fmt.short_date(number(chat.get("updated"))); + o.current = path == "/chat/" + chat.get("id").str(); + std::string cid = chat.get("id").str(); + o.action = [&sh, cid] { sh.router.go("/chat/" + cid); }; + TuiValue cc = chat; + o.menu = [this, cc] { chat_menu(cc); }; + o.key = "pin-" + chat.get("id").str(); + UStr title = chat.get("title").truthy() ? TuiStr::u(chat.get("title").str()) : sh.t("chat.untitled"); + items.push_back(std::make_shared(title, o)); + } + } + } + page->set_items(items); + GrrBarPart search; + search.text = U" " + ui->icon("search") + U" " + sh.t("home.search"); + search.fn = go("/search"); + search.bg = "card"; + search.fill = true; + GrrBarPart gear; + gear.text = U" " + ui->icon("gear") + U" "; + gear.fn = go("/settings"); + gear.bg = "card"; + GrrBarPart compose; + compose.text = U" " + ui->icon("compose") + U" "; + compose.fn = go("/chat/new"); + compose.bg = "card"; + bar->set({search, gear, compose}); +} + +void GrrHome::toggle_pinned() { + pinned_open = !pinned_open; + paint(); +} + +void GrrHome::plus_menu() { + GrrShell& sh = *g; + GrrMenuEntry a; + a.icon = "plus"; + a.label = sh.t("bots.new_bot"); + a.action = [&sh] { GrrBots::new_bot(sh); }; + GrrMenuEntry b; + b.icon = "group"; + b.label = sh.t("bots.new_group"); + b.action = [&sh] { sh.router.go("/bots?group=1"); }; + sh.menu({a, b}); +} + +void GrrHome::chat_menu(const TuiValue& chat) { + GrrShell& sh = *g; + std::string id = chat.get("id").str(); + auto rename = [&sh, chat, id] { + sh.edit(sh.t("chat.rename"), TuiStr::u(chat.get("title", TuiValue("")).str()), [&sh, id](const UStr& v) { + TuiValue body = TuiValue::dict(); + body.set("title", TuiStr::s(v)); + sh.call("PATCH", "/api/chats/" + id, body); + }); + }; + auto del = [&sh, id] { + sh.confirm(sh.t("chat.delete_title"), sh.t("chat.delete_text"), sh.t("ui.delete"), [&sh, id] { + sh.call("DELETE", "/api/chats/" + id, std::nullopt, [&sh, id](const TuiValue&) { + if (sh.router.current().find(id) != std::string::npos) sh.router.go("/"); + }); + }, true); + }; + GrrMenuEntry pin; + pin.icon = "pin"; + pin.label = chat.get("pinned").truthy() ? sh.t("chat.unpin") : sh.t("chat.pin"); + bool pinned_now = chat.get("pinned").truthy(); + pin.action = [&sh, id, pinned_now] { + TuiValue body = TuiValue::dict(); + body.set("pinned", !pinned_now); + sh.call("PATCH", "/api/chats/" + id, body); + }; + GrrMenuEntry ren; + ren.icon = "edit"; + ren.label = sh.t("chat.rename"); + ren.action = rename; + GrrMenuEntry trash; + trash.icon = "trash"; + trash.label = sh.t("ui.delete"); + trash.danger = true; + trash.action = del; + sh.menu({pin, ren, GrrMenuEntry::sep(), trash}); +} + +// ---- GrrBots --------------------------------------------------------------------------------------- + +void GrrBots::new_bot(GrrShell& g) { + TuiValue body = TuiValue::dict(); + body.set("name", TuiStr::s(g.t("bots.default_name"))); + g.call("POST", "/api/bots", body, [&g](const TuiValue& bot) { + std::string id = bot.get("id").str(); + g.store.refresh([&g, id](std::exception_ptr) { g.router.go("/bot/" + id + "?edit=1"); }); + }); +} + +void GrrBots::new_group(GrrShell& g) { + auto bots = g.store.bots; + if (bots.size() < 2) { + g.toast(g.t("bots.group_need_two"), "bad"); + return; + } + long long cap = g.ui->cap("GROUP_MAX_BOTS", 6); + auto picked = std::make_shared>(); + auto rows = std::make_shared>>(); + for (auto& bot : bots) { + GrrRowOpts o; + TuiValue b = bot; + o.lead = [b](GrrUi& ui, const std::string& bg) { return GrrAvatar::chip(ui, b, bg); }; + o.toggle = false; + std::string id = bot.get("id").str(); + o.on_toggle = [picked, rows, cap, id](bool on) { + if (on && static_cast(picked->size()) >= cap) { + for (auto& r : *rows) if (r->key && *r->key == id) { r->set_toggle(false); break; } + return; + } + if (on) picked->push_back(id); + else picked->erase(std::remove(picked->begin(), picked->end(), id), picked->end()); + }; + o.key = id; + rows->push_back(std::make_shared(TuiStr::u(bot.get("name").str()), o)); + } + GrrDialogSpec d; + d.title = g.t("bots.new_group"); + d.text = g.t("bots.group_hint", {{"n", std::to_string(cap)}}); + GrrField f; + f.placeholder = g.t("bots.group_name"); + f.max = 60; + d.fields.push_back(f); + std::vector items; + for (auto& r : grr_group(*rows)) items.push_back(r); + d.rows = items; + d.actions = std::vector{GrrAction{g.t("ui.cancel"), TuiValue("cancel")}, GrrAction{g.t("bots.group_create"), TuiValue("create"), true}}; + d.done = [&g, picked](const TuiValue& value, const std::vector& texts) { + if (!(value.is_str() && value.as_str() == "create")) return; + if (picked->size() < 2) { + g.toast(g.t("bots.group_need_two"), "bad"); + return; + } + TuiValue body = TuiValue::dict(); + body.set("kind", "group"); + TuiValue ids = TuiValue::list(); + for (auto& id : *picked) ids.push(id); + body.set("bot_ids", ids); + body.set("title", TuiStr::s(texts[0])); + g.call("POST", "/api/chats", body, [&g](const TuiValue& chat) { + std::string id = chat.get("id").str(); + g.store.refresh([&g, id](std::exception_ptr) { g.router.go("/chat/" + id); }); + }); + }; + g.dialog(std::move(d)); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrHome.h b/taicpp/src/tui/GrrHome.h new file mode 100644 index 0000000..829dac7 --- /dev/null +++ b/taicpp/src/tui/GrrHome.h @@ -0,0 +1,39 @@ +#pragma once +// GrrHome: the drawer: account, shortcuts, bots, pinned chats, bottom bar +// (tai.py GrrHome). GrrBots: actions shared by the bot list, the drawer and the +// chat menu (tai.py GrrBots). +#include "GrrBar.h" +#include "GrrItems.h" +#include "GrrPage.h" +#include "TuiPanel.h" +namespace detz::tui { + +class GrrShell; + +class GrrHome { +public: + explicit GrrHome(GrrShell& shell); + void destroy(); + void select(const std::string& path); + void latest(); + void paint(); + void toggle_pinned(); + void plus_menu(); + void chat_menu(const TuiValue& chat); + + GrrShell* g; + std::shared_ptr root; + std::shared_ptr page; + std::shared_ptr bar; + std::string path; + bool pinned_open{true}; + std::vector> offs; +}; + +class GrrBots { +public: + static void new_bot(GrrShell& g); + static void new_group(GrrShell& g); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrItem.cpp b/taicpp/src/tui/GrrItem.cpp new file mode 100644 index 0000000..3b1334d --- /dev/null +++ b/taicpp/src/tui/GrrItem.cpp @@ -0,0 +1,73 @@ +#include "GrrItem.h" +#include "GrrPage.h" +#include +namespace detz::tui { + +int GrrCard::inner(int width) { return std::max(1, width - 2 * GrrText::margin(width) - 2); } + +SpanRow GrrCard::top(GrrUi& ui, int width, const std::string& bg) { + int m = GrrText::margin(width); + int cw = std::max(2, width - 2 * m); + return GrrText::line({TuiSpan(UStr(static_cast(m), U' ') + U"▗" + TuiStr::repeat(U'▄', cw - 2) + U"▖", ui.st(bg, "bg"))}, width, ui.st("ink", "bg")); +} + +SpanRow GrrCard::bottom(GrrUi& ui, int width, const std::string& bg) { + int m = GrrText::margin(width); + int cw = std::max(2, width - 2 * m); + return GrrText::line({TuiSpan(UStr(static_cast(m), U' ') + U"▝" + TuiStr::repeat(U'▀', cw - 2) + U"▘", ui.st(bg, "bg"))}, width, ui.st("ink", "bg")); +} + +SpanRow GrrCard::line(GrrUi& ui, int width, const SpanRow& spans, const std::string& bg) { + int m = GrrText::margin(width); + StyleP page = ui.st("ink", "bg"); + StyleP edge = ui.st(bg, "bg"); + SpanRow body = GrrText::line(spans, inner(width), ui.st("ink", bg)); + SpanRow out{TuiSpan(UStr(static_cast(m), U' '), page), TuiSpan(U"▐", edge)}; + out.insert(out.end(), body.begin(), body.end()); + out.push_back(TuiSpan(U"▌", edge)); + out.push_back(TuiSpan(UStr(static_cast(m), U' '), page)); + return out; +} + +SpanRows GrrCard::card_rows(GrrUi& ui, int width, const SpanRows& lines, const std::string& bg) { + SpanRows out{top(ui, width, bg)}; + for (auto& l : lines) out.push_back(line(ui, width, l, bg)); + out.push_back(bottom(ui, width, bg)); + return out; +} + +void GrrItem::touch() { + version++; + if (page) page->item_changed(this); +} + +const SpanRows& GrrItem::rows(GrrUi& ui, int width, bool selected) { + CacheKey key{ui.version, width, selected, version, stop}; + if (cache_key_ && *cache_key_ == key) return cache_rows_; + hits_.clear(); + SpanRows built = build(ui, width, selected); + cache_key_ = key; + cache_rows_ = std::move(built); + cache_hits_ = std::move(hits_); + hits_.clear(); + return cache_rows_; +} + +GrrFn GrrItem::hit_at(GrrUi& ui, int width, int row, int x) { + rows(ui, width, false); + for (auto& h : cache_hits_) { + if (h.row == row && h.x0 <= x && x < h.x1) return h.fn; + } + return nullptr; +} + +std::string GrrItem::ident() { + if (key) return *key; + return std::string(typeid(*this).name()) + "\x1f" + TuiStr::s(ident_text()); +} + +void GrrItem::activate() { + if (action) action(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrItem.h b/taicpp/src/tui/GrrItem.h new file mode 100644 index 0000000..1e957d4 --- /dev/null +++ b/taicpp/src/tui/GrrItem.h @@ -0,0 +1,67 @@ +#pragma once +// GrrCard and GrrItem (tai.py GrrCard, GrrItem): the rounded card every grouped +// row sits in, and the base of 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). +#include "GrrText.h" +#include "GrrUi.h" +#include +#include +#include +#include +namespace detz::tui { + +class GrrPage; +using GrrFn = std::function; + +// Top and bottom edges are half-height rows with quarter-block corners, the sides are half blocks. +class GrrCard { +public: + static int inner(int width); + static SpanRow top(GrrUi& ui, int width, const std::string& bg = "card"); + static SpanRow bottom(GrrUi& ui, int width, const std::string& bg = "card"); + static SpanRow line(GrrUi& ui, int width, const SpanRow& spans, const std::string& bg = "card"); + static SpanRows card_rows(GrrUi& ui, int width, const SpanRows& lines, const std::string& bg = "card"); // grr_card_rows +}; + +class GrrItem { +public: + struct Hit { int row, x0, x1; GrrFn fn; }; + GrrItem() = default; + virtual ~GrrItem() = default; + GrrItem(const GrrItem&) = delete; + GrrItem& operator=(const GrrItem&) = delete; + + bool selectable{false}; + std::optional key; + GrrPage* page{nullptr}; + long version{0}; + int stop{0}; + int stops{1}; + GrrFn action; // python getattr(self, "action") + + void touch(); + const SpanRows& rows(GrrUi& ui, int width, bool selected); + virtual SpanRows build(GrrUi& ui, int width, bool selected) { (void)ui; (void)width; (void)selected; return {}; } + GrrFn hit_at(GrrUi& ui, int width, int row, int x); + // what identifies this item when a page is rebuilt: its key, or its kind and words + std::string ident(); + virtual UStr ident_text() const { return UStr(); } + virtual void activate(); + virtual bool nav(const std::string& direction) { (void)direction; return false; } + virtual void destroy() {} // items that own streams override (GrrProcessItem) + +protected: + void hit(int row, int x0, int x1, GrrFn fn) { hits_.push_back(Hit{row, x0, x1, std::move(fn)}); } +private: + struct CacheKey { int ui_version; int width; bool selected; long version; int stop; bool operator==(const CacheKey& o) const { + return ui_version == o.ui_version && width == o.width && selected == o.selected && version == o.version && stop == o.stop; } }; + std::optional cache_key_; + SpanRows cache_rows_; + std::vector cache_hits_; + std::vector hits_; +}; +using GrrItemP = std::shared_ptr; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrItems.cpp b/taicpp/src/tui/GrrItems.cpp new file mode 100644 index 0000000..e13eec4 --- /dev/null +++ b/taicpp/src/tui/GrrItems.cpp @@ -0,0 +1,758 @@ +#include "GrrItems.h" +#include "GrrAvatar.h" +#include "GrrPage.h" +#include "GrrShell.h" +#include +#include +namespace detz::tui { + +namespace { +UStr spaces(long n) { return UStr(static_cast(std::max(0L, n)), U' '); } +} // namespace + +// ---- simple blocks --------------------------------------------------------- + +SpanRows GrrSpacer::build(GrrUi& ui, int width, bool) { + SpanRows out; + for (int i = 0; i < n; i++) out.push_back(SpanRow{TuiSpan(spaces(width), ui.st())}); + return out; +} + +SpanRows GrrLabel::build(GrrUi& ui, int width, bool) { + int m = GrrText::margin(width) + 1; + return {GrrText::line({TuiSpan(spaces(m) + GrrText::clip(text, width - m * 2), ui.st("muted"))}, width, ui.st())}; +} + +SpanRows GrrHeading::build(GrrUi& ui, int width, bool) { + int m = GrrText::margin(width); + UStr t = GrrText::clip(text, width - 2 * m); + int pad = center ? (width - static_cast(t.size())) / 2 : m; + return {GrrText::line({TuiSpan(spaces(pad) + t, ui.st("ink", "bg", true))}, width, ui.st())}; +} + +SpanRows GrrNote::build(GrrUi& ui, int width, bool) { + int m = GrrText::margin(width) + 1; + SpanRows out; + for (auto& line : GrrText::wrap(text, width - 2 * m)) { + int pad = center ? (width - static_cast(line.size())) / 2 : m; + out.push_back(GrrText::line({TuiSpan(spaces(pad) + line, ui.st(tone))}, width, ui.st())); + } + return out; +} + +// ---- rows ---------------------------------------------------------------------- + +GrrRowItem::GrrRowItem(UStr title_in, GrrRowOpts o) + : title(std::move(title_in)), sub(std::move(o.sub)), icon(std::move(o.icon)), lead(std::move(o.lead)), value(std::move(o.value)), + chevron(std::move(o.chevron)), check(o.check), toggle(o.toggle), on_toggle(std::move(o.on_toggle)), danger(o.danger), link(o.link), wrap(o.wrap), + pos(std::move(o.pos)), tone(std::move(o.tone)) { + action = std::move(o.action); + key = std::move(o.key); + selectable = static_cast(action) || toggle.has_value(); +} + +void GrrRowItem::set_toggle(bool on) { + toggle = on; + touch(); +} + +void GrrRowItem::activate() { + if (toggle.has_value() && on_toggle) { + bool nxt = !*toggle; + toggle = nxt; + touch(); + on_toggle(nxt); + } else if (action) { + action(); + } +} + +SpanRows GrrRowItem::build(GrrUi& ui, int width, bool selected) { + int inner = GrrCard::inner(width); + std::string bg = selected ? "rule-strong" : "card"; + StyleP base = ui.st("ink", bg); + std::string title_fg = danger ? "danger" : (link ? "accent" : "ink"); + SpanRow lead_spans = lead ? lead(ui, bg) : SpanRow{}; + long lead_w = TuiSpans::width(lead_spans); + UStr icon_text = !icon.empty() ? ui.icon(icon) + U" " : UStr(); + SpanRow right; + if (!value.empty()) right.push_back(TuiSpan(GrrText::clip(value, std::max(6, inner / 2)) + U" ", ui.st("muted", bg))); + if (toggle.has_value()) { + bool on = *toggle; + StyleP edge = ui.st(on ? "accent" : "rule-strong", bg); + StyleP pill = mk_style(TuiColor(ui.palette.c(on ? "accent-ink" : "muted")), TuiColor(ui.palette.c(on ? "accent" : "rule-strong"))); + right.push_back(TuiSpan(U"▐", edge)); + right.push_back(TuiSpan(on ? UStr(U" ●") : UStr(U"● "), pill)); + right.push_back(TuiSpan(U"▌", edge)); + right.push_back(TuiSpan(U" ", base)); + } + if (check) right.push_back(TuiSpan(ui.icon("check") + U" ", ui.st("ink", bg, true))); + if (!chevron.empty()) right.push_back(TuiSpan(ui.icon(chevron == "ext" ? "external" : "right") + U" ", ui.st("muted", bg))); + long right_w = TuiSpans::width(right); + long lead_cols = static_cast(icon_text.size()) + lead_w; + long title_w = std::max(4L, inner - 1 - lead_cols - right_w); + std::vector lines; + if (wrap) { + lines = GrrText::wrap(title, title_w); + if (lines.size() > 3) lines.resize(3); + } else { + lines = {GrrText::clip(title, title_w)}; + } + std::vector subs; + if (!sub.empty()) { + subs = GrrText::wrap(sub, inner - 1 - lead_cols - 1); + if (subs.size() > 2) subs.resize(2); + } + SpanRows body; + for (size_t p = 0; p < lines.size(); p++) { + SpanRow spans{TuiSpan(U" ", base)}; + if (p == 0) { + if (!icon_text.empty()) spans.push_back(TuiSpan(icon_text, ui.st("soft", bg))); + spans.insert(spans.end(), lead_spans.begin(), lead_spans.end()); + } else { + spans.push_back(TuiSpan(spaces(lead_cols), base)); + } + spans.push_back(TuiSpan(lines[p], ui.st(title_fg, bg, selected && !danger))); + SpanRow line = GrrText::line(spans, inner - right_w, base); + if (p == 0) line.insert(line.end(), right.begin(), right.end()); + else line.push_back(TuiSpan(spaces(right_w), base)); + body.push_back(std::move(line)); + } + for (auto& text : subs) { + body.push_back(GrrText::line({TuiSpan(U" " + spaces(lead_cols) + text, ui.st(!tone.empty() ? tone : "muted", bg))}, inner, base)); + } + SpanRows out; + if (pos == "first" || pos == "only") out.push_back(GrrCard::top(ui, width)); + for (auto& r : body) out.push_back(GrrCard::line(ui, width, r, bg)); + if (pos == "last" || pos == "only") out.push_back(GrrCard::bottom(ui, width)); + return out; +} + +std::vector> grr_group(std::vector> rows) { + rows.erase(std::remove_if(rows.begin(), rows.end(), [](const std::shared_ptr& r) { return !r; }), rows.end()); + for (size_t p = 0; p < rows.size(); p++) { + rows[p]->pos = rows.size() == 1 ? "only" : (p == 0 ? "first" : (p + 1 == rows.size() ? "last" : "mid")); + } + return rows; +} + +GrrConvItem::GrrConvItem(UStr title_in, GrrConvOpts o) + : avatar(std::move(o.avatar)), title(std::move(title_in)), time_text(std::move(o.time_text)), preview(std::move(o.preview)), dot(o.dot), + tag(std::move(o.tag)), current(o.current), menu(std::move(o.menu)), busy(o.busy) { + selectable = true; + action = std::move(o.action); + key = std::move(o.key); +} + +SpanRows GrrConvItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + std::string bg = selected ? "rule-strong" : (current ? "card" : "bg"); + StyleP base = ui.st("ink", bg); + SpanRows art; + if (avatar) art = avatar(ui, bg); + else art = {SpanRow{TuiSpan(U" ", base)}, SpanRow{TuiSpan(U" ", base)}}; + long art_w = TuiSpans::width(art[0]); + long text_w = width - 2 * m - art_w - 2; + UStr stamp = time_text; + UStr tag_text = !tag.empty() ? U" " + tag + U" " : UStr(); + long name_w = std::max(4L, text_w - static_cast(stamp.size()) - 1 - static_cast(tag_text.size())); + SpanRow top{TuiSpan(GrrText::clip(title, name_w), ui.st("ink", bg, true))}; + if (!tag_text.empty()) top.push_back(TuiSpan(tag_text, ui.st("muted", "rule"))); + top = GrrText::line(top, text_w - static_cast(stamp.size()), base); + top.push_back(TuiSpan(stamp, ui.st("muted", bg))); + UStr prev = GrrText::clip(preview, text_w - ((dot || busy) ? 2 : 0)); + SpanRow sub{TuiSpan(prev, ui.st(busy ? "accent" : "muted", bg))}; + sub = GrrText::line(sub, text_w - 2, base); + sub.push_back(TuiSpan(U" ", base)); + sub.push_back(TuiSpan((dot || busy) ? UStr(U"●") : UStr(U" "), ui.st(dot ? "accent" : "success", bg))); + SpanRows out; + for (size_t p = 0; p < art.size() && p < 2; p++) { + const SpanRow& text = p == 0 ? top : sub; + SpanRow row{TuiSpan(spaces(m), ui.st("ink", "bg"))}; + row.insert(row.end(), art[p].begin(), art[p].end()); + row.push_back(TuiSpan(U" ", base)); + row.insert(row.end(), text.begin(), text.end()); + row.push_back(TuiSpan(spaces(m), ui.st("ink", "bg"))); + out.push_back(GrrText::line(row, width, ui.st("ink", "bg"))); + } + if (menu) hit(0, width - m - 2, width, menu); + return out; +} + +GrrSectionHead::GrrSectionHead(UStr text_in, std::string icon_in, GrrFn act) : text(std::move(text_in)), icon(std::move(icon_in)) { + action = std::move(act); + selectable = static_cast(action); +} + +SpanRows GrrSectionHead::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + std::string bg = selected ? "rule-strong" : "bg"; + UStr right = !icon.empty() ? U" " + ui.icon(icon) + U" " : UStr(); + SpanRow row = GrrText::line({TuiSpan(spaces(m) + text, ui.st("muted", bg, true))}, width - static_cast(right.size()) - m, ui.st("ink", bg)); + row.push_back(TuiSpan(right, ui.st("soft", bg))); + row.push_back(TuiSpan(spaces(m), ui.st("ink", bg))); + if (!right.empty() && action) hit(0, width - m - static_cast(right.size()), width, action); + return {row}; +} + +GrrButtonItem::GrrButtonItem(UStr label_in, GrrFn act, std::string kind_in, bool inline_flag, std::string icon_in, bool disabled_flag) + : label(std::move(label_in)), kind(std::move(kind_in)), inline_(inline_flag), icon(std::move(icon_in)), disabled(disabled_flag) { + action = std::move(act); + selectable = !disabled; +} + +SpanRows GrrButtonItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + UStr text = (!icon.empty() ? ui.icon(icon) + U" " : UStr()) + label; + std::string fg, bg; + if (kind == "primary") { fg = "accent-ink"; bg = "accent"; } + else if (kind == "danger") { fg = "danger"; bg = "card"; } + else if (kind == "ghost") { fg = "muted"; bg = "bg"; } + else { fg = "ink"; bg = "card"; } + if (selected) { + bg = kind == "primary" ? "ink" : "rule-strong"; + fg = kind == "primary" ? "bg" : fg; + } + int cw = inline_ ? std::min(width - 2 * m, static_cast(text.size()) + 8) : width - 2 * m; + int lead = (width - cw) / 2; + StyleP page = ui.st("ink", "bg"); + StyleP edge = ui.st(bg, "bg"); + StyleP body = ui.st(fg, bg, true, disabled); + SpanRows out; + out.push_back(GrrText::line({TuiSpan(spaces(lead) + U"▗" + TuiStr::repeat(U'▄', cw - 2) + U"▖", edge)}, width, page)); + out.push_back(GrrText::line({TuiSpan(spaces(lead), page), TuiSpan(TuiStr::head(TuiStr::center(text, cw), cw), body)}, width, page)); + out.push_back(GrrText::line({TuiSpan(spaces(lead) + U"▝" + TuiStr::repeat(U'▀', cw - 2) + U"▘", edge)}, width, page)); + if (!disabled) for (int p = 0; p < 3; p++) hit(p, lead, lead + cw, action); + return out; +} + +GrrTextCard::GrrTextCard(UStr text_in, GrrFn act, UStr placeholder_in, bool mono_flag, UStr label_in) + : text(std::move(text_in)), placeholder(std::move(placeholder_in)), mono(mono_flag), label(std::move(label_in)) { + action = std::move(act); + selectable = static_cast(action); +} + +SpanRows GrrTextCard::build(GrrUi& ui, int width, bool selected) { + int inner = GrrCard::inner(width); + std::string bg = selected ? "rule-strong" : "card"; + UStr shown = !text.empty() ? text : placeholder; + std::string tone = !text.empty() ? "ink" : "muted"; + auto lines = GrrText::wrap(shown, inner - 2); + if (lines.size() > 24) { + lines.resize(24); + lines.push_back(U"…"); + } + SpanRows out{GrrCard::top(ui, width, bg)}; + for (auto& line : lines) out.push_back(GrrCard::line(ui, width, {TuiSpan(U" " + line, ui.st(tone, bg))}, bg)); + out.push_back(GrrCard::bottom(ui, width, bg)); + return out; +} + +// ---- choices ----------------------------------------------------------------------- + +GrrTabsItem::GrrTabsItem(std::vector t, TuiValue v, std::function pick_fn) + : tabs(std::move(t)), value(std::move(v)), on_pick(std::move(pick_fn)) { + selectable = true; + stops = static_cast(tabs.size()); +} + +void GrrTabsItem::pick(const TuiValue& tab_id) { + value = tab_id; + touch(); + on_pick(tab_id); +} + +int GrrTabsItem::index() const { + for (size_t p = 0; p < tabs.size(); p++) if (tabs[p].id == value) return static_cast(p); + return 0; +} + +bool GrrTabsItem::nav(const std::string& direction) { + if (direction == "left" || direction == "right") { + int p = std::max(0, std::min(static_cast(tabs.size()) - 1, index() + (direction == "right" ? 1 : -1))); + if (!(tabs[static_cast(p)].id == value)) pick(tabs[static_cast(p)].id); + return true; + } + return false; +} + +void GrrTabsItem::menu() { + auto* app = page ? dynamic_cast(page->app) : nullptr; + if (app && app->grr) { + std::vector entries; + for (auto& t : tabs) { + GrrMenuEntry e; + e.label = t.label; + e.checked = t.id == value; + TuiValue id = t.id; + e.action = [this, id] { pick(id); }; + entries.push_back(e); + } + app->grr->menu(entries); + } +} + +void GrrTabsItem::activate() { + if (folded) menu(); +} + +SpanRows GrrTabsItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + long need = 0; + for (auto& t : tabs) need += static_cast(t.label.size()) + 4; + StyleP page_style = ui.st("ink", "bg"); + folded = need > width - 2 * m; + if (folded) { + UStr label; + for (auto& t : tabs) if (t.id == value) { label = t.label; break; } + UStr text = U" " + label + U" " + ui.icon("down") + U" "; + SpanRow row{TuiSpan(spaces(m), page_style), TuiSpan(text, ui.st("ink", selected ? "rule-strong" : "card", true))}; + hit(0, m, m + static_cast(text.size()), [this] { menu(); }); + return {GrrText::line(row, width, page_style), SpanRow{TuiSpan(spaces(width), page_style)}}; + } + SpanRow spans{TuiSpan(spaces(m), page_style)}, under{TuiSpan(spaces(m), page_style)}; + int x = m; + for (auto& tab : tabs) { + bool on = tab.id == value; + UStr text = U" " + tab.label + U" "; + bool focus = selected && on; + spans.push_back(TuiSpan(text + U" ", ui.st(on ? "ink" : "muted", focus ? "rule-strong" : "bg", on))); + under.push_back(TuiSpan(on ? TuiStr::repeat(U'▀', static_cast(text.size())) : spaces(static_cast(text.size())), ui.st("accent", "bg"))); + under.push_back(TuiSpan(U" ", page_style)); + TuiValue id = tab.id; + hit(0, x, x + static_cast(text.size()), [this, id] { pick(id); }); + x += static_cast(text.size()) + 2; + } + return {GrrText::line(spans, width, page_style), GrrText::line(under, width, page_style)}; +} + +GrrSegmentedItem::GrrSegmentedItem(std::vector i, TuiValue v, std::function pick_fn) + : items(std::move(i)), value(std::move(v)), on_pick(std::move(pick_fn)) { + selectable = true; +} + +void GrrSegmentedItem::pick(const TuiValue& item_id) { + value = item_id; + touch(); + on_pick(item_id); +} + +bool GrrSegmentedItem::nav(const std::string& direction) { + if (direction == "left" || direction == "right") { + int found = -1; + for (size_t p = 0; p < items.size(); p++) if (items[p].id == value) { found = static_cast(p); break; } + int p = found >= 0 ? std::max(0, std::min(static_cast(items.size()) - 1, found + (direction == "right" ? 1 : -1))) : 0; + pick(items[static_cast(p)].id); + return true; + } + return false; +} + +SpanRows GrrSegmentedItem::build(GrrUi& ui, int width, bool) { + int m = GrrText::margin(width); + StyleP page_style = ui.st("ink", "bg"); + int each = std::max(6, (width - 2 * m) / std::max(1, static_cast(items.size()))); + SpanRow spans{TuiSpan(spaces(m), page_style)}; + int x = m; + for (auto& item : items) { + bool on = item.id == value; + spans.push_back(TuiSpan(TuiStr::center(GrrText::clip(item.label, each - 2), each), ui.st(on ? "accent-ink" : "ink", on ? "accent" : "card", on))); + TuiValue id = item.id; + hit(1, x, x + each, [this, id] { pick(id); }); + x += each; + } + SpanRow edge_up{TuiSpan(spaces(m), page_style)}, edge_dn{TuiSpan(spaces(m), page_style)}; + for (auto& i : items) { + edge_up.push_back(TuiSpan(TuiStr::repeat(U'▄', each), ui.st(i.id == value ? "accent" : "card", "bg"))); + edge_dn.push_back(TuiSpan(TuiStr::repeat(U'▀', each), ui.st(i.id == value ? "accent" : "card", "bg"))); + } + return {GrrText::line(edge_up, width, page_style), GrrText::line(spans, width, page_style), GrrText::line(edge_dn, width, page_style)}; +} + +GrrChipsItem::GrrChipsItem(std::vector c) : chips(std::move(c)) { + selectable = true; + stops = static_cast(chips.size()); +} + +bool GrrChipsItem::nav(const std::string& direction) { + if (direction == "left" || direction == "right") { + int nxt = std::max(0, std::min(static_cast(chips.size()) - 1, stop + (direction == "right" ? 1 : -1))); + if (nxt != stop) { + stop = nxt; + touch(); + } + return true; + } + return false; +} + +void GrrChipsItem::activate() { + GrrChip& chip = chips[static_cast(std::min(stop, static_cast(chips.size()) - 1))]; + chip.action(); +} + +SpanRows GrrChipsItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + StyleP page_style = ui.st("ink", "bg"); + SpanRow spans{TuiSpan(spaces(m), page_style)}; + int x = m; + for (size_t p = 0; p < chips.size(); p++) { + auto& chip = chips[p]; + UStr label = (!chip.icon.empty() ? ui.icon(chip.icon) + U" " : UStr()) + chip.label + (chip.caret ? U" " + ui.icon("down") : UStr()); + UStr text = U" " + label + U" "; + bool focus = selected && static_cast(p) == stop; + spans.push_back(TuiSpan(text, ui.st("ink", focus ? "rule-strong" : "card", focus))); + spans.push_back(TuiSpan(U" ", page_style)); + hit(0, x, x + static_cast(text.size()), chip.action); + x += static_cast(text.size()) + 1; + } + return {GrrText::line(spans, width, page_style)}; +} + +SpanRows GrrDividerItem::build(GrrUi& ui, int width, bool) { + UStr label = U" " + text + U" "; + int side = std::max(0, (width - static_cast(label.size())) / 2); + std::string tone = accent ? "accent" : "muted"; + SpanRow spans{TuiSpan(accent ? TuiStr::repeat(U'─', side) : spaces(side), ui.st("accent", "bg")), TuiSpan(label, ui.st(tone, "bg", accent))}; + spans.push_back(TuiSpan(accent ? TuiStr::repeat(U'─', width - side - static_cast(label.size())) : UStr(), ui.st("accent", "bg"))); + return {GrrText::line(spans, width, ui.st("ink", "bg"))}; +} + +SpanRows GrrEmptyItem::build(GrrUi& ui, int width, bool) { + StyleP page_style = ui.st("ink", "bg"); + SpanRows out; + for (int i = 0; i < 2; i++) out.push_back(SpanRow{TuiSpan(spaces(width), page_style)}); + out.push_back(GrrText::line({TuiSpan(TuiStr::center(U" " + ui.icon(icon) + U" ", width), ui.st("muted", "bg", true))}, width, page_style)); + out.push_back(SpanRow{TuiSpan(spaces(width), page_style)}); + for (auto& line : GrrText::wrap(text, width - 6)) out.push_back(GrrText::line({TuiSpan(TuiStr::center(line, width), ui.st("ink", "bg", true))}, width, page_style)); + if (!sub.empty()) { + for (auto& line : GrrText::wrap(sub, width - 8)) out.push_back(GrrText::line({TuiSpan(TuiStr::center(line, width), ui.st("muted", "bg"))}, width, page_style)); + } + return out; +} + +SpanRows GrrKvItem::build(GrrUi& ui, int width, bool) { + int inner = GrrCard::inner(width); + SpanRows out{GrrCard::top(ui, width)}; + for (auto& kv : pairs) { + const UStr& key_text = kv.first; + const UStr& value = kv.second; + UStr left = GrrText::clip(key_text, std::max(4L, inner - static_cast(value.size()) - 4)); + auto tn = tone.find(key_text); + SpanRow row{TuiSpan(U" " + left, ui.st("ink", "card")), TuiSpan(spaces(std::max(1L, inner - 2 - static_cast(left.size()) - static_cast(value.size()))), ui.st("ink", "card")), + TuiSpan(value + U" ", ui.st(tn != tone.end() ? tn->second : "muted", "card"))}; + out.push_back(GrrCard::line(ui, width, row)); + } + out.push_back(GrrCard::bottom(ui, width)); + return out; +} + +GrrProgressItem::GrrProgressItem(UStr label_in, double pct_in, UStr right_in, UStr foot_in, std::string level_in) + : label(std::move(label_in)), pct(std::max(0.0, pct_in)), right(std::move(right_in)), foot(std::move(foot_in)), level(std::move(level_in)) {} + +SpanRows GrrProgressItem::build(GrrUi& ui, int width, bool) { + int inner = GrrCard::inner(width); + StyleP base = ui.st("ink", "card"); + std::string tone = pct >= 100 ? "danger" : (pct >= 80 ? "accent" : "info"); + int bar_w = std::max(4, inner - 4); + int filled = static_cast(std::nearbyint(std::min(100.0, pct) / 100.0 * bar_w)); + SpanRow head = GrrText::line({TuiSpan(U" " + label, ui.st("ink", "card", true))}, inner - static_cast(right.size()) - 1, base); + head.push_back(TuiSpan(right + U" ", ui.st("muted", "card"))); + SpanRow bar{TuiSpan(U" ", base), TuiSpan(TuiStr::repeat(U'█', filled), ui.st(tone, "card")), TuiSpan(TuiStr::repeat(U'▒', bar_w - filled), ui.st("rule-strong", "card")), TuiSpan(U" ", base)}; + SpanRows rows{head, bar}; + if (!foot.empty()) rows.push_back(SpanRow{TuiSpan(U" " + foot, ui.st("muted", "card"))}); + SpanRows out{GrrCard::top(ui, width)}; + for (auto& r : rows) out.push_back(GrrCard::line(ui, width, r)); + out.push_back(GrrCard::bottom(ui, width)); + return out; +} + +SpanRows GrrBarsItem::build(GrrUi& ui, int width, bool) { + int inner = GrrCard::inner(width); + long long peak = 1; + for (auto& d : days) peak = std::max(peak, d.get("tokens", TuiValue(0)).as_int()); + static const UStr levels = U" ▁▂▃▄▅▆▇█"; + int cell = std::max(3, std::min(8, (inner - 2) / std::max(1, static_cast(days.size())))); + SpanRow top{TuiSpan(U" ", ui.st("ink", "card"))}; + SpanRow low{TuiSpan(U" ", ui.st("ink", "card"))}; + for (auto& d : days) { + int level = std::min(8, std::max(1, static_cast(std::nearbyint(static_cast(d.get("tokens", TuiValue(0)).as_int()) / static_cast(peak) * 8)))); + top.push_back(TuiSpan(TuiStr::repeat(levels[static_cast(level)], cell - 1) + U" ", ui.st("accent", "card"))); + UStr day = TuiStr::u(d.get("day", TuiValue("")).str()); + low.push_back(TuiSpan(TuiStr::ljust(TuiStr::from(day, 5), cell), ui.st("muted", "card"))); + } + return {GrrCard::top(ui, width), GrrCard::line(ui, width, top), GrrCard::line(ui, width, low), GrrCard::bottom(ui, width)}; +} + +// ---- tiles ----------------------------------------------------------------------------- + +GrrTileGridItem::GrrTileGridItem(std::vector t, int cols_in, int tile_h_in, int min_w_in, int max_cols_in, std::function pick_fn) + : tiles(std::move(t)), cols_wanted(cols_in), tile_h(tile_h_in), min_w(min_w_in), max_cols(max_cols_in), on_pick(std::move(pick_fn)) { + selectable = true; + stops = std::max(1, static_cast(tiles.size())); +} + +bool GrrTileGridItem::nav(const std::string& direction) { + int step; + if (direction == "left") step = -1; + else if (direction == "right") step = 1; + else if (direction == "up") step = -cols; + else if (direction == "down") step = cols; + else return false; + int nxt = stop + step; + if (0 <= nxt && nxt < static_cast(tiles.size())) { + stop = nxt; + touch(); + return true; + } + return false; +} + +void GrrTileGridItem::activate() { + if (!tiles.empty()) { + GrrTile& tile = tiles[static_cast(std::min(stop, static_cast(tiles.size()) - 1))]; + if (tile.action) tile.action(); + } +} + +SpanRows GrrTileGridItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + int gap = 1; + int c = cols_wanted ? cols_wanted : std::max(1, std::min(max_cols, (width - 2 * m + gap) / (min_w + gap))); + cols = c; + int tw = (width - 2 * m - gap * (c - 1)) / c; + StyleP page_style = ui.st("ink", "bg"); + SpanRows out; + for (size_t start = 0; start < tiles.size(); start += static_cast(c)) { + size_t stop_at = std::min(tiles.size(), start + static_cast(c)); + std::vector lines; + for (int i = 0; i < tile_h; i++) lines.push_back(SpanRow{TuiSpan(spaces(m), page_style)}); + for (size_t off = 0; start + off < stop_at; off++) { + GrrTile& tile = tiles[start + off]; + bool focus = selected && static_cast(start + off) == stop; + std::string bg = focus ? "rule-strong" : "card"; + StyleP edge = ui.st(tile.on ? "accent" : bg, "bg"); + StyleP body = ui.st("ink", bg); + SpanRows shown; + if (tile.art) shown = tile.art(ui, bg); + else shown = {SpanRow{TuiSpan(TuiStr::center(ui.icon(tile.icon), tw), ui.st("soft", bg, true))}}; + SpanRows art_rows; + for (auto& part : shown) { + int pad = std::max(0, (tw - static_cast(TuiSpans::width(part))) / 2); + SpanRow r{TuiSpan(spaces(pad), body)}; + r.insert(r.end(), part.begin(), part.end()); + art_rows.push_back(GrrText::line(r, tw, body)); + } + SpanRows rows{SpanRow{TuiSpan(U"▗" + TuiStr::repeat(U'▄', tw - 2) + U"▖", edge)}}; + rows.insert(rows.end(), art_rows.begin(), art_rows.end()); + if (!tile.label.empty()) rows.push_back(GrrText::line({TuiSpan(TuiStr::center(GrrText::clip(tile.label, tw - 2), tw), ui.st("ink", bg, tile.on))}, tw, body)); + if (!tile.sub.empty() && tile_h >= 6) rows.push_back(GrrText::line({TuiSpan(TuiStr::center(GrrText::clip(tile.sub, tw - 2), tw), ui.st("muted", bg))}, tw, body)); + rows.push_back(SpanRow{TuiSpan(U"▝" + TuiStr::repeat(U'▀', tw - 2) + U"▘", edge)}); + for (int p = 0; p < tile_h; p++) { + SpanRow cell = p < static_cast(rows.size()) ? rows[static_cast(p)] : SpanRow{TuiSpan(spaces(tw), page_style)}; + lines[static_cast(p)].insert(lines[static_cast(p)].end(), cell.begin(), cell.end()); + lines[static_cast(p)].push_back(TuiSpan(spaces(gap), page_style)); + GrrFn act = tile.action ? tile.action : GrrFn([] {}); + hit(static_cast(out.size()) + p, m + static_cast(off) * (tw + gap), m + static_cast(off) * (tw + gap) + tw, act); + } + } + for (auto& line : lines) out.push_back(GrrText::line(line, width, page_style)); + } + return out; +} + +// ---- Part D items --------------------------------------------------------------------------- + +GrrStripItem::GrrStripItem(std::vector b, std::function pick_fn) : bots(std::move(b)), on_pick(std::move(pick_fn)) { + selectable = true; + stops = std::max(1, static_cast(bots.size())); +} + +bool GrrStripItem::nav(const std::string& direction) { + int step; + if (direction == "left") step = -1; + else if (direction == "right") step = 1; + else if (direction == "up") step = -cols; + else if (direction == "down") step = cols; + else return false; + int nxt = stop + step; + if (0 <= nxt && nxt < static_cast(bots.size())) { + stop = nxt; + touch(); + return true; + } + return false; +} + +void GrrStripItem::activate() { + if (!bots.empty()) on_pick(bots[static_cast(std::min(stop, static_cast(bots.size()) - 1))]); +} + +SpanRows GrrStripItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width); + int cell = 11; + cols = std::max(1, (width - 2 * m) / cell); + StyleP page_style = ui.st("ink", "bg"); + SpanRows out; + for (size_t start = 0; start < bots.size(); start += static_cast(cols)) { + std::vector lines; + for (int i = 0; i < 3; i++) lines.push_back(SpanRow{TuiSpan(spaces(m), page_style)}); + size_t stop_at = std::min(bots.size(), start + static_cast(cols)); + for (size_t off = 0; start + off < stop_at; off++) { + const TuiValue& bot = bots[start + off]; + bool focus = selected && static_cast(start + off) == stop; + std::string bg = focus ? "rule-strong" : "bg"; + SpanRows art = GrrAvatar::rows(ui, bot, bg); + int pad = (cell - 7) / 2; + for (int p = 0; p < 2; p++) { + lines[static_cast(p)].push_back(TuiSpan(spaces(pad), ui.st("ink", bg))); + lines[static_cast(p)].insert(lines[static_cast(p)].end(), art[static_cast(p)].begin(), art[static_cast(p)].end()); + lines[static_cast(p)].push_back(TuiSpan(spaces(cell - 7 - pad), ui.st("ink", bg))); + } + lines[2].push_back(TuiSpan(TuiStr::center(GrrText::clip(TuiStr::u(bot.get("name").str()), cell - 1), cell), ui.st("ink", bg, focus))); + for (int p = 0; p < 3; p++) { + TuiValue b = bot; + hit(static_cast(out.size()) + p, m + static_cast(off) * cell, m + (static_cast(off) + 1) * cell, [this, b] { on_pick(b); }); + } + } + for (auto& line : lines) out.push_back(GrrText::line(line, width, page_style)); + out.push_back(SpanRow{TuiSpan(spaces(width), page_style)}); + } + return out; +} + +SpanRows GrrHeroItem::build(GrrUi& ui, int width, bool) { + SpanRows art = GrrAvatar::rows(ui, bot, "bg", false, true); + SpanRows out{SpanRow{TuiSpan(spaces(width), ui.st())}}; + for (auto& row : art) { + SpanRow r{TuiSpan(spaces((width - TuiSpans::width(row)) / 2), ui.st())}; + r.insert(r.end(), row.begin(), row.end()); + out.push_back(GrrText::line(r, width, ui.st())); + } + return out; +} + +GrrNameCard::GrrNameCard(UStr name_in, UStr desc_in, UStr placeholder_in, GrrFn name_fn, GrrFn desc_fn) + : name(std::move(name_in)), desc(std::move(desc_in)), placeholder(std::move(placeholder_in)), on_name(std::move(name_fn)), on_desc(std::move(desc_fn)) { + selectable = true; + stops = 2; +} + +bool GrrNameCard::nav(const std::string& direction) { + if (direction == "up" || direction == "down") { + int nxt = stop + (direction == "down" ? 1 : -1); + if (0 <= nxt && nxt <= 1) { + stop = nxt; + touch(); + return true; + } + } + return false; +} + +void GrrNameCard::activate() { (stop == 0 ? on_name : on_desc)(); } + +SpanRows GrrNameCard::build(GrrUi& ui, int width, bool selected) { + int inner = GrrCard::inner(width); + std::string name_bg = selected && stop == 0 ? "rule-strong" : "card"; + std::string desc_bg = selected && stop == 1 ? "rule-strong" : "card"; + SpanRows rows{GrrCard::top(ui, width)}; + rows.push_back(GrrCard::line(ui, width, {TuiSpan(TuiStr::center(GrrText::clip(name, inner - 2), inner), ui.st("ink", name_bg, true))}, name_bg)); + hit(1, 0, width, on_name); + UStr text = !desc.empty() ? desc : placeholder; + auto lines = GrrText::wrap(text, inner - 4); + if (lines.size() > 4) lines.resize(4); + for (auto& line : lines) { + rows.push_back(GrrCard::line(ui, width, {TuiSpan(TuiStr::center(line, inner), ui.st(!desc.empty() ? "muted" : "faint", desc_bg))}, desc_bg)); + hit(static_cast(rows.size()) - 1, 0, width, on_desc); + } + rows.push_back(GrrCard::bottom(ui, width)); + return rows; +} + +GrrSwatchItem::GrrSwatchItem(std::vector c, TuiValue v, std::function pick_fn) + : colors(std::move(c)), value(std::move(v)), on_pick(std::move(pick_fn)) { + selectable = true; + stops = static_cast(colors.size()); + for (size_t p = 0; p < colors.size(); p++) if (value.is_str() && colors[p] == value.as_str()) { stop = static_cast(p); break; } +} + +bool GrrSwatchItem::nav(const std::string& direction) { + if (direction == "left" || direction == "right") { + int nxt = std::max(0, std::min(static_cast(colors.size()) - 1, stop + (direction == "right" ? 1 : -1))); + if (nxt != stop) { + stop = nxt; + touch(); + } + return true; + } + return false; +} + +void GrrSwatchItem::activate() { on_pick(colors[static_cast(stop)]); } + +SpanRows GrrSwatchItem::build(GrrUi& ui, int width, bool selected) { + int m = GrrText::margin(width) + 1; + StyleP page_style = ui.st("ink", "bg"); + SpanRow spans{TuiSpan(spaces(m), page_style)}; + int x = m; + for (size_t p = 0; p < colors.size(); p++) { + const std::string& color = colors[p]; + bool on = value.is_str() && color == value.as_str(); + bool focus = selected && static_cast(p) == stop; + UStr mark = on ? U"◉" : U"●"; + spans.push_back(TuiSpan(U" " + mark + U" ", mk_style(TuiColor(color), TuiColor(ui.palette.c(focus ? "rule-strong" : "bg"))))); + std::string c = color; + hit(0, x, x + 3, [this, c] { on_pick(c); }); + x += 3; + } + return {GrrText::line(spans, width, page_style)}; +} + +SpanRows GrrScreenPreview::build(GrrUi& ui, int width, bool) { + int inner = GrrCard::inner(width); + SpanRows rows{GrrCard::top(ui, width)}; + for (int p = 0; p < 3; p++) { + UStr text = p == 1 ? ui.icon("monitor") : UStr(); + rows.push_back(GrrCard::line(ui, width, {TuiSpan(TuiStr::center(text, inner), ui.st("muted", "card"))})); + } + rows.push_back(GrrCard::bottom(ui, width)); + rows.push_back(GrrText::line({TuiSpan(TuiStr::center(GrrText::clip(caption, inner), width), ui.st("muted", "bg"))}, width, ui.st("ink", "bg"))); + return rows; +} + +GrrRoutineItem::GrrRoutineItem(TuiValue routine, GrrFn on_open, std::function menu_fn) : r(std::move(routine)), on_menu(std::move(menu_fn)) { + selectable = true; + action = std::move(on_open); + key = r.get("id").str(); +} + +SpanRows GrrRoutineItem::build(GrrUi& ui, int width, bool selected) { + int inner = GrrCard::inner(width); + std::string bg = selected ? "rule-strong" : "card"; + StyleP base = ui.st("ink", bg); + TuiValue bot = r.get("bot"); + SpanRow chip = bot.truthy() ? GrrAvatar::chip(ui, bot, bg) : SpanRow{TuiSpan(U" " + ui.icon("automation") + U" ", ui.st("soft", bg))}; + UStr name = TuiStr::u(r.get("name").str()); + SpanRow head{TuiSpan(U" ", base)}; + head.insert(head.end(), chip.begin(), chip.end()); + head.push_back(TuiSpan(U" " + GrrText::clip(name, inner - 14), ui.st(r.get("active").truthy() ? "ink" : "muted", bg, true))); + head = GrrText::line(head, inner - 4, base); + head.push_back(TuiSpan(U" " + ui.icon("moreV") + U" ", ui.st("soft", bg))); + SpanRows lines{head}; + auto instr = GrrText::wrap(TuiStr::u(r.get("instruction", TuiValue("")).str()), inner - 3); + if (instr.size() > 3) instr.resize(3); + for (auto& line : instr) lines.push_back(SpanRow{TuiSpan(U" " + line, ui.st("muted", bg))}); + UStr foot = (bot.truthy() ? TuiStr::u(bot.get("name").str()) + U" · " : UStr()) + ui.fmt.schedule(r.get("schedule")) + + (r.get("active").truthy() ? UStr() : U" · " + ui.t("automations.paused")); + lines.push_back(SpanRow{TuiSpan(U" " + GrrText::clip(foot, inner - 3), ui.st("faint", bg))}); + SpanRows out{GrrCard::top(ui, width, bg)}; + for (auto& l : lines) out.push_back(GrrCard::line(ui, width, l, bg)); + out.push_back(GrrCard::bottom(ui, width, bg)); + TuiValue rr = r; + auto menu_fn = on_menu; + hit(1, width - GrrText::margin(width) - 6, width, [menu_fn, rr] { menu_fn(rr); }); + return out; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrItems.h b/taicpp/src/tui/GrrItems.h new file mode 100644 index 0000000..e65d16e --- /dev/null +++ b/taicpp/src/tui/GrrItems.h @@ -0,0 +1,340 @@ +#pragma once +// The blocks of a grr page (tai.py Part B items): GrrSpacer, GrrLabel, +// GrrHeading, GrrNote, GrrRowItem, GrrConvItem, GrrSectionHead, GrrButtonItem, +// GrrTextCard, GrrTabsItem, GrrSegmentedItem, GrrChipsItem, GrrDividerItem, +// GrrEmptyItem, GrrKvItem, GrrProgressItem, GrrBarsItem, GrrTileGridItem, plus +// the Part D items GrrStripItem, GrrHeroItem, GrrNameCard, GrrSwatchItem, +// GrrScreenPreview, GrrRoutineItem. The python dict arguments (menu entries, +// chips, tabs, tiles) are the small structs below. +#include "GrrAvatar.h" +#include "GrrItem.h" +#include +namespace detz::tui { + +struct GrrMenuEntry { + bool separator{false}; + UStr label; + std::string icon; + bool danger{false}; + bool checked{false}; + GrrFn action; + static GrrMenuEntry sep() { GrrMenuEntry e; e.separator = true; return e; } +}; +struct GrrChip { + UStr label; + std::string icon; + bool caret{true}; + GrrFn action; +}; +struct GrrTab { + TuiValue id; + UStr label; +}; +using GrrLead = std::function; +struct GrrTile { + UStr label, sub; + std::string icon{"file"}; + bool on{false}; + GrrArt art; + GrrFn action; +}; + +class GrrSpacer : public GrrItem { +public: + explicit GrrSpacer(int n = 1) : n(n) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + int n; +}; + +// A small muted caption above a group, like Character or Planning. +class GrrLabel : public GrrItem { +public: + explicit GrrLabel(UStr text) : text(std::move(text)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return text; } + UStr text; +}; + +class GrrHeading : public GrrItem { +public: + GrrHeading(UStr text, bool center = false) : text(std::move(text)), center(center) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return text; } + UStr text; + bool center; +}; + +class GrrNote : public GrrItem { +public: + GrrNote(UStr text, bool center = false, std::string tone = "faint") : text(std::move(text)), center(center), tone(std::move(tone)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return text; } + UStr text; + bool center; + std::string tone; +}; + +struct GrrRowOpts { + UStr sub; + std::string icon; + GrrLead lead; + UStr value; + std::string chevron; // "" none, "1" a chevron, "ext" the external-link mark + bool check{false}; + std::optional toggle; + std::function on_toggle; + GrrFn action; + bool danger{false}; + bool link{false}; + bool wrap{false}; + std::string pos{"only"}; + std::optional key; + std::string tone; +}; + +// 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. +class GrrRowItem : public GrrItem { +public: + explicit GrrRowItem(UStr title, GrrRowOpts opts = {}); + void set_toggle(bool on); + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return title; } + UStr title, sub; + std::string icon; + GrrLead lead; + UStr value; + std::string chevron; + bool check; + std::optional toggle; + std::function on_toggle; + bool danger, link, wrap; + std::string pos; + std::string tone; +}; +// Mark the rows of one card as first, middle and last, return them (grr_group; nulls are dropped). +std::vector> grr_group(std::vector> rows); +// items += rows (python list concatenation of items and a group of rows) +template void grr_append(std::vector& out, const std::vector>& more) { for (auto& m : more) out.push_back(m); } +inline void grr_append(std::vector& out, const std::vector& more) { out.insert(out.end(), more.begin(), more.end()); } + +// A conversation row: avatar, name, time, preview, unread dot. +struct GrrConvOpts { + GrrArt avatar; + UStr time_text, preview; + bool dot{false}; + UStr tag; + bool current{false}; + GrrFn action; + GrrFn menu; + std::optional key; + bool busy{false}; +}; +class GrrConvItem : public GrrItem { +public: + GrrConvItem(UStr title, GrrConvOpts opts = {}); + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return title; } + GrrArt avatar; + UStr title, time_text, preview; + bool dot; + UStr tag; + bool current; + GrrFn menu; + bool busy; +}; + +class GrrSectionHead : public GrrItem { +public: + GrrSectionHead(UStr text, std::string icon = "", GrrFn action = nullptr); + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return text; } + UStr text; + std::string icon; +}; + +// A pill button: three rows (cap, label, cap) so the ends look round. +class GrrButtonItem : public GrrItem { +public: + GrrButtonItem(UStr label, GrrFn action, std::string kind = "primary", bool inline_ = false, std::string icon = "", bool disabled = false); + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return label; } + UStr label; + std::string kind; + bool inline_; + std::string icon; + bool disabled; +}; + +// Text in a card, wrapped. Selectable when it has an action (edit). +class GrrTextCard : public GrrItem { +public: + GrrTextCard(UStr text, GrrFn action = nullptr, UStr placeholder = UStr(), bool mono = false, UStr label = UStr()); + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return !text.empty() ? text : label; } + UStr text, placeholder; + bool mono; + UStr label; +}; + +// 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. +class GrrTabsItem : public GrrItem { +public: + GrrTabsItem(std::vector tabs, TuiValue value, std::function on_pick); + void pick(const TuiValue& tab_id); + int index() const; + bool nav(const std::string& direction) override; + void menu(); + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector tabs; + TuiValue value; + std::function on_pick; + bool folded{false}; +}; + +// A pill group of exclusive choices (Every, Daily, Weekly). +class GrrSegmentedItem : public GrrItem { +public: + GrrSegmentedItem(std::vector items, TuiValue value, std::function on_pick); + void pick(const TuiValue& item_id); + bool nav(const std::string& direction) override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector items; + TuiValue value; + std::function on_pick; +}; + +// A row of chips; each opens a menu or runs an action. Used for mode, tier, sort and filter dropdowns. +class GrrChipsItem : public GrrItem { +public: + explicit GrrChipsItem(std::vector chips); + bool nav(const std::string& direction) override; + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector chips; +}; + +class GrrDividerItem : public GrrItem { +public: + GrrDividerItem(UStr text, bool accent = false) : text(std::move(text)), accent(accent) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return text; } + UStr text; + bool accent; +}; + +class GrrEmptyItem : public GrrItem { +public: + GrrEmptyItem(std::string icon, UStr text, UStr sub = UStr()) : icon(std::move(icon)), text(std::move(text)), sub(std::move(sub)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return text; } + std::string icon; + UStr text, sub; +}; + +// Key and value rows in one card (usage details, server health). +class GrrKvItem : public GrrItem { +public: + GrrKvItem(std::vector> pairs, std::map tone = {}) : pairs(std::move(pairs)), tone(std::move(tone)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector> pairs; + std::map tone; +}; + +class GrrProgressItem : public GrrItem { +public: + GrrProgressItem(UStr label, double pct, UStr right = UStr(), UStr foot = UStr(), std::string level = ""); + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return label; } + UStr label; + double pct; + UStr right, foot; + std::string level; +}; + +// A small column chart of the last days of usage. +class GrrBarsItem : public GrrItem { +public: + explicit GrrBarsItem(std::vector days) : days(std::move(days)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector days; +}; + +// Tiles in columns (library, themes, shapes): a box with an icon, a name and a sub line. Arrows move between tiles. +class GrrTileGridItem : public GrrItem { +public: + GrrTileGridItem(std::vector tiles, int cols = 0, int tile_h = 4, int min_w = 22, int max_cols = 4, std::function on_pick = nullptr); + bool nav(const std::string& direction) override; + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector tiles; + int cols_wanted, tile_h, min_w, max_cols; + std::function on_pick; + int cols{1}; +}; + +// The bots as big avatars in a row, names below. Left and Right walk along it, Enter opens the chat. +class GrrStripItem : public GrrItem { +public: + GrrStripItem(std::vector bots, std::function on_pick); + bool nav(const std::string& direction) override; + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector bots; + std::function on_pick; + int cols{1}; +}; + +// A big avatar, centered. +class GrrHeroItem : public GrrItem { +public: + explicit GrrHeroItem(TuiValue bot) : bot(std::move(bot)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + TuiValue bot; +}; + +// The bot's name and description in one card; each opens its editor. +class GrrNameCard : public GrrItem { +public: + GrrNameCard(UStr name, UStr desc, UStr placeholder, GrrFn on_name, GrrFn on_desc); + bool nav(const std::string& direction) override; + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr ident_text() const override { return name; } + UStr name, desc, placeholder; + GrrFn on_name, on_desc; +}; + +class GrrSwatchItem : public GrrItem { +public: + GrrSwatchItem(std::vector colors, TuiValue value, std::function on_pick); + bool nav(const std::string& direction) override; + void activate() override; + SpanRows build(GrrUi& ui, int width, bool selected) override; + std::vector colors; + TuiValue value; + std::function on_pick; +}; + +// The framed placeholder of the bot's screen on the Computer tab. +class GrrScreenPreview : public GrrItem { +public: + explicit GrrScreenPreview(UStr caption) : caption(std::move(caption)) {} + SpanRows build(GrrUi& ui, int width, bool selected) override; + UStr caption; +}; + +// A routine as a card: bot, name, prompt, schedule line. +class GrrRoutineItem : public GrrItem { +public: + GrrRoutineItem(TuiValue routine, GrrFn on_open, std::function on_menu); + SpanRows build(GrrUi& ui, int width, bool selected) override; + TuiValue r; + std::function on_menu; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrMenu.cpp b/taicpp/src/tui/GrrMenu.cpp new file mode 100644 index 0000000..64d0e9f --- /dev/null +++ b/taicpp/src/tui/GrrMenu.cpp @@ -0,0 +1,185 @@ +#include "GrrMenu.h" +#include "GrrAvatar.h" +#include "GrrShell.h" +#include +namespace detz::tui { + +TuiPanelOpts GrrMenu::opts_for(GrrShell& shell, const std::vector& items, std::optional> anchor, bool align_left, bool icons) { + size_t longest = 10; + for (auto& e : items) if (!e.separator) longest = std::max(longest, e.label.size()); + int width = std::min(shell.app->cols - 2, static_cast(longest) + 6 + (icons ? 3 : 0)); + int height = std::min(GRR_MENU_MAX_H, static_cast(items.size())); + int cols = shell.app->cols, rows = shell.app->rows; + int x, y; + if (!anchor) { + x = std::max(0, (cols - width) / 2); + y = std::max(0, (rows - height) / 3); + } else { + int ax = anchor->first, ay = anchor->second; + x = align_left ? ax : ax - width + 1; + x = std::max(1, std::min(x, cols - width - 1)); + y = ay + 1; + if (y + height > rows - 1) y = std::max(0, ay - height); + } + TuiPanelOpts o; + o.layer = "dialog"; + o.modal = true; + o.focusable = true; + o.x = TuiSpec(x); + o.y = TuiSpec(y); + o.w = TuiSpec(width); + o.h = TuiSpec(height); + o.style = shell.ui->st("ink", "card"); + return o; +} + +GrrMenu::GrrMenu(GrrShell& sh, std::vector items, std::optional> anchor, bool align_left) + : TuiPanel(opts_for(sh, items, anchor, align_left, std::any_of(items.begin(), items.end(), [](const GrrMenuEntry& e) { return !e.separator && !e.icon.empty(); }))), + shell(&sh), ui(sh.ui), entries(std::move(items)), + icons(std::any_of(entries.begin(), entries.end(), [](const GrrMenuEntry& e) { return !e.separator && !e.icon.empty(); })) { + for (size_t i = 0; i < entries.size(); i++) if (!entries[i].separator) { sel = static_cast(i); break; } +} + +SpanRows GrrMenu::content_rows() { + int width = content_w(), height = content_h(); + if (sel < top) top = sel; + else if (sel >= top + height) top = sel - height + 1; + SpanRows rows; + for (int pos = 0; pos < height; pos++) { + int index = top + pos; + if (index >= static_cast(entries.size())) { + rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), ui->st("ink", "card"))}); + } else if (entries[static_cast(index)].separator) { + rows.push_back(SpanRow{TuiSpan(TuiStr::repeat(U'─', width), ui->st("rule-strong", "card"))}); + } else { + const GrrMenuEntry& entry = entries[static_cast(index)]; + bool on = index == sel; + std::string bg = on ? "rule-strong" : "card"; + std::string tone = entry.danger ? "danger" : "ink"; + UStr lead = !entry.icon.empty() ? ui->icon(entry.icon) + U" " : (icons ? UStr(U" ") : UStr()); + UStr mark = entry.checked ? ui->icon("check") : UStr(U" "); + UStr text = TuiStr::ljust(TuiStr::head(TuiStr::ljust(U" " + lead + entry.label, width - 2), width - 2), width - 2) + mark + U" "; + rows.push_back(SpanRow{TuiSpan(text, ui->st(tone, bg, on))}); + } + } + return rows; +} + +void GrrMenu::run(int index) { + GrrMenuEntry entry = entries[static_cast(index)]; + if (entry.separator) return; + shell->close_menu(); + if (entry.action) entry.action(); +} + +void GrrMenu::step(int delta) { + int pos = sel; + while (true) { + pos += delta; + if (pos < 0 || pos >= static_cast(entries.size())) return; + if (!entries[static_cast(pos)].separator) { + sel = pos; + touch(); + return; + } + } +} + +void GrrMenu::on_key(TuiKey& event) { + const std::string& key = event.key; + event.stop(); + if (key == "escape") { + shell->close_menu(); + } else if (key == "up") { + step(-1); + } else if (key == "down") { + step(1); + } else if (key == "home") { + sel = 0; + for (size_t i = 0; i < entries.size(); i++) if (!entries[i].separator) { sel = static_cast(i); break; } + touch(); + } else if (key == "end") { + for (int i = static_cast(entries.size()) - 1; i >= 0; i--) if (!entries[static_cast(i)].separator) { sel = i; break; } + touch(); + } else if (key == "enter" || key == "space") { + run(sel); + } +} + +void GrrMenu::on_click(TuiClick& event) { + if (!event.pressed) return; + event.stop(); + int index = top + (event.y - ay); + if (0 <= index && index < static_cast(entries.size())) run(index); +} + +void GrrMenu::on_outside(TuiClick&) { shell->close_menu(); } + +void GrrMenu::on_scroll(TuiScroll& event) { + event.stop(); + step(event.direction == "up" ? -1 : 1); +} + +// ---- toast ----------------------------------------------------------------------------- + +TuiPanelOpts GrrToast::opts_for(int w, int y, int h) { + TuiPanelOpts o; + o.layer = "toast"; + o.x = TuiSpec("center"); + o.y = TuiSpec(y); + o.w = TuiSpec(w); + o.h = TuiSpec(h); + return o; +} + +GrrToast::GrrToast(SpanRow row, int w, int y, GrrFn c) : TuiRich(SpanRows{}, opts_for(w, y, 1)), click(std::move(c)) { set_row(row); } +GrrToast::GrrToast(SpanRows rows, int w, int y, GrrFn c) + : TuiRich(SpanRows{}, opts_for(w, y, !rows.empty() ? static_cast(rows.size()) : 1)), click(std::move(c)) { set(std::move(rows)); } + +void GrrToast::on_click(TuiClick& event) { + if (event.pressed && click) { + event.stop(); + click(); + } +} + +// ---- welcome -------------------------------------------------------------------------------- + +GrrWelcome::GrrWelcome(GrrShell& sh) + : TuiPanel([&] { + TuiPanelOpts o; + o.w = TuiSpec("1fr"); + o.h = TuiSpec("100%"); + o.style = sh.ui->st("ink", "bg"); + return o; + }()), + shell(&sh) {} + +SpanRows GrrWelcome::content_rows() { + GrrUi& ui = *shell->ui; + int width = content_w(), height = content_h(); + TuiValue bot = TuiValue::dict(); + bot.set("shape", "blob"); + bot.set("color", ui.palette.c("accent")); + SpanRows art = GrrAvatar::rows(ui, bot, "bg", false, true); + SpanRows lines; + for (auto& r : art) { + SpanRow row{TuiSpan(UStr(static_cast(std::max(0, (width - TuiSpans::width(r)) / 2)), U' '), ui.st())}; + row.insert(row.end(), r.begin(), r.end()); + lines.push_back(GrrText::line(row, width, ui.st())); + } + lines.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), ui.st())}); + lines.push_back(SpanRow{TuiSpan(TuiStr::center(U"grr", width), ui.st("ink", "bg", true))}); + for (auto& line : GrrText::wrap(shell->t("app.tagline"), std::max(10, std::min(width - 4, 46)))) { + lines.push_back(SpanRow{TuiSpan(TuiStr::center(line, width), ui.st("muted", "bg"))}); + } + int top_pad = std::max(0, (height - static_cast(lines.size())) / 2); + SpanRow blank{TuiSpan(UStr(static_cast(width), U' '), ui.st())}; + SpanRows rows(static_cast(top_pad), blank); + rows.insert(rows.end(), lines.begin(), lines.end()); + for (int i = 0; i < height; i++) rows.push_back(blank); + rows.resize(static_cast(height)); + return rows; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrMenu.h b/taicpp/src/tui/GrrMenu.h new file mode 100644 index 0000000..b1eb27c --- /dev/null +++ b/taicpp/src/tui/GrrMenu.h @@ -0,0 +1,52 @@ +#pragma once +// GrrMenu: a popup list of actions near the click that opened it. Up and Down +// move, Enter runs, Esc or a click outside closes (tai.py GrrMenu). GrrToast: a +// short message over the screen; clicking a banner runs its action (tai.py +// GrrToast). GrrWelcome: the empty main pane of the two-pane layout (tai.py +// GrrWelcome). +#include "GrrItems.h" +#include "GrrTuiApp.h" +#include "TuiRich.h" +namespace detz::tui { + +class GrrShell; + +class GrrMenu : public TuiPanel, public GrrUiHolder, public GrrOutsideClick { +public: + GrrMenu(GrrShell& shell, std::vector items, std::optional> anchor = std::nullopt, bool align_left = false); + void set_ui(const GrrUiP& u) override { ui = u; } + SpanRows content_rows() override; + void run(int index); + void step(int delta); + void on_key(TuiKey& event) override; + void on_click(TuiClick& event) override; + void on_outside(TuiClick& event) override; + void on_scroll(TuiScroll& event) override; + GrrShell* shell; + GrrUiP ui; + std::vector entries; + int sel{0}; + int top{0}; + bool icons; +private: + static TuiPanelOpts opts_for(GrrShell& shell, const std::vector& items, std::optional> anchor, bool align_left, bool icons); +}; + +class GrrToast : public TuiRich { +public: + GrrToast(SpanRow row, int w, int y, GrrFn click = nullptr); // one flat row of spans + GrrToast(SpanRows rows, int w, int y, GrrFn click = nullptr); // several rows + void on_click(TuiClick& event) override; + GrrFn click; +private: + static TuiPanelOpts opts_for(int w, int y, int h); +}; + +class GrrWelcome : public TuiPanel { +public: + explicit GrrWelcome(GrrShell& shell); + SpanRows content_rows() override; + GrrShell* shell; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrPage.cpp b/taicpp/src/tui/GrrPage.cpp new file mode 100644 index 0000000..ec458d4 --- /dev/null +++ b/taicpp/src/tui/GrrPage.cpp @@ -0,0 +1,253 @@ +#include "GrrPage.h" +#include "TuiApp.h" +#include +namespace detz::tui { + +GrrPage::GrrPage(GrrUiP u, bool stick, bool auto_select, TuiPanelOpts opts) + : TuiPanel([&] { + if (!opts.focusable) opts.focusable = true; + return opts; + }()), + ui(std::move(u)), stick_(stick), auto_(auto_select) {} + +void GrrPage::set_items(std::vector items) { + std::string keep; + bool have_keep = false; + if (0 <= sel && sel < static_cast(items_.size())) { + keep = items_[static_cast(sel)]->ident(); + have_keep = true; + } + for (auto& item : items_) item->page = nullptr; + items_ = std::move(items); + for (auto& item : items_) item->page = this; + sel = -1; + if (have_keep) { + for (size_t p = 0; p < items_.size(); p++) { + if (items_[p]->selectable && items_[p]->ident() == keep) { + sel = static_cast(p); + break; + } + } + } + if (sel < 0 && auto_) sel = first_selectable(); + dirty_ = true; + touch(); +} + +void GrrPage::append(const GrrItemP& item) { + item->page = this; + items_.push_back(item); + dirty_ = true; + touch(); +} + +void GrrPage::item_changed(GrrItem*) { + dirty_ = true; + touch(); +} + +int GrrPage::first_selectable() const { + for (size_t p = 0; p < items_.size(); p++) if (items_[p]->selectable) return static_cast(p); + return -1; +} + +std::vector GrrPage::selectables() const { + std::vector out; + for (size_t p = 0; p < items_.size(); p++) if (items_[p]->selectable) out.push_back(static_cast(p)); + return out; +} + +void GrrPage::layout(int width) { + if (!dirty_ && width == width_seen) return; + width_seen = width; + dirty_ = false; + starts_.clear(); + int row = 0; + for (auto& item : items_) { + starts_.push_back(row); + row += static_cast(item->rows(*ui, width, false).size()); + } + total_ = row; +} + +int GrrPage::total_rows() { + layout(content_w()); + return total_; +} + +void GrrPage::scroll_to_end() { + follow = true; + top_row = std::max(0, total_rows() - content_h()); + touch(); +} + +bool GrrPage::at_bottom() const { return top_row >= std::max(0, total_ - content_h()); } + +void GrrPage::scroll_by(int delta) { + int height = content_h(); + layout(content_w()); + top_row = std::max(0, std::min(std::max(0, total_ - height), top_row + delta)); + follow = top_row >= std::max(0, total_ - height); + touch(); + if (on_bottom) on_bottom(follow); +} + +int GrrPage::first_at(int row) { + int lo = 0, hi = static_cast(starts_.size()); + while (lo < hi) { + int mid = (lo + hi) / 2; + if (starts_[static_cast(mid)] + static_cast(items_[static_cast(mid)]->rows(*ui, width_seen, false).size()) <= row) lo = mid + 1; + else hi = mid; + } + return lo; +} + +SpanRows GrrPage::content_rows() { + int width = content_w(), height = content_h(); + layout(width); + bool focused = app && app->focused == this; + if (stick_ && follow) top_row = std::max(0, total_ - height); + if (reveal_ && 0 <= sel && sel < static_cast(items_.size())) { + int start = starts_[static_cast(sel)]; + int end = start + static_cast(items_[static_cast(sel)]->rows(*ui, width, false).size()); + if (start < top_row) top_row = start; + else if (end > top_row + height) top_row = std::min(start, end - height); + reveal_ = false; + } + top_row = std::max(0, std::min(top_row, std::max(0, total_ - height))); + SpanRows rows; + std::vector visible; + int pos = first_at(top_row); + StyleP blank = ui->st("ink", "bg"); + while (pos < static_cast(items_.size()) && static_cast(rows.size()) < height) { + auto& item = items_[static_cast(pos)]; + bool selected = focused && pos == sel; + SpanRows built = item->rows(*ui, width, selected); + int skip = rows.empty() ? std::max(0, top_row - starts_[static_cast(pos)]) : 0; + int room = height - static_cast(rows.size()); + int count = 0; + for (int i = skip; i < static_cast(built.size()) && i < skip + room; i++) { + rows.push_back(built[static_cast(i)]); + count++; + } + visible.push_back(Visible{static_cast(rows.size()) - count, pos, skip, count}); + pos++; + } + visible_ = visible; + while (static_cast(rows.size()) < height) rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), blank)}); + return rows; +} + +void GrrPage::select(int pos) { + if (pos != sel) sel = pos; + reveal_ = true; + touch(); +} + +void GrrPage::move_selection(int delta, bool* moved) { + auto done = [&](bool v) { if (moved) *moved = v; }; + if (0 <= sel && sel < static_cast(items_.size()) && items_[static_cast(sel)]->nav(delta > 0 ? "down" : "up")) { + reveal_ = true; + touch(); + done(true); + return; + } + auto order = selectables(); + if (order.empty()) { + scroll_by(delta * 3); + done(false); + return; + } + auto it = std::find(order.begin(), order.end(), sel); + if (it == order.end()) { + select(delta > 0 ? order.front() : order.back()); + done(true); + return; + } + int at = static_cast(it - order.begin()) + delta; + if (0 <= at && at < static_cast(order.size())) { + select(order[static_cast(at)]); + done(true); + return; + } + done(false); +} + +void GrrPage::focus_last() { + auto order = selectables(); + if (!order.empty() && sel < 0) select(order.back()); +} + +void GrrPage::on_key(TuiKey& event) { + const std::string& key = event.key; + UStr ch = event.ch; + if (key == "up") { + event.stop(); + move_selection(-1); + } else if (key == "down") { + event.stop(); + move_selection(1); + } else if ((key == "left" || key == "right") && 0 <= sel && sel < static_cast(items_.size())) { + if (items_[static_cast(sel)]->nav(key)) { + event.stop(); + reveal_ = true; + touch(); + } + } else if (key == "enter" || key == "space" || (ch == U" " && key.compare(0, 4, "ctrl") != 0)) { + if (0 <= sel && sel < static_cast(items_.size())) { + event.stop(); + items_[static_cast(sel)]->activate(); + } + } else if (key == "pageup") { + event.stop(); + scroll_by(-std::max(1, content_h() - 1)); + } else if (key == "pagedown") { + event.stop(); + scroll_by(std::max(1, content_h() - 1)); + } else if (key == "home") { + event.stop(); + auto order = selectables(); + if (!order.empty()) select(order.front()); + top_row = 0; + follow = false; + touch(); + } else if (key == "end") { + event.stop(); + auto order = selectables(); + if (!order.empty()) select(order.back()); + scroll_to_end(); + } +} + +void GrrPage::on_scroll(TuiScroll& event) { + event.stop(); + scroll_by(event.direction == "up" ? -3 : 3); +} + +void GrrPage::on_click(TuiClick& event) { + if (!event.pressed) return; + event.stop(); + int local_y = event.y - ay; + int local_x = event.x - ax; + for (auto& v : std::vector(visible_)) { + if (v.start <= local_y && local_y < v.start + v.count) { + GrrItemP item = items_[static_cast(v.pos)]; + int row = v.skip + local_y - v.start; + GrrFn fn = item->hit_at(*ui, width_seen, row, local_x); + if (fn) { + if (item->selectable) sel = v.pos; + fn(); + return; + } + if (item->selectable) { + sel = v.pos; + reveal_ = false; + touch(); + item->activate(); + } + return; + } + } +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrPage.h b/taicpp/src/tui/GrrPage.h new file mode 100644 index 0000000..712274f --- /dev/null +++ b/taicpp/src/tui/GrrPage.h @@ -0,0 +1,54 @@ +#pragma once +// GrrPage: a scrolling list of items with a keyboard selection (tai.py GrrPage). +// 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. +#include "GrrItem.h" +#include "TuiPanel.h" +namespace detz::tui { + +class GrrPage : public TuiPanel, public GrrUiHolder { +public: + GrrPage(GrrUiP ui, bool stick = false, bool auto_select = true, TuiPanelOpts opts = {}); + GrrUiP ui; + void set_ui(const GrrUiP& u) override { ui = u; } + const std::vector& items() const { return items_; } + void set_items(std::vector items); + void append(const GrrItemP& item); + void item_changed(GrrItem* item); + std::vector selectables() const; + int total_rows(); + void scroll_to_end(); + bool at_bottom() const; + void scroll_by(int delta); // note: one argument, unlike TuiPanel::scroll_by(dx, dy) + SpanRows content_rows() override; + void move_selection(int delta, bool* moved = nullptr); // python: move + void focus_last(); + void on_key(TuiKey& event) override; + void on_scroll(TuiScroll& event) override; + void on_click(TuiClick& event) override; + + std::function on_bottom; + bool follow{true}; // python _follow + int sel{-1}; // python _sel + int top_row{0}; // python _top + int width_seen{-1}; // python _width + +private: + std::vector items_; + bool stick_; + bool auto_; + std::vector starts_; + int total_{0}; + bool dirty_{true}; + struct Visible { int start, pos, skip, count; }; + std::vector visible_; + bool reveal_{false}; + void layout(int width); + int first_selectable() const; + int first_at(int row); + void select(int pos); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrPalette.cpp b/taicpp/src/tui/GrrPalette.cpp new file mode 100644 index 0000000..804ac3e --- /dev/null +++ b/taicpp/src/tui/GrrPalette.cpp @@ -0,0 +1,81 @@ +#include "GrrPalette.h" +#include "GrrConst.h" +#include +namespace detz::tui { + +GrrPalette::GrrPalette(const TuiValue& spec, const std::string& theme, const std::string& variant_in) { + TuiValue palette = spec.get("palette"); + if (!palette.truthy()) palette = GrrConst::palette_fallback(); + mode = mode_of(theme); + const TuiValue& fb = GrrConst::palette_fallback(); + for (auto& p : fb.get(mode).pairs()) colors[p.first] = p.second.str(); + TuiValue mode_colors = palette.get(mode); + if (mode_colors.is_dict()) for (auto& p : mode_colors.pairs()) colors[p.first] = p.second.str(); + TuiValue variants = palette.get("variants"); + TuiValue change = variants.is_dict() ? variants.get(variant_in).get(mode) : TuiValue(); + if (change.is_dict() && change.truthy()) for (auto& p : change.pairs()) colors[p.first] = p.second.str(); + variant = variant_in; + TuiValue bots = palette.get("bot_colors"); + if (!bots.truthy()) bots = fb.get("bot_colors"); + for (auto& b : bots.items()) bot_colors.push_back(b.str()); + name = "grr-" + mode + "-" + variant; +} + +std::string GrrPalette::mode_of(const std::string& theme) { + if (theme == "day" || theme == "night") return theme; + const char* env = std::getenv("COLORFGBG"); + std::string hint = env ? env : ""; + if (!hint.empty()) { + size_t semi = hint.rfind(';'); + std::string last = semi == std::string::npos ? hint : hint.substr(semi + 1); + bool digits = !last.empty(); + for (char ch : last) if (ch < '0' || ch > '9') digits = false; + if (digits) { + long v = std::strtol(last.c_str(), nullptr, 10); + if (v == 7 || v == 15) return "day"; + } + } + return "night"; +} + +std::string GrrPalette::c(const std::string& n) const { + auto it = colors.find(n); + if (it != colors.end() && !it->second.empty()) return it->second; + return colors.at("ink"); +} + +std::shared_ptr GrrPalette::tui_theme() const { + auto C = [&](const char* n) { return TuiColor(c(n)); }; + std::vector> 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 std::make_shared(name, mode == "night", roles); +} + +void GrrPalette::apply() const { + TuiThemes::register_theme(tui_theme()); + TuiThemes::set(name); +} + +bool GrrPalette::light(const std::string& color) { + size_t b = 0; + while (b < color.size() && color[b] == '#') b++; + std::string hex = color.substr(b); + if (hex.empty()) return false; + unsigned long n = 0; + for (char ch : hex) { + n <<= 4; + if (ch >= '0' && ch <= '9') n |= static_cast(ch - '0'); + else if (ch >= 'a' && ch <= 'f') n |= static_cast(ch - 'a' + 10); + else if (ch >= 'A' && ch <= 'F') n |= static_cast(ch - 'A' + 10); + else return false; + } + return (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000.0 > 150; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrPalette.h b/taicpp/src/tui/GrrPalette.h new file mode 100644 index 0000000..eda64fd --- /dev/null +++ b/taicpp/src/tui/GrrPalette.h @@ -0,0 +1,28 @@ +#pragma once +// 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 (tai.py +// GrrPalette). +#include "TuiTheme.h" +#include "TuiValue.h" +#include +#include +#include +namespace detz::tui { + +class GrrPalette { +public: + explicit GrrPalette(const TuiValue& spec = TuiValue(), const std::string& theme = "system", const std::string& variant = "black"); + static std::string mode_of(const std::string& theme); + std::string c(const std::string& name) const; + std::shared_ptr tui_theme() const; + void apply() const; + static bool light(const std::string& color); + + std::string mode; + std::map colors; + std::string variant; + std::vector bot_colors; + std::string name; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/GrrPane.cpp b/taicpp/src/tui/GrrPane.cpp new file mode 100644 index 0000000..61a79dc --- /dev/null +++ b/taicpp/src/tui/GrrPane.cpp @@ -0,0 +1,559 @@ +#include "GrrPane.h" +#include "GrrChat.h" +#include "GrrShell.h" +#include +#include +#include +namespace detz::tui { + +namespace { +UStr su(const TuiValue& v) { return TuiStr::u(v.str()); } +std::vector list_of(const TuiValue& v) { return v.is_list() ? v.items() : std::vector{}; } +} // namespace + +GrrShell& GrrPaneTab::g() const { return *pane->g; } +const TuiValue& GrrPaneTab::bot() const { return pane->bot; } +std::string GrrPaneTab::base(const std::string& name) const { return "/api/bots/" + bot().get("id").str() + "/" + name; } +void GrrPaneTab::items(std::vector its) { pane->page->set_items(std::move(its)); } + +// ---- pane ----------------------------------------------------------------------------------- + +GrrPane::GrrPane(GrrChatView* view) : v(view), g(&view->g) { + GrrUiP ui = view->ui; + cfg = g->spec.get("pane").truthy() ? g->spec.get("pane") : TuiValue::dict(); + if (cfg.get("tabs").truthy()) tabs = cfg.get("tabs").items(); + else tabs = {TuiValue::object({{"id", "preview"}}), TuiValue::object({{"id", "files"}}), TuiValue::object({{"id", "deploy"}, {"enabled", false}})}; + tab = cfg.get("default_tab").truthy() ? cfg.get("default_tab").str() : "preview"; + auto bs = view->bots(); + bot = bs[0]; + TuiPanelOpts ro; + ro.w = width(); + ro.h = TuiSpec("100%"); + ro.style = ui->st("ink", "bg"); + root = std::make_shared(ro); + TuiPanelOpts ho; + ho.style = ui->st("ink", "bg"); + head = std::make_shared(ui, ho); + TuiPanelOpts po; + po.style = ui->st("ink", "bg"); + po.h = TuiSpec("1fr"); + page = std::make_shared(ui, false, true, po); + root->add(head); + root->add(page); + TuiValue& slot = g->pane_state[view->chat.get("id").str()]; + if (slot.is_null()) slot = TuiValue::dict(); + state = &slot; + watchers.push_back(g->events.on("files.changed", [this](const TuiValue& d) { on_event("files", d); })); + watchers.push_back(g->events.on("port.opened", [this](const TuiValue& d) { on_event("preview", d); })); + watchers.push_back(g->events.on("port.closed", [this](const TuiValue& d) { on_event("preview", d); })); + watchers.push_back(g->events.on("preview.ready", [this](const TuiValue& d) { on_event("preview", d); })); + watchers.push_back(g->events.on("preview.failed", [this](const TuiValue& d) { on_event("preview", d); })); +} + +TuiSpec GrrPane::width() const { + if (layer_mode()) return TuiSpec("1fr"); + return TuiSpec(width_px()); +} +int GrrPane::width_px() const { return std::max(GRR_PANE_MIN_W, std::min(GRR_PANE_MAX_W, g->app->cols * 46 / 100)); } +bool GrrPane::layer_mode() const { return g->app->cols < GRR_PANE_MIN_COLS; } + +void GrrPane::destroy() { + for (auto& off : watchers) off(); + watchers.clear(); + if (keeper) { + g->app->cancel_timer(keeper); + keeper.reset(); + } + preview.hide(); +} + +void GrrPane::restore() { + if (state->get("open").truthy() && !layer_mode()) show(state->get("tab").truthy() ? state->get("tab").str() : tab); +} + +void GrrPane::toggle() { + if (is_open) hide(); + else show(state->get("tab").truthy() ? state->get("tab").str() : tab); +} + +void GrrPane::show(const std::string& want) { + is_open = true; + state->set("open", true); + root->set_w(width()); + if (!root->parent) v->root->add(root); + if (layer_mode()) v->col->set_visible(false); + v->refresh_head(); + bool known = std::any_of(tabs.begin(), tabs.end(), [&](const TuiValue& t) { return t.get("id").str() == want; }); + pick(known ? want : tab); + g->app->set_focus(page.get()); +} + +void GrrPane::hide() { + if (!is_open) return; + is_open = false; + state->set("open", false); + if (root->parent) v->root->remove(root.get()); + v->col->set_visible(true); + preview.hide(); + v->refresh_head(); + g->app->set_focus(v->box.get()); +} + +void GrrPane::pick(const std::string& t) { + tab = t; + state->set("tab", t); + if (t != "preview") preview.hide(); + paint_head(); + current()->show(); +} + +GrrPaneTab* GrrPane::current() { + if (tab == "files") return &files; + if (tab == "deploy") return &deploy; + return &preview; +} + +void GrrPane::paint_head() { + GrrUi& ui = *v->ui; + std::vector parts; + for (auto& t : tabs) { + std::string id = t.get("id").str(); + bool on = id == tab; + GrrBarPart p; + p.text = U" " + g->t("pane.tab_" + id) + U" "; + p.fn = [this, id] { pick(id); }; + p.bg = on ? "accent" : "bg"; + p.fg = on ? "accent-ink" : "muted"; + p.bold = on; + parts.push_back(p); + } + GrrBarPart fill; + fill.fill = true; + parts.push_back(fill); + auto bs = v->bots(); + if (bs.size() > 1) { + GrrBarPart p; + p.text = U" " + ui.icon("user") + U" " + GrrText::clip(su(bot.get("name")), 12) + U" "; + p.bg = "card"; + p.fn = [this, bs] { + std::vector entries; + for (auto& b : bs) { + GrrMenuEntry e; + e.label = su(b.get("name")); + e.checked = b.get("id") == bot.get("id"); + TuiValue bb = b; + e.action = [this, bb] { choose(bb); }; + entries.push_back(e); + } + g->menu(entries); + }; + parts.push_back(p); + } + GrrBarPart close_part; + close_part.text = U" " + ui.icon("close") + U" "; + close_part.fn = [this] { hide(); }; + close_part.bg = "card"; + parts.push_back(close_part); + head->set(parts); +} + +void GrrPane::choose(const TuiValue& b) { + bot = b; + state->set("bot", b.get("id")); + files.reset(); + preview.reset(); + paint_head(); + current()->show(); +} + +void GrrPane::on_event(const std::string& kind, const TuiValue& data) { + TuiValue bid = data.get("bot_id", bot.get("id")); + if (!is_open || !(bid == bot.get("id"))) return; + if (kind == "files" && tab == "files") files.changed(); + else if (kind == "preview" && tab == "preview") preview.load(true); +} + +// ---- files ---------------------------------------------------------------------------------------- + +std::string GrrPaneFiles::base(const std::string& name) const { return "/api/bots/" + bot().get("id").str() + "/workspace/" + name; } + +void GrrPaneFiles::reset() { + path.clear(); + q.clear(); + file = TuiValue(); + entries.reset(); + gen++; +} + +void GrrPaneFiles::show() { + if (file.truthy()) view_file(file); + else load(); +} + +void GrrPaneFiles::changed() { + if (!file.truthy()) load(true); +} + +void GrrPaneFiles::load(bool quiet) { + GrrShell& sh = g(); + gen++; + long my = gen; + std::string url = !q.empty() ? base("search") + "?q=" + GrrClient::quote(TuiStr::s(q)) : base("tree") + "?path=" + GrrClient::quote(path); + sh.app->offload([&sh, url]() -> TuiValue { return sh.client.get(url); }, + [this, my, quiet, &sh, alive = pane->alive](const TuiValue& res, std::exception_ptr err) { + if (!*alive || my != gen) return; + if (err) { + if (!quiet) sh.error(err); + items({std::make_shared("warn", sh.t("files.load_failed"))}); + return; + } + TuiValue e = res.get("entries").truthy() ? res.get("entries") : res.get("results"); + entries = list_of(e); + total = res.has("total") ? res.get("total").as_int() : static_cast(entries->size()); + truncated = res.get("truncated").truthy(); + paint(); + }); +} + +void GrrPaneFiles::paint() { + GrrShell& sh = g(); + GrrUi& ui = *sh.ui; + std::vector chips; + { + GrrChip c1; + c1.label = U"/" + TuiStr::u(path); + c1.icon = "folder"; c1.caret = false; c1.action = [this] { up(); }; + GrrChip c2; + c2.label = !q.empty() ? q : sh.t("files.filter"); + c2.icon = "search"; c2.caret = false; c2.action = [this] { ask(); }; + GrrChip c3; + c3.label = sh.t("files.refresh"); + c3.icon = "refresh"; c3.caret = false; c3.action = [this] { load(); }; + chips = {c1, c2, c3}; + } + std::vector its; + its.push_back(std::make_shared(chips)); + std::vector> rows; + for (auto& e : entries.value_or(std::vector{})) { + std::string name = e.get("path").truthy() ? e.get("path").str() : (path.empty() ? "" : path + "/") + e.get("name").str(); + bool is_dir = e.get("type").is_str() && e.get("type").as_str() == "dir"; + UStr sub = is_dir ? (e.get("cache").truthy() ? sh.t("files.cache") : UStr()) : GrrFormat::bytes(e.get("size", TuiValue(0)).as_int()); + GrrRowOpts o; + o.icon = is_dir ? "folder" : "file"; + o.sub = sub; + o.chevron = is_dir ? "1" : ""; + o.action = [this, name, is_dir] { if (is_dir) enter(name); else open_file(name); }; + UStr title = !q.empty() ? (e.get("path").truthy() ? su(e.get("path")) : su(e.get("name"))) : su(e.get("name")); + rows.push_back(std::make_shared(title, o)); + } + if (!path.empty() && q.empty()) { + GrrRowOpts o; + o.icon = "back"; o.action = [this] { up(); }; + rows.insert(rows.begin(), std::make_shared(U"..", o)); + } + if (!rows.empty()) grr_append(its, grr_group(rows)); + else its.push_back(std::make_shared("folder", !q.empty() ? sh.t("files.none") : sh.t("files.empty"))); + if (truncated) its.push_back(std::make_shared(sh.t("files.cut", {{"shown", std::to_string(entries ? entries->size() : 0)}, {"total", std::to_string(total)}}))); + (void)ui; + items(its); +} + +void GrrPaneFiles::enter(const std::string& name) { + path = name; + q.clear(); + load(); +} + +void GrrPaneFiles::up() { + if (!q.empty()) { + q.clear(); + } else { + size_t at = path.rfind('/'); + path = at != std::string::npos ? path.substr(0, at) : std::string(); + } + load(); +} + +void GrrPaneFiles::ask() { + GrrShell& sh = g(); + sh.edit(sh.t("files.filter"), q, [this](const UStr& v) { + q = TuiStr::strip(v); + load(); + }); +} + +void GrrPaneFiles::open_file(const std::string& p) { + GrrShell& sh = g(); + gen++; + long my = gen; + std::string url = base("file") + "?path=" + GrrClient::quote(p); + sh.app->offload([&sh, url]() -> TuiValue { return sh.client.get(url); }, [this, my, &sh, alive = pane->alive](const TuiValue& res, std::exception_ptr err) { + if (!*alive || my != gen) return; + if (err) { + sh.error(err); + return; + } + file = res; + view_file(res); + }); +} + +void GrrPaneFiles::view_file(const TuiValue& info) { + GrrShell& sh = g(); + TuiValue inf = info; + GrrChip c1; + c1.label = sh.t("files.back_list"); c1.icon = "back"; c1.caret = false; c1.action = [this] { close_file(); }; + GrrChip c2; + c2.label = sh.t("files.copy_path"); c2.icon = "copy"; c2.caret = false; + c2.action = [&sh, inf] { sh.copy_text(TuiStr::u(inf.get("path", TuiValue("")).str())); }; + GrrChip c3; + c3.label = sh.t("files.download"); c3.icon = "download"; c3.caret = false; c3.action = [this, inf] { download(inf); }; + std::vector its = {std::make_shared(std::vector{c1, c2, c3}), + std::make_shared(TuiStr::u(info.get("path", TuiValue("")).str()), false, "muted")}; + if (info.get("text").is_str()) { + auto lines = TuiStr::split(TuiStr::u(info.get("text").as_str()), U'\n'); + size_t total_lines = lines.size(); + if (lines.size() > 2000) lines.resize(2000); + int width = static_cast(std::to_string(lines.size()).size()); + UStr body; + for (size_t i = 0; i < lines.size(); i++) { + if (i) body += U"\n"; + char buf[32]; + std::snprintf(buf, sizeof buf, "%*zu ", width, i + 1); + body += TuiStr::u(buf) + lines[i]; + } + its.push_back(std::make_shared(body, nullptr, UStr(), true)); + if (total_lines > lines.size()) its.push_back(std::make_shared(sh.t("files.cut", {{"shown", std::to_string(lines.size())}, {"total", std::to_string(total_lines)}}))); + } else { + std::string kind = info.get("kind").is_str() ? info.get("kind").as_str() : ""; + UStr text; + if (kind == "large") text = sh.t("files.large", {{"max", TuiStr::s(GrrFormat::bytes(sh.ui->cap("VIEWER_MAX_BYTES", 2097152)))}}); + else if (kind == "gone") text = sh.t("files.gone"); + else text = sh.t("files.binary"); + its.push_back(std::make_shared(text, true)); + } + items(its); +} + +void GrrPaneFiles::close_file() { + file = TuiValue(); + load(); +} + +void GrrPaneFiles::download(const TuiValue& info) { + GrrShell& sh = g(); + std::string url = base("raw") + "?path=" + GrrClient::quote(info.get("path", TuiValue("")).str()) + "&download=1"; + std::string name = info.get("name", TuiValue("file")).str(); + sh.app->offload( + [&sh, url, name]() -> TuiValue { + auto r = sh.client.fetch(url, GRR_BODY_MAX); + std::string safe; + for (char c : name) safe.push_back((std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-') ? c : '_'); + const char* tmp = std::getenv("TMPDIR"); + std::string dest = std::string(tmp && *tmp ? tmp : "/tmp") + "/grr-" + safe; + std::ofstream out(dest, std::ios::binary); + out.write(r.data.data(), static_cast(r.data.size())); + return TuiValue(dest); + }, + [&sh](const TuiValue& dest, std::exception_ptr err) { + if (err) sh.error(err); + else sh.toast(U"Saved " + TuiStr::u(dest.str())); + }); +} + +// ---- preview ---------------------------------------------------------------------------------------------- + +void GrrPanePreview::reset() { + info = TuiValue(); + target.clear(); +} + +void GrrPanePreview::show() { + GrrShell& sh = g(); + shown = true; + load(); + if (!pane->keeper) { + double ttl = std::max(5.0, static_cast(sh.ui->cap("PORT_WATCH_TTL", 120)) / 2.0); + pane->keeper = sh.app->every(ttl, [this, alive = pane->alive] { if (*alive && shown) load(true); }); + } +} + +void GrrPanePreview::hide() { + shown = false; + if (pane->keeper) { + g().app->cancel_timer(pane->keeper); + pane->keeper.reset(); + } +} + +void GrrPanePreview::load(bool quick) { + GrrShell& sh = g(); + std::string url = base("preview") + (quick ? "?ports=0" : ""); + sh.app->offload([&sh, url]() -> TuiValue { return sh.client.get(url); }, [this, &sh, alive = pane->alive](const TuiValue& res, std::exception_ptr err) { + if (!*alive) return; + if (err) { + info = TuiValue::object({{"folders", TuiValue::list()}, {"ports", TuiValue::list()}, {"active", TuiValue()}, {"proxy", "local"}, {"domain", ""}, {"failed", true}}); + sh.error(err); + } else { + info = res; + } + paint(); + TuiValue active = info.get("active"); + if (shown && active.truthy() && active.get("state").is_str() && active.get("state").as_str() == "starting") { + sh.app->after(2.0, [this, alive = pane->alive] { if (*alive && shown) load(true); }); + } + }); +} + +std::vector GrrPanePreview::options() const { + GrrShell& sh = g(); + std::vector