mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
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:
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user