---
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.
---

# 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.size()` (not `frame.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` when `providers_v2_enabled` is true. 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. When `providers_v2_enabled` is true, CRUD operations are read-only in the TUI; when false, the TUI supports create/update/delete.
- **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 │ 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, the top 20% renders sandbox metadata (`sandbox_detail`), and the bottom 80% 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  ALPHA  | Current Gateway: openshell [local] (Healthy) | Workspace: default | Dashboard`
- Sandbox detail: ` >_ OpenShell  ALPHA  | 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` on a 2-second tick, scoped to the current workspace (or all workspaces).

**Providers**: Fetched via `ListProviders` on each tick. When `providers_v2_enabled` is true, provider profiles are also fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`.

**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` on each tick.

### 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 |
| `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/warn, red for unhealthy/error.
- **Separators**: Muted `│` characters between title bar segments and nav bar sections.
- **Log source labels**: `"sandbox"` source renders in `accent` (green), `"gateway"` in `muted`.

## 6. UX Conventions

### Destructive actions require confirmation

Always show a y/n confirm dialog before delete, stop, or other irreversible operations.

```
Delete sandbox 'my-sandbox'? [y] Confirm  [Esc] Cancel
```

The `confirm_delete` flag in `App` gates destructive key handling — while true, only `y`, `n`, and `Esc` are processed.

### CLI parity

TUI actions should parallel `openshell` CLI commands so users have familiar mental models:

| CLI Command | TUI Equivalent |
| --- | --- |
| `openshell sandbox list` | Sandbox table on Dashboard |
| `openshell sandbox delete <name>` | `[d]` on sandbox detail, then `[y]` to confirm |
| `openshell sandbox create` | `[c]` on sandbox panel to open create form |
| `openshell sandbox connect` | `[s]` on sandbox policy view to launch SSH shell |
| `openshell logs <name>` | `[l]` on sandbox detail to open log viewer |
| `openshell provider list` | Provider table on Dashboard (middle pane) |
| `openshell provider create` | `[c]` on provider panel (when not providers_v2) |
| `openshell status` | Status in title bar + gateway list |

When adding new TUI features, check what the CLI offers and maintain consistency.

### Scrollable views follow k9s conventions

Any scrollable content (logs, future long lists) should follow the k9s autoscroll pattern:

- **Autoscroll on by default** — when entering a scrollable view, it auto-follows new content
- **Scrolling up pauses** — any upward scroll (keyboard or mouse) disables autoscroll
- **`f` or `G` re-enables** — jump to bottom and resume following
- **Visual indicator** — show `● FOLLOWING` (green) or `○ PAUSED` (yellow) in the panel footer
- **Mouse scroll supported** — `ScrollUp`/`ScrollDown` events move by 3 lines and respect autoscroll state
- **Scroll position shown** — `[current/total]` in the panel footer

State is tracked via `log_autoscroll: bool` on `App`. The `scroll_logs(delta)` method handles both keyboard and mouse input uniformly.

### Long content: truncate + detail popup

When content can exceed the viewport width (log lines, field lists, etc.):

- **Truncate in the list view** — hard-cut at the viewport's inner width and append `…`. This keeps density high and avoids wrapping that breaks the 1-line-per-entry model.
- **Enter opens a detail popup** — a centered overlay showing the full untruncated content with word-wrap. `Esc` or `Enter` closes it. Track the open state via `Option<usize>` index.
- **Drop noise in the list view** — omit empty fields, remove developer-internal info (like module paths / tracing targets) that the user doesn't need at a glance.
- **Smart field ordering** — for known message types (e.g. CONNECT, L7_REQUEST), put the most important fields first and trail with process ancestry / noise. Unknown types sort alphabetically.
- **Show everything in the popup** — the detail popup is where target, all fields (including empty ones if useful), and the full message are visible.

This pattern should be reused for any future view with potentially long entries.

### Vim-style navigation

| Key | Action |
| --- | --- |
| `j` / `Down` | Move selection down |
| `k` / `Up` | Move selection up |
| `g` | Jump to top (logs), disables autoscroll |
| `G` | Jump to bottom (logs), re-enables autoscroll |
| `f` | Follow / re-enable autoscroll (logs) |
| `Tab` / `BackTab` | Switch between panels on Dashboard |
| `Enter` | Select / drill into item; open detail popup in logs |
| `Esc` | Go back one level |
| `q` | Quit (from any screen) |
| `Ctrl+C` | Force quit |

### Keyboard-first, mouse-augmented

All actions are accessible via keyboard shortcuts displayed in the nav bar. The nav bar is context-sensitive — it shows different hints depending on the current screen and focus state. Mouse scrolling is supported as a convenience but never required — every action must have a keyboard equivalent.

### Command mode

`:` enters command mode (like vim). The command bar renders at the bottom with a green `:` prompt and a block cursor. Currently supports:

- `:q` / `:quit` — exit the application

`Esc` returns to normal mode. `Enter` executes the command.

### Screen-specific key hints

**Dashboard (Gateways focus):**
`[Tab] Switch Panel  [Enter] Select  [j/k] Navigate  │  [:] Command  [q] Quit`

**Dashboard (Providers focus, providers_v2):**
`[Tab] Switch Panel  [h/l] Switch Tab  [j/k] Navigate  [Enter] Detail  read-only  │  [:] Command  [q] Quit`

**Dashboard (Providers focus, legacy):**
`[Tab] Switch Panel  [h/l] Switch Tab  [j/k] Navigate  [Enter] Detail  [c] Create  [u] Update  [d] Delete  │  [:] Command  [q] Quit`

**Dashboard (Global Settings focus):**
`[Tab] Switch Panel  [h/l] Switch Tab  [j/k] Navigate  [Enter] Edit  [d] Delete  │  [:] Command  [q] Quit`

**Dashboard (Sandboxes focus):**
`[Tab] Switch Panel  [j/k] Navigate  [Enter] Select  [c] Create Sandbox  [w] Workspace  │  [:] Command  [q] Quit`

**Sandbox (Policy focus):**
`[h] Switch Tab  [j/k] Scroll  [g/G] Top/Bottom  [s] Shell  [l] Logs  [r] Rules  [d] Delete  │  [Esc] Back  [q] Quit`

**Sandbox (Settings focus):**
`[h/l] Switch Tab  [j/k] Navigate  [Enter] Edit  [d] Delete  │  [Esc] Back  [q] Quit`

**Sandbox (Logs focus):**
`[j/k] Navigate  [Enter] Detail  [g/G] Top/Bottom  [f] Follow  [s] Source: <filter>  [y] Copy  [Y] Copy All  [v] Select  [r] Rules  │  [Esc] Policy  [q] Quit`

**Sandbox (Draft focus):**
`[j/k] Navigate  [Enter] Detail  [a] Approve  [x] Reject  [A] Approve All  [p] Policy  [l] Logs  │  [Esc] Back  [q] Quit`

## 7. Architecture & Key Files

| File | Purpose |
| --- | --- |
| `crates/openshell-tui/Cargo.toml` | Crate manifest — dependencies on `openshell-core`, `openshell-bootstrap`, `ratatui`, `crossterm`, `tonic`, `tokio` |
| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`, `fetch_providers_v2_setting`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners |
| `crates/openshell-tui/src/app.rs` | `App` state struct, `Screen`/`Focus`/`InputMode`/`LogSourceFilter`/`MiddlePaneTab`/`SandboxPolicyTab` enums, `LogLine`/`GatewayEntry`/`GlobalSettingEntry`/`SandboxSettingEntry`/`ProviderListEntry`/`ProviderDetailView` structs, create sandbox/provider form state, all key handling logic |
| `crates/openshell-tui/src/event.rs` | `Event` enum (`Key`, `Mouse`, `Tick`, `Redraw`, `Resize`, `LogLines`, `CreateResult`, `ProviderCreateResult`, `ProviderDetailFetched`, `ProviderUpdateResult`, `ProviderDeleteResult`, `DraftActionResult`, `GlobalSettingsFetched`, `GlobalSettingSetResult`, `GlobalSettingDeleteResult`, `SandboxSettingSetResult`, `SandboxSettingDeleteResult`, `ForwardWarnings`), `EventHandler` with mpsc channels and crossterm polling |
| `crates/openshell-tui/src/theme.rs` | `colors` module (NVIDIA_GREEN, EVERGLADE, BG, FG) and `styles` module (all `Style` constants) |
| `crates/openshell-tui/src/clipboard.rs` | Clipboard copy support for log lines |
| `crates/openshell-tui/src/ui/mod.rs` | Top-level `draw()` dispatcher, `draw_title_bar` (with workspace display), `draw_nav_bar`, `draw_command_bar`, screen routing, shared setting-edit overlay, modal helpers |
| `crates/openshell-tui/src/ui/dashboard.rs` | Dashboard screen — 3-pane vertical layout: gateway list (25%) + provider/settings middle pane (25%) + sandbox table (50%) |
| `crates/openshell-tui/src/ui/providers.rs` | Provider list table with profile-aware columns: Name, Category, Type, Credentials, Workspace |
| `crates/openshell-tui/src/ui/global_settings.rs` | Global settings table: Key, Type, Value. Includes edit overlay, confirm-set, and confirm-delete popups |
| `crates/openshell-tui/src/ui/sandboxes.rs` | Reusable sandbox table widget with columns: Name, Status, Created, Age, Image, Workspace, Notes |
| `crates/openshell-tui/src/ui/sandbox_detail.rs` | Sandbox metadata view — name, status, image, created, age, providers, policy version |
| `crates/openshell-tui/src/ui/sandbox_policy.rs` | Policy viewer — rendered policy lines with scroll support, tab title |
| `crates/openshell-tui/src/ui/sandbox_settings.rs` | Sandbox settings table: Key, Type, Value, Scope. Includes edit overlay and confirm popups |
| `crates/openshell-tui/src/ui/sandbox_logs.rs` | Structured log viewer — timestamp, source, level, target, message, key=value fields, scroll position, source filter, visual selection mode, clipboard copy |
| `crates/openshell-tui/src/ui/sandbox_draft.rs` | Draft policy recommendations — chunk list, detail popup, approve/reject/approve-all flows |
| `crates/openshell-tui/src/ui/create_sandbox.rs` | Create sandbox modal form with name, image, command, providers, ports |
| `crates/openshell-tui/src/ui/create_provider.rs` | Create provider modal, provider detail popup, update provider form |
| `crates/openshell-tui/src/ui/splash.rs` | Splash/boot screen |

### Module dependency flow

```
lib.rs (event loop, gRPC, async tasks, capability fetch)
  ├── app.rs (state + key handling + tab/workspace logic)
  ├── event.rs (Event enum + EventHandler)
  ├── clipboard.rs (copy support)
  ├── theme.rs (colors + styles)
  └── ui/
        ├── mod.rs (draw dispatcher, chrome, shared overlays)
        ├── splash.rs (boot screen)
        ├── dashboard.rs (3-pane layout: gateways + middle + sandboxes)
        ├── providers.rs (provider list with profile awareness)
        ├── global_settings.rs (settings table + edit/confirm overlays)
        ├── sandboxes.rs (sandbox table widget)
        ├── sandbox_detail.rs (metadata view)
        ├── sandbox_policy.rs (policy viewer)
        ├── sandbox_settings.rs (sandbox settings table + overlays)
        ├── sandbox_logs.rs (log viewer + visual selection)
        ├── sandbox_draft.rs (draft recommendations)
        ├── create_sandbox.rs (create sandbox modal)
        └── create_provider.rs (create/detail/update provider modals)
```

## 8. Technical Notes

### Dependency constraints

- **`openshell-tui` cannot depend on `openshell-cli`** — this would create a circular dependency. TLS channel building for gateway switching is done directly in `lib.rs` using `tonic::transport` primitives (`Certificate`, `Identity`, `ClientTlsConfig`, `Endpoint`).
- Gateway authentication supports both mTLS and OIDC. `connect_to_gateway()` reads gateway metadata to determine the auth mode, then builds an `EdgeAuthInterceptor` (bearer token for OIDC, noop for mTLS).
- mTLS certs are read from `~/.config/openshell/gateways/<name>/mtls/` (ca.crt, tls.crt, tls.key).
- OIDC tokens are loaded via `openshell_bootstrap::oidc_token::load_oidc_token()` and checked for expiry.

### Proto generated code

Proto types come from `openshell-core` which generates them from `OUT_DIR` via `include!`. They are **not** checked into the repo. Import paths look like:

```rust
use openshell_core::proto::openshell_client::OpenShellClient;
use openshell_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...};
```

### Proto field gotchas

- `DeleteSandboxRequest` uses the `name` field (not `id`):
  ```rust
  let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name };
  ```
- `WatchSandboxRequest` has extra fields beyond what you might need — always use `..Default::default()`:
  ```rust
  let req = openshell_core::proto::WatchSandboxRequest {
      id: sandbox_id,
      follow_status: false,
      follow_logs: true,
      follow_events: false,
      log_tail_lines: 0,
      ..Default::default()
  };
  ```
- `SandboxLogLine` proto fields: `sandbox_id`, `timestamp_ms`, `level`, `target`, `message`, `source`, `fields` (HashMap<String, String>).
- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), `sources` (Vec<String>), `min_level` (String), `workspace` (String).
- `ListSandboxesRequest` fields: `limit` (i64), `offset` (i64), `label_selector` (String), `workspace` (String), `all_workspaces` (bool).
- `ListProvidersRequest` fields: `limit` (i64), `offset` (i64), `workspace` (String), `all_workspaces` (bool).
- `ListWorkspacesRequest` fields: `limit` (i64), `offset` (i64), `label_selector` (String).
- `UpdateConfigRequest` fields: `name` (String, sandbox name or empty for global), `setting_key`, `setting_value`, `delete_setting` (bool), `global` (bool), `workspace`.
- Most resource requests include a `workspace` field that scopes the operation to the current workspace.

### gRPC timeouts

All gRPC calls use a 5-second timeout:

```rust
tokio::time::timeout(Duration::from_secs(5), client.health(req)).await
```

The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at 10-second intervals.

### Log streaming lifecycle

1. User presses `[l]` on sandbox detail → `pending_log_fetch = true`
2. Event loop sees the flag → calls `spawn_log_stream()`
3. Previous stream handle is aborted via `cancel_log_stream()`
4. New `tokio::spawn` task: fetches initial 500 lines, then streams via `WatchSandbox`
5. Lines arrive as `Event::LogLines` and are appended to `app.sandbox_log_lines`
6. Auto-scroll kicks in if the user is near the bottom (within 5 lines)
7. Stream is cancelled when user presses `Esc` or navigates away (handle is `.abort()`ed)

### Gateway switching lifecycle

1. User selects a different gateway and presses `Enter` → `pending_gateway_switch = Some(name)`
2. Event loop calls `handle_gateway_switch()`
3. New channel is built via `connect_to_gateway()` (mTLS or OIDC depending on gateway metadata)
4. On success:
   - `app.client` is replaced with a new intercepted client
   - `reset_sandbox_state()` clears all sandbox/log/draft/policy data
   - `fetch_providers_v2_setting()` probes the new gateway's `GetGatewayConfig` to determine whether providers_v2 mode is enabled, so provider CRUD controls render correctly
   - `refresh_data()` runs the full capability refresh sequence: `refresh_health` → `refresh_global_settings` → `refresh_workspaces` → `refresh_providers` → `refresh_sandboxes`
5. On failure: `status_text` shows the error

### Initial startup lifecycle

On launch, before the event loop starts:

1. `fetch_providers_v2_setting()` — probe gateway capability
2. `refresh_gateway_list()` — discover gateways from disk
3. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes)

### Workspace switching lifecycle

1. User presses `[w]` on the sandboxes panel → `cycle_workspace()` advances through discovered workspace names, then "all"
2. `pending_workspace_refresh = true` is set, cursor indices are reset
3. Event loop calls `refresh_providers()` and `refresh_sandboxes()` with the new workspace scope

### Settings CRUD lifecycle (global and sandbox)

1. User presses `[Enter]` on a setting → edit overlay opens (bool types toggle inline and jump to confirmation)
2. Text input with validation (int, bool, string with allowed-values check)
3. `[Enter]` opens a confirmation popup → `[y]` fires the pending flag
4. Event loop spawns `spawn_set_global_setting()` or `spawn_set_sandbox_setting()` → `UpdateConfig` RPC
5. On success: re-fetches settings to reflect the change
6. `[d]` on a setting with a value → confirmation popup → `spawn_delete_*_setting()` → `UpdateConfig` with `delete_setting: true`

For sandbox settings, globally-managed entries (scope = global) are blocked from editing or deletion at the sandbox level.

## 9. Development Workflow

### Build and run

```bash
# Build the crate
cargo build -p openshell-tui

# Run the TUI against the active gateway
mise run term

# Run with cargo-watch for hot-reload during development
mise run term:dev

# Format
cargo fmt -p openshell-tui

# Lint
cargo clippy -p openshell-tui
```

### Pre-commit

Always run before committing:

```bash
mise run pre-commit
```

### Gateway changes

If you change sandbox or server code that affects the backend, restart or redeploy the gateway for the compute platform you are using.

For Docker-backed local development:

```bash
mise run gateway:docker
```

For Kubernetes Helm deployments:

```bash
helm upgrade --install openshell deploy/helm/openshell --namespace openshell
```

For Kubernetes, pick up new sandbox images after changing sandbox code by deleting the pod manually so it gets recreated:

```bash
kubectl delete pod <pod-name> -n <namespace>
```

### Adding a new gRPC call

1. Check the proto definitions in `openshell-core` for available RPCs and message types.
2. Add the call in `lib.rs` following the existing pattern (timeout wrapper, error handling, state update).
3. If the call is triggered by a key press, add a `pending_*` flag to `App` and handle it in the event loop.
4. If the call returns streaming data, spawn it as a background task and send results via `Event` variants.

### Adding a new Event variant

1. Add the variant to `Event` in `event.rs`.
2. Handle it in the `match events.next().await` block in `lib.rs`.
3. Update `App` state as needed from the event data.
