Use delayed shimmer for plugin loading headers in tui and tui_app_server (#15674)

- Add a small delayed loading header for plugin list/detail loading
messages in the TUI. Keep existing text for the first 1s, then show
shimmer on the loading line.
- Apply the same behavior in both tui and tui_app_server.


https://github.com/user-attachments/assets/71dd35e4-7e3b-4e7b-867a-3c13dc395d3a
This commit is contained in:
canvrno-oai
2026-03-24 16:03:40 -07:00
committed by Roy Han
parent a6b0207a39
commit ba3b48b2da
4 changed files with 168 additions and 32 deletions

View File

@@ -1,4 +1,6 @@
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use super::ChatWidget;
use crate::app_event::AppEvent;
@@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallResponse;
@@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
struct DelayedLoadingHeader {
started_at: Instant,
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
}
impl DelayedLoadingHeader {
fn new(
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
) -> Self {
Self {
started_at: Instant::now(),
frame_requester,
animations_enabled,
loading_text,
note,
}
}
}
impl Renderable for DelayedLoadingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let mut lines = Vec::with_capacity(3);
lines.push(Line::from("Plugins".bold()));
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.started_at);
if elapsed < LOADING_ANIMATION_DELAY {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed);
lines.push(Line::from(self.loading_text.as_str().dim()));
} else if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
lines.push(Line::from(shimmer_spans(self.loading_text.as_str())));
} else {
lines.push(Line::from(self.loading_text.as_str().dim()));
}
if let Some(note) = &self.note {
lines.push(Line::from(note.as_str().dim()));
}
Paragraph::new(lines).render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
2 + u16::from(self.note.is_some())
}
}
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
@@ -474,16 +545,14 @@ impl ChatWidget {
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"Available marketplaces will appear here when ready.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
"Loading available plugins...".to_string(),
Some("This first pass shows the ChatGPT marketplace only.".to_string()),
)),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
@@ -495,15 +564,14 @@ impl ChatWidget {
}
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
format!("Loading details for {plugin_display_name}..."),
/*note*/ None,
)),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some("This updates when plugin details load.".to_string()),

View File

@@ -4,6 +4,6 @@ expression: popup
---
Plugins
Loading available plugins...
Available marketplaces will appear here when ready.
This first pass shows the ChatGPT marketplace only.
Loading plugins... This updates when the marketplace list is ready.

View File

@@ -1,4 +1,6 @@
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use super::ChatWidget;
use crate::app_event::AppEvent;
@@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallResponse;
@@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
struct DelayedLoadingHeader {
started_at: Instant,
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
}
impl DelayedLoadingHeader {
fn new(
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
) -> Self {
Self {
started_at: Instant::now(),
frame_requester,
animations_enabled,
loading_text,
note,
}
}
}
impl Renderable for DelayedLoadingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let mut lines = Vec::with_capacity(3);
lines.push(Line::from("Plugins".bold()));
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.started_at);
if elapsed < LOADING_ANIMATION_DELAY {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed);
lines.push(Line::from(self.loading_text.as_str().dim()));
} else if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
lines.push(Line::from(shimmer_spans(self.loading_text.as_str())));
} else {
lines.push(Line::from(self.loading_text.as_str().dim()));
}
if let Some(note) = &self.note {
lines.push(Line::from(note.as_str().dim()));
}
Paragraph::new(lines).render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
2 + u16::from(self.note.is_some())
}
}
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
@@ -474,16 +545,14 @@ impl ChatWidget {
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"Available marketplaces will appear here when ready.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
"Loading available plugins...".to_string(),
Some("This first pass shows the ChatGPT marketplace only.".to_string()),
)),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
@@ -495,15 +564,14 @@ impl ChatWidget {
}
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
format!("Loading details for {plugin_display_name}..."),
/*note*/ None,
)),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some("This updates when plugin details load.".to_string()),

View File

@@ -4,6 +4,6 @@ expression: popup
---
Plugins
Loading available plugins...
Available marketplaces will appear here when ready.
This first pass shows the ChatGPT marketplace only.
Loading plugins... This updates when the marketplace list is ready.