tui-development

от nvidia

Guide for developing the OpenShell TUI — a ratatui-based terminal UI for the OpenShell platform. Covers architecture, navigation, data fetching, theming, UX…

npx skills add https://github.com/nvidia/openshell --skill tui-development

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

Gateway (discovered via openshell_bootstrap::list_gateways())
  └── Sandboxes (fetched via gRPC ListSandboxes)
        └── 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, and local/remote flag.
  • Sandboxes belong to the active gateway. Fetched via ListSandboxes gRPC call with a periodic tick refresh. Each sandbox has: id, name, phase, created_at_ms, and spec.template.image.
  • 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> (<status>) │ <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.

ScreenDescriptionModule
DashboardGateway list (top) + sandbox table (bottom)ui/dashboard.rs
SandboxSingle-sandbox view — detail or logs depending on Focusui/sandbox_detail.rs, ui/sandbox_logs.rs

Focus (Focus enum)

Tracks which panel currently receives keyboard input.

FocusScreenDescription
GatewaysDashboardGateway list panel has input focus
SandboxesDashboardSandbox table panel has input focus
SandboxDetailSandboxSandbox detail view (name, status, image, age)
SandboxLogsSandboxLog viewer with structured rendering

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::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),
}

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 │ Current Gateway: openshell (Healthy) │ Dashboard
  • Sandbox detail: OpenShell │ Current Gateway: openshell (Healthy) │ 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: Currently fetched via ListSandboxes on a 2-second tick. Could be enhanced with a watch mechanism.

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:

// In lib.rs
spawn_log_stream(&mut app, events.sender());

// In the spawned task
let _ = tx.send(Event::LogLines(lines));

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):

  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)

ConstantValueUsage
NVIDIA_GREENColor::Rgb(118, 185, 0)Primary accent (dark theme)
NVIDIA_GREEN_DARKColor::Rgb(80, 140, 0)Primary accent (light theme — darker for contrast)
EVERGLADEColor::Rgb(18, 49, 35)Dark green — borders, title bar bg (dark theme)
MAROONColor::Rgb(128, 0, 0)Pacman chase animation

Theme struct fields

The Theme struct has 16 Style fields, accessed at runtime via app.theme:

FieldDark valueLight valueUsage
textWhite fgNear-black fgDefault body text
mutedWhite + DIMGray fgSecondary info, separators
headingWhite + BOLDNear-black + BOLDPanel titles, names
accentNVIDIA_GREEN fgNVIDIA_GREEN_DARK fgSelected row marker, source labels
accent_boldNVIDIA_GREEN + BOLDNVIDIA_GREEN_DARK + BOLDBrand text, command prompt
selectedBOLD onlyBOLD onlySelected row emphasis
borderEVERGLADE fgLight sage fgUnfocused panel borders
border_focusedNVIDIA_GREEN fgNVIDIA_GREEN_DARK fgFocused panel borders
status_okNVIDIA_GREEN fgNVIDIA_GREEN_DARK fgHealthy, INFO, Ready
status_warnYellow fgDark yellow fgDegraded, WARN, Provisioning
status_errRed fgDark red fgUnhealthy, ERROR
key_hintNVIDIA_GREEN fgNVIDIA_GREEN_DARK fgKeyboard shortcut labels
log_cursorEVERGLADE bgLight green bgSelected log line highlight
clawMAROON + BOLDMAROON + BOLDPacman animation
title_barWhite on EVERGLADE + BOLDNear-black on light green + BOLDTitle bar strip
badgeBlack on NVIDIA_GREEN + BOLDWhite on NVIDIA_GREEN_DARK + BOLDNotification 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 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 CommandTUI Equivalent
openshell sandbox listSandbox 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 statusStatus 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 supportedScrollUp/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

KeyAction
j / DownMove selection down
k / UpMove selection up
gJump to top (logs), disables autoscroll
GJump to bottom (logs), re-enables autoscroll
fFollow / re-enable autoscroll (logs)
Tab / BackTabSwitch between panels on Dashboard
EnterSelect / drill into item; open detail popup in logs
EscGo back one level
qQuit (from any screen)
Ctrl+CForce 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 (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

7. Architecture & Key Files

FilePurpose
crates/openshell-tui/Cargo.tomlCrate manifest — dependencies on openshell-core, openshell-bootstrap, ratatui, crossterm, tonic, tokio
crates/openshell-tui/src/lib.rsEntry 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.rsApp state struct, Screen/Focus/InputMode/LogSourceFilter enums, LogLine struct, GatewayEntry, all key handling logic
crates/openshell-tui/src/event.rsEvent enum (Key, Mouse, Tick, Resize, LogLines), EventHandler with mpsc channels and crossterm polling
crates/openshell-tui/src/theme.rscolors module (NVIDIA_GREEN, EVERGLADE, BG, FG) and styles module (all Style constants)
crates/openshell-tui/src/ui/mod.rsTop-level draw() dispatcher, draw_title_bar, draw_nav_bar, draw_command_bar, screen routing
crates/openshell-tui/src/ui/dashboard.rsDashboard screen — gateway list table (top) + sandbox table (bottom)
crates/openshell-tui/src/ui/sandboxes.rsReusable sandbox table widget with columns: Name, Status, Created, Age, Image
crates/openshell-tui/src/ui/sandbox_detail.rsSandbox detail view — name, status, image, created, age, delete confirmation dialog
crates/openshell-tui/src/ui/sandbox_logs.rsStructured log viewer — timestamp, source, level, target, message, key=value fields, scroll position, source filter

Module dependency flow

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)

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).
  • mTLS certs are read from ~/.config/openshell/gateways/<name>/mtls/ (ca.crt, tls.crt, tls.key).

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

gRPC timeouts

All gRPC calls use a 5-second timeout:

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 Enterpending_gateway_switch = Some(name)
  2. Event loop calls handle_gateway_switch()
  3. New mTLS channel is built via connect_to_gateway()
  4. On success: app.client is replaced, reset_sandbox_state() clears all sandbox data, refresh_data() fetches health + sandboxes for the new gateway
  5. On failure: status_text shows the error

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.

For Docker-backed local development:

mise run gateway:docker

For Kubernetes Helm deployments:

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:

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.

Больше skills от nvidia

compileiq-debug
nvidia
Use when something is wrong: Search() hangs, all evaluations return INVALID_SCORE, scores aren't improving, every config returns the same number, ptxas errors…
official
create-github-pr
nvidia
Create GitHub pull requests using the gh CLI. Use when the user wants to create a new PR, submit code for review, or open a pull request. Trigger keywords -…
official
diagnose-perf
nvidia
First-responder performance triage for Isaac Sim and Isaac Lab. Identifies bottleneck category (GPU-bound, CPU-bound, VRAM, loading) using nvidia-smi and…
official
eagle3-review-logs
nvidia
Review EAGLE3 pipeline experiment logs from the launcher's experiments/ directory. Summarizes pass/fail status for all 4 tasks, diagnoses failures with root…
official
nemoclaw-maintainer-cross-issue-sweep
nvidia
Сканирует другие открытые задачи, чтобы найти те, которые данный PR может исправить или случайно сломать. Выводит возможности смежных исправлений и риски противоречий с указанием файла:строки…
official
karpathy-guidelines
nvidia
Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes,…
official
fhir-basics
nvidia
Обучает агентов работе с API FHIR R4, доступным ресурсам, запросам с параметрами поиска и корректному разбору всех форматов ответов…
official
underdeclared-agent
nvidia
A helpful assistant agent
official