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.
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:
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:
Create a new module under src/ui/ with a pub fn draw(frame, app, area).
Add the module declaration in ui/mod.rs.
Add a match arm in ui::draw() to dispatch to the new module.
Add relevant Focus variants if the screen has multiple panels.
Add key handling methods in App for the new focus states.
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:
// Background task sends data back via mpsc channellethandle = tokio::spawn(asyncmove {
letresult = 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):
// In lib.rsspawn_log_stream(&mut app, events.sender());
// In the spawned tasklet_ = 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:
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.
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
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:
use openshell_core::proto::openshell_client::OpenShellClient;
use openshell_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...};
Proto field gotchas
DeleteSandboxRequest uses the name field (not id):
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.
The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at 10-second intervals.
Log streaming lifecycle
User presses [l] on sandbox detail → pending_log_fetch = true
Event loop sees the flag → calls spawn_log_stream()
Previous stream handle is aborted via cancel_log_stream()
New tokio::spawn task: fetches initial 500 lines, then streams via WatchSandbox
Lines arrive as Event::LogLines and are appended to app.sandbox_log_lines
Auto-scroll kicks in if the user is near the bottom (within 5 lines)
Stream is cancelled when user presses Esc or navigates away (handle is .abort()ed)
Gateway switching lifecycle
User selects a different gateway and presses Enter → pending_gateway_switch = Some(name)
Event loop calls handle_gateway_switch()
New channel is built via connect_to_gateway() (mTLS or OIDC depending on gateway metadata)
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
refresh_gateway_list() — discover gateways from disk
refresh_data() — full refresh (health, global settings, workspaces, providers, sandboxes)
Workspace switching lifecycle
User presses [w] on the sandboxes panel → cycle_workspace() advances through discovered workspace names, then "all"
pending_workspace_refresh = true is set, cursor indices are reset
Event loop calls refresh_providers() and refresh_sandboxes() with the new workspace scope
Settings CRUD lifecycle (global and sandbox)
User presses [Enter] on a setting → edit overlay opens (bool types toggle inline and jump to confirmation)
Text input with validation (int, bool, string with allowed-values check)
[Enter] opens a confirmation popup → [y] fires the pending flag
Event loop spawns spawn_set_global_setting() or spawn_set_sandbox_setting() → UpdateConfig RPC
On success: re-fetches settings to reflect the change
[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
# 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:
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.