- name
- tui-development
- description
- Guide for developing the OpenShell TUI — a ratatui-based terminal UI for the OpenShell platform. Covers architecture, navigation, data fetching, theming, UX conventions, and development workflow. Trigger keywords - term, TUI, terminal UI, ratatui, openshell-tui, tui development, tui feature, tui bug.
- metadata
- {"internal":true}
# OpenShell TUI Development Guide
Comprehensive reference for any agent working on the OpenShell TUI.
## 1. Overview
The OpenShell TUI is a ratatui-based terminal UI for the OpenShell platform. It provides a keyboard-driven interface for managing gateways, sandboxes, and logs — the same operations available via the `openshell` CLI, but with a live, interactive dashboard.
- **Launched via:** `openshell term` or `mise run term`
- **Crate:** `crates/openshell-tui/`
- **Key dependencies:**
- `ratatui` (workspace version) — uses `frame.area()` for the drawable terminal area
- `crossterm` (workspace version) — terminal backend and event polling
- `tonic` with TLS — gRPC client for the OpenShell gateway
- `tokio` — async runtime for event loop, spawned tasks, and mpsc channels
- `openshell-core` — proto-generated types (`OpenShellClient`, request/response structs)
- `openshell-bootstrap` — gateway discovery (`list_gateways()`)
- **Theme:** Adaptive dark/light via `Theme` struct — NVIDIA-branded green accents. Controlled by `--theme` flag, `OPENSHELL_THEME` env var, or auto-detection.
## 2. Domain Object Hierarchy
The data model follows a strict hierarchy: **Gateway > Workspace > Sandboxes/Providers/Settings > Logs**.
```
Gateway (discovered via openshell_bootstrap::list_gateways())
├── Global Settings (fetched via GetGatewayConfig)
├── Global Policy indicator (fetched via ListSandboxPolicies global=true)
├── Workspaces (fetched via ListWorkspaces)
├── Provider Profiles (fetched via ListProviderProfiles, workspace-scoped)
├── Providers (fetched via ListProviders, workspace-scoped)
│ └── cached ProviderProfile (matched by type + workspace)
└── Sandboxes (fetched via ListSandboxes, workspace-scoped)
├── Policy (fetched via GetSandboxConfig)
├── Settings (effective settings with scope, from GetSandboxConfig)
├── Draft recommendations (fetched via GetDraftPolicy)
└── Logs (fetched via GetSandboxLogs + streamed via WatchSandbox)
```
- **Gateways** are discovered from on-disk config via `openshell_bootstrap::list_gateways()`. Each gateway has a name, endpoint, local/remote flag, and source label.
- **Workspaces** are fetched via `ListWorkspaces`. The user cycles through workspaces with `[w]`, or views all workspaces at once. The current workspace scopes provider and sandbox lists.
- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles`. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability.
- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. The TUI supports profile-backed create, update, and delete operations.
- **Global Settings** are fetched via `GetGatewayConfig` and displayed in a tabbed pane alongside providers on the dashboard. Each setting is a registered key with a typed value (bool/int/string). Platform-admin access is required; `PermissionDenied` disables the pane.
- **Sandboxes** belong to the active gateway and workspace. Fetched via `ListSandboxes` with a periodic tick refresh.
- **Sandbox Settings** are effective settings returned by `GetSandboxConfig`, each with a scope (sandbox, global, or unset). Globally-managed settings are blocked from sandbox-level edits.
- **Logs** belong to a single sandbox. Initial batch fetched via `GetSandboxLogs` (500 lines), then live-tailed via `WatchSandbox` with `follow_logs: true`.
The **title bar** always reflects this hierarchy, reading left-to-right from general to specific:
```
OpenShell v<version> │ Current Gateway: <name> [source] (<status>) │ Workspace: <name|all> │ <screen/context>
```
## 3. Navigation & Screen Architecture
### Screens (`Screen` enum)
Top-level layouts that own the full content area. Each has its own nav bar hints.
| Screen | Description | Module |
| --- | --- | --- |
| `Splash` | Boot screen shown on startup, auto-dismissed after 3 seconds | `ui/splash.rs` |
| `Dashboard` | Gateway list (top) + providers/settings (middle) + sandbox table (bottom) | `ui/dashboard.rs` |
| `Sandbox` | Single-sandbox view — metadata (top) + policy/settings/logs/drafts (bottom) | `ui/sandbox_detail.rs`, `ui/sandbox_policy.rs`, `ui/sandbox_settings.rs`, `ui/sandbox_logs.rs`, `ui/sandbox_draft.rs` |
### Focus (`Focus` enum)
Tracks which panel currently receives keyboard input.
| Focus | Screen | Description |
| --- | --- | --- |
| `Gateways` | Dashboard | Gateway list panel has input focus |
| `Providers` | Dashboard | Provider list or global settings pane (depends on `MiddlePaneTab`) |
| `Sandboxes` | Dashboard | Sandbox table panel has input focus |
| `SandboxPolicy` | Sandbox | Policy viewer or settings table (depends on `SandboxPolicyTab`) |
| `SandboxLogs` | Sandbox | Log viewer with structured rendering |
| `SandboxDraft` | Sandbox | Draft policy recommendations list |
### Tab enums
Two tab enums control which sub-view renders within a focus area:
- **`MiddlePaneTab`** (`Providers` | `GlobalSettings`): toggles the middle dashboard pane between the provider list and the global settings table. Switched with `[h/l]`.
- **`SandboxPolicyTab`** (`Policy` | `Settings`): toggles the sandbox bottom pane between the policy viewer and the sandbox settings table. Switched with `[h]`.
### Screen dispatch
The top-level `ui::draw()` function (`ui/mod.rs`) handles the chrome (title bar, nav bar, command bar) and dispatches to the correct screen module:
```rust
match app.screen {
Screen::Splash => unreachable!(),
Screen::Dashboard => dashboard::draw(frame, app, chunks[1]),
Screen::Sandbox => draw_sandbox_screen(frame, app, chunks[1]),
}
```
Within the `Sandbox` screen, `sandbox_detail::required_height` sizes the metadata and restart status pane to its contents. The remaining area dispatches based on focus and tab state:
```rust
match app.focus {
Focus::SandboxLogs => sandbox_logs::draw(frame, app, chunks[1]),
Focus::SandboxDraft => sandbox_draft::draw(frame, app, chunks[1]),
_ => match app.sandbox_policy_tab {
SandboxPolicyTab::Settings => sandbox_settings::draw(frame, app, chunks[1]),
SandboxPolicyTab::Policy => sandbox_policy::draw(frame, app, chunks[1]),
},
}
```
On the dashboard, the middle pane dispatches by `MiddlePaneTab`:
```rust
match app.middle_pane_tab {
MiddlePaneTab::Providers => providers::draw(frame, app, chunks[1], mid_focused),
MiddlePaneTab::GlobalSettings => global_settings::draw(frame, app, chunks[1], mid_focused),
}
```
### Layout structure
Every frame renders four vertical regions:
```
┌─────────────────────────────────────────────┐
│ Title bar (1 row) — brand + gateway + context│
├─────────────────────────────────────────────┤
│ │
│ Main content (flexible) │
│ │
├─────────────────────────────────────────────┤
│ Nav bar (1 row) — context-sensitive key hints│
├─────────────────────────────────────────────┤
│ Command bar (1 row) — `:` command input │
└─────────────────────────────────────────────┘
```
### Title bar examples
- Dashboard: ` >_ OpenShell v<version> | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard`
- Sandbox detail: ` >_ OpenShell v<version> | Current Gateway: openshell [local] (Healthy) | Workspace: team-a | Sandbox: my-sandbox`
### Adding a new screen
1. Add a variant to `Screen` in `app.rs`.
2. Create a new module under `src/ui/` with a `pub fn draw(frame, app, area)`.
3. Add the module declaration in `ui/mod.rs`.
4. Add a match arm in `ui::draw()` to dispatch to the new module.
5. Add relevant `Focus` variants if the screen has multiple panels.
6. Add key handling methods in `App` for the new focus states.
7. Add nav bar hints in `draw_nav_bar()` for the new screen/focus combinations.
## 4. Data Fetching Pattern
### Initial fetch first, then stream
Always grab a batch of initial data so the UI has content immediately, then attach streaming for live updates.
**Logs example** (`spawn_log_stream` in `lib.rs`):
```
Phase 1: GetSandboxLogs → 500 initial lines → send via Event::LogLines
Phase 2: WatchSandbox(follow_logs: true) → live tail → send via Event::LogLines
```
**Sandboxes**: Fetched via `ListSandboxes` in a background collection-refresh task scheduled from the 2-second tick, scoped to the current workspace (or all workspaces). Follow `next_page_token` until empty so the dashboard reflects the complete collection. The NOTES column summarizes active `ConfigurationInvalid` readiness conditions as `Invalid config` before port forwards and clears the note on refresh after repair. Full diagnostics remain available through `openshell sandbox get <name> -o json`. Timed-out provisioning attempts show `Provisioning timed out` with cleanup pending or compute reclaimed, preserving port forwards. The sandbox detail pane wraps the full configuration error in its Notes field.
**Providers**: Fetched via `ListProviders` in the background collection-refresh task. Provider profiles are fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`. Follow each list RPC's `next_page_token` until empty.
**Settings**: Global settings are fetched via `GetGatewayConfig` on each tick. Sandbox settings are fetched alongside the sandbox policy via `GetSandboxConfig` and refreshed on each tick when viewing a sandbox.
**Workspaces**: The workspace list is fetched via `ListWorkspaces` in the background collection-refresh task, following `next_page_token` until empty.
Only one collection-refresh task may run at a time. Workspace and gateway changes abort the active task, and refresh results carry their gateway/workspace context so stale results are discarded.
### Never block the event loop
All network calls must be spawned as async tasks via `tokio::spawn`. The event loop in `lib.rs` must remain responsive to keyboard input and rendering at all times.
**Pattern:**
```rust
// Background task sends data back via mpsc channel
let handle = tokio::spawn(async move {
let result = client.some_rpc(request).await;
let _ = tx.send(Event::SomeData(result));
});
```
### Loading states
Show `"Loading..."` while async data is in flight (see `sandbox_logs.rs` — renders a loading message when `filtered` is empty and `sandbox_log_lines` is also empty).
### Event channel
Background tasks communicate with the event loop via `mpsc::UnboundedSender<Event>`. The `EventHandler` provides a `sender()` method to clone the transmit handle. There are many `Event` variants for different async results (log lines, create results, provider CRUD results, setting CRUD results, draft action results, forward warnings):
```rust
// In lib.rs
spawn_log_stream(&mut app, events.sender());
// In the spawned task
let _ = tx.send(Event::LogLines(lines));
```
### Access denial handling
Global settings and global policy queries may return `PermissionDenied` when the user lacks platform-admin access. The TUI sets `global_settings_access_denied` / `global_policy_access_denied` flags to stop retrying these calls on subsequent ticks, and clears the corresponding UI state.
### gRPC timeouts
All gRPC calls use a 5-second timeout via `tokio::time::timeout`:
```rust
tokio::time::timeout(Duration::from_secs(5), client.health(req)).await
```
## 5. Style Guide & Colors
### Theme System (`theme.rs`)
Colors and styles are defined in `crates/openshell-tui/src/theme.rs` via the `Theme` struct. The TUI supports dark and light terminal backgrounds.
#### Theme selection
Theme mode is controlled by three mechanisms (highest priority first):
1. `--theme dark|light|auto` CLI flag on `openshell term`
2. `OPENSHELL_THEME` environment variable
3. Auto-detection via `COLORFGBG` env var (falls back to dark)
The `ThemeMode` enum (`Auto`, `Dark`, `Light`) is resolved at startup via `theme::detect()` before entering raw mode.
#### Brand colors (`theme::brand`)
| Constant | Value | Usage |
| --- | --- | --- |
| `NVIDIA_GREEN` | `Color::Rgb(118, 185, 0)` | Primary accent (dark theme) |
| `NVIDIA_GREEN_DARK` | `Color::Rgb(80, 140, 0)` | Primary accent (light theme — darker for contrast) |
| `EVERGLADE` | `Color::Rgb(18, 49, 35)` | Dark green — borders, title bar bg (dark theme) |
| `MAROON` | `Color::Rgb(128, 0, 0)` | Pacman chase animation |
#### Theme struct fields
The `Theme` struct has 16 `Style` fields, accessed at runtime via `app.theme`:
| Field | Dark value | Light value | Usage |
| --- | --- | --- | --- |
| `text` | White fg | Near-black fg | Default body text |
| `muted` | White + DIM | Gray fg | Secondary info, separators |
| `heading` | White + BOLD | Near-black + BOLD | Panel titles, names |
| `accent` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Selected row marker, source labels |
| `accent_bold` | NVIDIA_GREEN + BOLD | NVIDIA_GREEN_DARK + BOLD | Brand text, command prompt |
| `selected` | BOLD only | BOLD only | Selected row emphasis |
| `border` | EVERGLADE fg | Light sage fg | Unfocused panel borders |
| `border_focused` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Focused panel borders |
| `status_ok` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Healthy, INFO, Ready |
| `status_warn` | Yellow fg | Dark yellow fg | Degraded, WARN, Provisioning, Starting |
| `status_err` | Red fg | Dark red fg | Unhealthy, ERROR |
| `key_hint` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Keyboard shortcut labels |
| `log_cursor` | EVERGLADE bg | Light green bg | Selected log line highlight |
| `claw` | MAROON + BOLD | MAROON + BOLD | Pacman animation |
| `title_bar` | White on EVERGLADE + BOLD | Near-black on light green + BOLD | Title bar strip |
| `badge` | Black on NVIDIA_GREEN + BOLD | White on NVIDIA_GREEN_DARK + BOLD | Notification badges |
#### Accessing the theme in draw functions
The `Theme` is stored on `App` and accessed via a local alias:
```rust
fn draw_my_widget(frame: &mut Frame<'_>, app: &App, area: Rect) {
let t = &app.theme;
frame.render_widget(
Paragraph::new(Span::styled("Hello", t.text)),
area,
);
}
```
For functions that don't take `&App` (e.g., detail popups, helpers), pass `&Theme` as a parameter:
```rust
fn draw_detail_popup(frame: &mut Frame<'_>, data: &MyData, area: Rect, theme: &Theme) {
let t = theme;
// ...
}
```
#### Visual conventions
- **Selected row**: Green `▌` left-border marker on the selected row. Active gateway also gets a green `●` dot.
- **Focused panel**: Border changes from `border` to `border_focused` style.
- **Status indicators**: Green for healthy/ready/info, yellow for degraded/provisioning/starting/warn, red for unhealthy/error.
- **Separators**: Muted `│` characters between title bar segments and nav bar sections.
Auf GitHub ansehen