OpenShell TUI Development Guide
SkillDev toolsGuides your agent through building and fixing the OpenShell terminal UI, covering its architecture, navigation, theming, and workflow.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the OpenShell TUI Development Guide skill
About this capability
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
What this skill tells your AI
The instructions your AI receives, as published by nvidia/openshell in .agents/skills/tui-development/SKILL.md and read by ahel’s review.
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 termormise run term - Crate:
crates/openshell-tui/ - Key dependencies:
ratatui(workspace version) — usesframe.size()(notframe.area())crossterm(workspace version) — terminal backend and event pollingtonicwith TLS — gRPC client for the OpenShell gatewaytokio— async runtime for event loop, spawned tasks, and mpsc channelsopenshell-core— proto-generated types (OpenShellClient, request/response structs)openshell-bootstrap— gateway discovery (list_gateways())
- Theme: Adaptive dark/light via
Themestruct — NVIDIA-branded green accents. Controlled by--themeflag,OPENSHELL_THEMEenv 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 aProviderProfileCachekeyed 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
ListProvidersscoped to the current workspace. EachProviderListEntrypairs a provider with its optional cached profile. The TUI supports profile-backed create, update, and delete operations. - Global Settings are fetched via
GetGatewayConfigand 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;PermissionDenieddisables the pane. - Sandboxes belong to the active gateway and workspace. Fetched via
ListSandboxeswith 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 viaWatchSandboxwithfollow_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:
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:
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:
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
- Add a variant to
Screeninapp.rs. - Create a new module under
src/ui/with apub 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
Focusvariants if the screen has multiple panels. - Add key handling methods in
Appfor 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 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:
// 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):
// 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:
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):
--theme dark|light|autoCLI flag onopenshell termOPENSHELL_THEMEenvironment variable- Auto-detection via
COLORFGBGenv 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:
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:
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
bordertoborder_focusedstyle. - 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 inaccent(green),"gateway"inmuted.
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 |
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
forGre-enables — jump to bottom and resume following- Visual indicator — show
● FOLLOWING(green) or○ PAUSED(yellow) in the panel footer - Mouse scroll supported —
ScrollUp/ScrollDownevents 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.
EscorEntercloses it. Track the open state viaOption<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):
[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete [w] Workspace │ [:] 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, background collection refresh (spawn_list_refresh), gRPC calls (refresh_global_settings, spawn_log_stream, handle_sandbox_delete), 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, ListRefreshCompleted, 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
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 9k
- Forks
- 1k
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
tui-development- Source
- github.com/nvidia/openshell