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.
76
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Comprehensive reference for any agent working on the OpenShell TUI.
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.
openshell term or mise run termcrates/openshell-tui/ratatui (workspace version) — uses frame.size() (not frame.area())crossterm (workspace version) — terminal backend and event pollingtonic with 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 struct — NVIDIA-branded green accents. Controlled by --theme flag, OPENSHELL_THEME env var, or auto-detection.The data model follows a strict hierarchy: Gateway > Sandboxes > Logs.
Gateway (discovered via openshell_bootstrap::list_gateways())
└── Sandboxes (fetched via gRPC ListSandboxes)
└── Logs (fetched via GetSandboxLogs + streamed via WatchSandbox)openshell_bootstrap::list_gateways(). Each gateway has a name, endpoint, and local/remote flag.ListSandboxes gRPC call with a periodic tick refresh. Each sandbox has: id, name, phase, created_at_ms, and spec.template.image.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> (<status>) │ <screen/context>Screen enum)Top-level layouts that own the full content area. Each has its own nav bar hints.
| Screen | Description | Module |
|---|---|---|
Dashboard | Gateway list (top) + sandbox table (bottom) | ui/dashboard.rs |
Sandbox | Single-sandbox view — detail or logs depending on Focus | ui/sandbox_detail.rs, ui/sandbox_logs.rs |
Focus enum)Tracks which panel currently receives keyboard input.
| Focus | Screen | Description |
|---|---|---|
Gateways | Dashboard | Gateway list panel has input focus |
Sandboxes | Dashboard | Sandbox table panel has input focus |
SandboxDetail | Sandbox | Sandbox detail view (name, status, image, age) |
SandboxLogs | Sandbox | Log viewer with structured rendering |
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::Dashboard => dashboard::draw(frame, app, chunks[1]),
Screen::Sandbox => draw_sandbox_screen(frame, app, chunks[1]),
}Within the Sandbox screen, focus determines which sub-view renders:
match app.focus {
Focus::SandboxLogs => sandbox_logs::draw(frame, app, area),
_ => sandbox_detail::draw(frame, app, area),
}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 │
└─────────────────────────────────────────────┘ OpenShell │ Current Gateway: openshell (Healthy) │ Dashboard OpenShell │ Current Gateway: openshell (Healthy) │ Sandbox: my-sandboxScreen in app.rs.src/ui/ with a pub fn draw(frame, app, area).ui/mod.rs.ui::draw() to dispatch to the new module.Focus variants if the screen has multiple panels.App for the new focus states.draw_nav_bar() for the new screen/focus combinations.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::LogLinesSandboxes: Currently fetched via ListSandboxes on a 2-second tick. Could be enhanced with a watch mechanism.
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));
});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).
Background tasks communicate with the event loop via mpsc::UnboundedSender<Event>. The EventHandler provides a sender() method to clone the transmit handle:
// In lib.rs
spawn_log_stream(&mut app, events.sender());
// In the spawned task
let _ = tx.send(Event::LogLines(lines));All gRPC calls use a 5-second timeout via tokio::time::timeout:
tokio::time::timeout(Duration::from_secs(5), client.health(req)).awaittheme.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 mode is controlled by three mechanisms (highest priority first):
--theme dark|light|auto CLI flag on openshell termOPENSHELL_THEME environment variableCOLORFGBG env var (falls back to dark)The ThemeMode enum (Auto, Dark, Light) is resolved at startup via theme::detect() before entering raw mode.
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 |
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 |
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;
// ...
}▌ left-border marker on the selected row. Active gateway also gets a green ● dot.border to border_focused style.│ characters between title bar segments and nav bar sections."sandbox" source renders in accent (green), "gateway" in muted.Always show a y/n confirm dialog before delete, stop, or other irreversible operations.
Delete sandbox 'my-sandbox'? [y] Confirm [Esc] CancelThe confirm_delete flag in App gates destructive key handling — while true, only y, n, and Esc are processed.
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 logs <name> | [l] on sandbox detail to open log viewer |
openshell status | Status in title bar + gateway list |
When adding new TUI features, check what the CLI offers and maintain consistency.
Any scrollable content (logs, future long lists) should follow the k9s autoscroll pattern:
f or G re-enables — jump to bottom and resume following● FOLLOWING (green) or ○ PAUSED (yellow) in the panel footerScrollUp/ScrollDown events move by 3 lines and respect autoscroll state[current/total] in the panel footerState is tracked via log_autoscroll: bool on App. The scroll_logs(delta) method handles both keyboard and mouse input uniformly.
When content can exceed the viewport width (log lines, field lists, etc.):
…. This keeps density high and avoids wrapping that breaks the 1-line-per-entry model.Esc or Enter closes it. Track the open state via Option<usize> index.This pattern should be reused for any future view with potentially long entries.
| 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 |
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.
: 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 applicationEsc returns to normal mode. Enter executes the command.
Dashboard (Gateways focus):
[Tab] Switch Panel [Enter] Select [j/k] Navigate │ [:] Command [q] Quit
Dashboard (Sandboxes focus): Same as above.
Sandbox (Detail focus):
[l] Logs [d] Delete │ [Esc] Back to Dashboard [q] Quit
Sandbox (Logs focus):
[j/k] Scroll [Enter] Detail [g/G] Top/Bottom [f] Follow [s] Source: <filter> │ [Esc] Back [q] Quit
| 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_health, refresh_sandboxes, spawn_log_stream, handle_sandbox_delete), gateway switching, mTLS channel building |
crates/openshell-tui/src/app.rs | App state struct, Screen/Focus/InputMode/LogSourceFilter enums, LogLine struct, GatewayEntry, all key handling logic |
crates/openshell-tui/src/event.rs | Event enum (Key, Mouse, Tick, Resize, LogLines), 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/ui/mod.rs | Top-level draw() dispatcher, draw_title_bar, draw_nav_bar, draw_command_bar, screen routing |
crates/openshell-tui/src/ui/dashboard.rs | Dashboard screen — gateway list table (top) + sandbox table (bottom) |
crates/openshell-tui/src/ui/sandboxes.rs | Reusable sandbox table widget with columns: Name, Status, Created, Age, Image |
crates/openshell-tui/src/ui/sandbox_detail.rs | Sandbox detail view — name, status, image, created, age, delete confirmation dialog |
crates/openshell-tui/src/ui/sandbox_logs.rs | Structured log viewer — timestamp, source, level, target, message, key=value fields, scroll position, source filter |
lib.rs (event loop, gRPC, async tasks)
├── app.rs (state + key handling)
├── event.rs (Event enum + EventHandler)
├── theme.rs (colors + styles)
└── ui/
├── mod.rs (draw dispatcher, chrome)
├── dashboard.rs (gateway list + sandbox table layout)
├── sandboxes.rs (sandbox table widget)
├── sandbox_detail.rs (detail view)
└── sandbox_logs.rs (log viewer)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).~/.config/openshell/gateways/<name>/mtls/ (ca.crt, tls.crt, tls.key).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, ...};DeleteSandboxRequest uses the name field (not id):
let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name };WatchSandboxRequest has extra fields beyond what you might need — always use ..Default::default():
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), min_level (String).ListSandboxesRequest fields: limit (i64), offset (i64).All gRPC calls use a 5-second timeout:
tokio::time::timeout(Duration::from_secs(5), client.health(req)).awaitThe connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at 10-second intervals.
[l] on sandbox detail → pending_log_fetch = truespawn_log_stream()cancel_log_stream()tokio::spawn task: fetches initial 500 lines, then streams via WatchSandboxEvent::LogLines and are appended to app.sandbox_log_linesEsc or navigates away (handle is .abort()ed)Enter → pending_gateway_switch = Some(name)handle_gateway_switch()connect_to_gateway()app.client is replaced, reset_sandbox_state() clears all sandbox data, refresh_data() fetches health + sandboxes for the new gatewaystatus_text shows the error# 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-tuiAlways run before committing:
mise run pre-commitIf 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:
mise run gateway:dockerFor Kubernetes Helm deployments:
helm upgrade --install openshell deploy/helm/openshell --namespace openshellFor Kubernetes, pick up new sandbox images after changing sandbox code by deleting the pod manually so it gets recreated:
kubectl delete pod <pod-name> -n <namespace>openshell-core for available RPCs and message types.lib.rs following the existing pattern (timeout wrapper, error handling, state update).pending_* flag to App and handle it in the event loop.Event variants.Event in event.rs.match events.next().await block in lib.rs.App state as needed from the event data.deced87
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.