Skip to content

Commit 9706cb6

Browse files
committed
fix(tui): separate configuration summaries from full diagnostics
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent a1f2358 commit 9706cb6

9 files changed

Lines changed: 122 additions & 40 deletions

File tree

‎.agents/skills/tui-development/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Phase 1: GetSandboxLogs → 500 initial lines → send via Event::LogLines
170170
Phase 2: WatchSandbox(follow_logs: true) → live tail → send via Event::LogLines
171171
```
172172

173-
**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 puts active `ConfigurationInvalid` readiness diagnostics before port forwards and clears them on refresh after repair. Timed-out provisioning attempts show `Provisioning timed out` with cleanup pending or compute reclaimed, preserving port forwards. The sandbox detail pane uses the same Notes field.
173+
**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 `inspect for 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.
174174

175175
**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.
176176

‎architecture/sandbox.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,10 @@ default.
630630
The gateway tracks configuration admission independently of compute health.
631631
A blocked startup remains `Provisioning` with a `ConfigurationInvalid` readiness
632632
condition, even when the container backend reports readiness. Gateway management
633-
operations remain available. The TUI exposes configuration rejection conditions
634-
in sandbox NOTES alongside active port forwards. Replacing the policy or repairing providers allows
633+
operations remain available. The TUI summarizes configuration rejection in sandbox
634+
NOTES alongside active port forwards; the detail view wraps the full diagnostic,
635+
which is also available through sandbox inspection.
636+
Replacing the policy or repairing providers allows
635637
the same supervisor to reconcile and launch; it does not recreate the sandbox.
636638
Startup retries continue reporting readiness, but unchanged configuration rejections
637639
produce only one log event. A changed configuration or diagnostic emits a new

‎crates/openshell-tui/src/app.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ pub struct App {
657657
pub sandbox_created: Vec<String>,
658658
pub sandbox_images: Vec<String>,
659659
pub sandbox_notes: Vec<String>,
660+
pub sandbox_detail_notes: Vec<String>,
660661
/// Formatted labels for each sandbox (e.g., "env=prod,team=platform" or empty string).
661662
pub sandbox_labels: Vec<String>,
662663
/// Formatted annotations for each sandbox (e.g., "policy-signature=abc" or empty string).
@@ -1019,6 +1020,7 @@ impl App {
10191020
sandbox_created: Vec::new(),
10201021
sandbox_images: Vec::new(),
10211022
sandbox_notes: Vec::new(),
1023+
sandbox_detail_notes: Vec::new(),
10221024
sandbox_labels: Vec::new(),
10231025
sandbox_annotations: Vec::new(),
10241026
sandbox_workspaces: Vec::new(),
@@ -3528,6 +3530,7 @@ impl App {
35283530
self.sandbox_created.clear();
35293531
self.sandbox_images.clear();
35303532
self.sandbox_notes.clear();
3533+
self.sandbox_detail_notes.clear();
35313534
self.sandbox_labels.clear();
35323535
self.sandbox_annotations.clear();
35333536
self.sandbox_policy_versions.clear();

‎crates/openshell-tui/src/lib.rs‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2732,6 +2732,14 @@ async fn fetch_sandboxes(
27322732
}
27332733

27342734
fn sandbox_notes(sandbox: &openshell_core::proto::Sandbox, forwards: String) -> String {
2735+
sandbox_notes_for_view(sandbox, forwards, false)
2736+
}
2737+
2738+
fn sandbox_notes_for_view(
2739+
sandbox: &openshell_core::proto::Sandbox,
2740+
forwards: String,
2741+
detail: bool,
2742+
) -> String {
27352743
if let Some(record) = sandbox
27362744
.status
27372745
.as_ref()
@@ -2760,17 +2768,18 @@ fn sandbox_notes(sandbox: &openshell_core::proto::Sandbox, forwards: String) ->
27602768
let Some(rejection) = rejection else {
27612769
return forwards;
27622770
};
2763-
// Keep the table row on one line even when a diagnostic contains newlines.
2764-
let message = rejection
2765-
.message
2766-
.split_whitespace()
2767-
.collect::<Vec<_>>()
2768-
.join(" ");
2769-
let mut notes = "Config invalid".to_string();
2770-
if !message.is_empty() {
2771-
notes.push_str(": ");
2772-
notes.push_str(&message);
2773-
}
2771+
let mut notes = if detail {
2772+
format!(
2773+
"Invalid config: {}",
2774+
rejection
2775+
.message
2776+
.split_whitespace()
2777+
.collect::<Vec<_>>()
2778+
.join(" ")
2779+
)
2780+
} else {
2781+
"inspect for config".to_string()
2782+
};
27742783
if !forwards.is_empty() {
27752784
notes.push_str("; ");
27762785
notes.push_str(&forwards);
@@ -2838,6 +2847,14 @@ fn apply_sandbox_refresh(app: &mut App, sandboxes: Vec<openshell_core::proto::Sa
28382847
})
28392848
.collect();
28402849

2850+
app.sandbox_detail_notes = sandboxes
2851+
.iter()
2852+
.map(|s| {
2853+
let forwards = openshell_core::forward::build_sandbox_notes(s.object_name(), &forwards);
2854+
sandbox_notes_for_view(s, forwards, true)
2855+
})
2856+
.collect();
2857+
28412858
// Build LABELS column from metadata.
28422859
app.sandbox_labels = sandboxes
28432860
.iter()
@@ -3330,18 +3347,19 @@ mod sandbox_notes_tests {
33303347
};
33313348
assert_eq!(
33323349
sandbox_notes(&sandbox, "fwd:8080".into()),
3333-
"Config invalid: credentialed endpoint requires L7 inspection; fwd:8080"
3350+
"inspect for config; fwd:8080"
33343351
);
3335-
// Older gateways can expose only Ready; retain the diagnostic there too.
3336-
sandbox.status.as_mut().unwrap().conditions.remove(0);
33373352
assert_eq!(
3338-
sandbox_notes(&sandbox, String::new()),
3339-
"Config invalid: credentialed endpoint requires L7 inspection"
3353+
super::sandbox_notes_for_view(&sandbox, "fwd:8080".into(), true),
3354+
"Invalid config: credentialed endpoint requires L7 inspection; fwd:8080"
33403355
);
3356+
// Older gateways can expose only Ready; retain the note there too.
3357+
sandbox.status.as_mut().unwrap().conditions.remove(0);
3358+
assert_eq!(sandbox_notes(&sandbox, String::new()), "inspect for config");
33413359
sandbox.status.as_mut().unwrap().conditions[0]
33423360
.message
33433361
.clear();
3344-
assert_eq!(sandbox_notes(&sandbox, String::new()), "Config invalid");
3362+
assert_eq!(sandbox_notes(&sandbox, String::new()), "inspect for config");
33453363
sandbox.status.as_mut().unwrap().conditions[0].status = "True".into();
33463364
assert_eq!(sandbox_notes(&sandbox, "fwd:8080".into()), "fwd:8080");
33473365
sandbox.status = None;

‎crates/openshell-tui/src/ui/mod.rs‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ fn draw_sandbox_screen(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
7474
let chunks = Layout::default()
7575
.direction(Direction::Vertical)
7676
.constraints([
77-
Constraint::Length(sandbox_detail::required_height(app)), // metadata
78-
Constraint::Min(0), // policy or logs
77+
Constraint::Length(sandbox_detail::required_height(app, area.width)), // metadata
78+
Constraint::Min(0), // policy or logs
7979
])
8080
.split(area);
8181

@@ -738,6 +738,55 @@ mod tests {
738738
assert!(!rendered.contains("ALPHA"));
739739
}
740740

741+
#[tokio::test]
742+
async fn configuration_notes_fit_dashboard_and_wrap_in_detail() {
743+
let mut app = test_app();
744+
app.sandbox_names = vec!["quarantined".into()];
745+
app.sandbox_count = 1;
746+
app.sandbox_notes = vec!["inspect for config".into()];
747+
let diagnostic = "Invalid config: credentialed endpoint api.example.com:443 requires L7 inspection before the sandbox workload can start safely";
748+
app.sandbox_detail_notes = vec![diagnostic.into()];
749+
for width in [80, 100, 120] {
750+
for all_workspaces in [false, true] {
751+
app.all_workspaces = all_workspaces;
752+
let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
753+
terminal
754+
.draw(|frame| {
755+
sandboxes::draw(frame, &app, frame.size(), true);
756+
})
757+
.unwrap();
758+
let text: String = terminal
759+
.backend()
760+
.buffer()
761+
.content()
762+
.iter()
763+
.map(ratatui::buffer::Cell::symbol)
764+
.collect();
765+
assert!(
766+
text.contains("inspect for config"),
767+
"dashboard at {width}: {text}"
768+
);
769+
assert!(!text.contains("credentialed endpoint"));
770+
}
771+
app.screen = Screen::Sandbox;
772+
let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
773+
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
774+
let text: String = terminal
775+
.backend()
776+
.buffer()
777+
.content()
778+
.iter()
779+
.map(ratatui::buffer::Cell::symbol)
780+
.collect();
781+
let words = text
782+
.replace('│', " ")
783+
.split_whitespace()
784+
.collect::<Vec<_>>()
785+
.join(" ");
786+
assert!(words.contains(diagnostic), "detail at {width}: {words}");
787+
}
788+
}
789+
741790
#[tokio::test]
742791
async fn sandbox_delete_confirmation_is_visible_in_standard_terminal() {
743792
let mut app = test_app();

‎crates/openshell-tui/src/ui/sandbox_detail.rs‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,32 @@ fn pending_draft_count(app: &App) -> usize {
2727
}
2828
}
2929

30+
fn note_lines(app: &App, width: u16) -> Vec<String> {
31+
let notes = app
32+
.sandbox_detail_notes
33+
.get(app.sandbox_selected)
34+
.filter(|s| !s.is_empty())
35+
.map_or("none", String::as_str);
36+
super::sandbox_draft::wrap_value(
37+
&format!(" Notes: {notes}"),
38+
usize::from(width.saturating_sub(4).max(1)),
39+
)
40+
}
41+
3042
/// Return the rows needed to render every metadata line without clipping.
31-
pub(super) fn required_height(app: &App) -> u16 {
43+
pub(super) fn required_height(app: &App, width: u16) -> u16 {
3244
let policy_rows = u16::from(app.sandbox_policy_is_global);
3345
let action_rows = if app.confirm_delete {
3446
2 // spacer plus confirmation
3547
} else {
3648
u16::from(pending_draft_count(app) > 0)
3749
};
3850

39-
BASE_CONTENT_ROWS + policy_rows + action_rows + BORDER_ROWS
51+
BASE_CONTENT_ROWS
52+
+ policy_rows
53+
+ action_rows
54+
+ BORDER_ROWS
55+
+ u16::try_from(note_lines(app, width).len().saturating_sub(1)).unwrap_or(u16::MAX - 16)
4056
}
4157

4258
/// Draw a compact metadata pane for the currently selected sandbox.
@@ -130,18 +146,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) {
130146
Span::styled(providers_str, t.text),
131147
]);
132148

133-
// Row 6: Configuration diagnostics and forwarded ports
134-
let notes_str = app
135-
.sandbox_notes
136-
.get(idx)
137-
.filter(|s| !s.is_empty())
138-
.map_or("none", String::as_str);
139-
let row6 = Line::from(vec![
140-
Span::styled(" Notes: ", t.muted),
141-
Span::styled(notes_str, t.text),
142-
]);
143-
144-
let mut lines = vec![row1, row2, row3, row4, row5, row6];
149+
let mut lines = vec![row1, row2, row3, row4, row5];
150+
lines.extend(
151+
note_lines(app, area.width)
152+
.into_iter()
153+
.map(|line| Line::from(Span::styled(line, t.text))),
154+
);
145155

146156
// Show global policy indicator when the sandbox's policy is managed at
147157
// gateway scope.

‎crates/openshell-tui/src/ui/sandbox_draft.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ fn display_width(text: &str) -> usize {
574574
///
575575
/// A word wider than `width` is hard-broken rather than allowed to overflow.
576576
/// Always returns at least one row so callers can index the first row safely.
577-
fn wrap_value(text: &str, width: usize) -> Vec<String> {
577+
pub(super) fn wrap_value(text: &str, width: usize) -> Vec<String> {
578578
if width == 0 {
579579
return vec![text.to_string()];
580580
}

‎crates/openshell-tui/src/ui/sandboxes.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) {
9494
Constraint::Percentage(7),
9595
Constraint::Percentage(18),
9696
Constraint::Percentage(15),
97-
Constraint::Percentage(12),
97+
Constraint::Length(18),
9898
]
9999
} else {
100100
vec![
@@ -104,7 +104,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) {
104104
Constraint::Percentage(8),
105105
Constraint::Percentage(20),
106106
Constraint::Percentage(15),
107-
Constraint::Percentage(12),
107+
Constraint::Length(18),
108108
]
109109
};
110110

‎docs/sandboxes/manage-sandboxes.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,7 @@ Use the terminal to spot blocked connections marked `action=deny` and provider-r
609609
610610
The dashboard has three panels stacked vertically: Gateways, Providers (or Global Settings), and Sandboxes. Navigate within a panel with `Up`/`Down` or `j`/`k`. At a list boundary the cursor overflows into the adjacent panel, skipping empty panels. Use `Tab`/`Shift+Tab` to cycle panels directly. Press `h`/`l` or `Left`/`Right` in the middle panel to switch between the Providers and Global Settings tabs.
611611
612-
The sandbox table’s NOTES column shows `Config invalid` and the rejection reason when policy or provider configuration blocks provisioning. Open the sandbox detail view to see the Notes field. The diagnostic clears after the configuration is repaired; active port forwards remain listed.
612+
The sandbox table’s NOTES column shows `inspect for config` when policy or provider configuration blocks provisioning. Open the sandbox detail view for the full rejection reason, or run `openshell sandbox get <name> -o json`. The note clears after the configuration is repaired; active port forwards remain listed.
613613
614614
## Port Forwarding
615615

0 commit comments

Comments
 (0)