feat(tui/paste): route terminal paste to active bottom-pane views; enable paste into custom spinner prompt (Create Spinner)

This commit is contained in:
James Peter
2025-09-10 21:49:02 +10:00
parent 704286d3da
commit a48ad2a1c1
3 changed files with 70 additions and 16 deletions
@@ -53,4 +53,10 @@ pub(crate) trait BottomPaneView<'a> {
) -> Option<ApprovalRequest> {
Some(request)
}
/// Handle pasted text while this view is active. Return whether a redraw
/// is needed. Default: ignore paste.
fn handle_paste(&mut self, _text: String) -> ConditionalUpdate {
ConditionalUpdate::NoRedraw
}
}
+11 -5
View File
@@ -236,12 +236,18 @@ impl BottomPane<'_> {
}
pub fn handle_paste(&mut self, pasted: String) {
if self.active_view.is_none() {
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
// Large pastes may arrive as bursts; coalesce paints
self.request_redraw();
if let Some(ref mut view) = self.active_view {
use crate::bottom_pane::bottom_pane_view::ConditionalUpdate;
match view.handle_paste(pasted) {
ConditionalUpdate::NeedsRedraw => self.request_redraw(),
ConditionalUpdate::NoRedraw => {}
}
return;
}
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
// Large pastes may arrive as bursts; coalesce paints
self.request_redraw();
}
}
@@ -293,17 +293,17 @@ impl ThemeSelectionView {
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 40, "description": "Display name for the spinner (shown in the UI)."},
"name": {"type": "string", "minLength": 1, "maxLength": 40, "description": "Display name for the spinner (1 - 3 words, shown in the UI)."},
"interval": {"type": "integer", "minimum": 50, "maximum": 300, "description": "Delay between frames in milliseconds (50–300)."},
"frames": {
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 20},
"minItems": 2,
"maxItems": 50,
"description": "Spinner frames; 2–50 strings, 1–20 characters each."
"description": "2–50 Spinner frames, 1–20 characters each (every frame should be the same length of characters)."
}
},
"required": ["interval", "frames"],
"required": ["name", "interval", "frames"],
"additionalProperties": false
});
let format = codex_core::TextFormat { r#type: "json_schema".to_string(), name: Some("custom_spinner".to_string()), strict: Some(true), schema: Some(schema) };
@@ -456,12 +456,35 @@ enum ProgressMsg {
ThinkingDelta(String),
OutputDelta(String),
RawOutput(String),
CompletedOk { name: String, interval: u64, frames: Vec<String> },
CompletedOk {
name: String,
interval: u64,
frames: Vec<String>,
},
// `_raw_snippet` is captured for potential future display/debugging
CompletedErr { error: String, _raw_snippet: String },
CompletedErr {
error: String,
_raw_snippet: String,
},
}
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
fn handle_paste(&mut self, text: String) -> super::bottom_pane_view::ConditionalUpdate {
use super::bottom_pane_view::ConditionalUpdate;
if let Mode::CreateSpinner(ref mut s) = self.mode {
if s.is_loading.get() {
return ConditionalUpdate::NoRedraw;
}
if matches!(s.step.get(), CreateStep::Prompt) {
let paste = text.replace('\r', "\n");
// The description is a single-line prompt; replace newlines with spaces.
let paste = paste.replace('\n', " ");
s.prompt.push_str(&paste);
return ConditionalUpdate::NeedsRedraw;
}
}
ConditionalUpdate::NoRedraw
}
fn desired_height(&self, _width: u16) -> u16 {
match &self.mode {
// Border (2) + inner padding (2) + 2 content rows = 6
@@ -657,10 +680,19 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
.unwrap_or_else(|| "Custom".to_string());
if let Ok(home) = codex_core::config::find_codex_home() {
let _ = codex_core::config::set_custom_spinner(
&home, "custom", &display_name, interval, &frames,
&home,
"custom",
&display_name,
interval,
&frames,
);
}
crate::spinner::add_custom_spinner("custom".to_string(), display_name.clone(), interval, frames);
crate::spinner::add_custom_spinner(
"custom".to_string(),
display_name.clone(),
interval,
frames,
);
crate::spinner::switch_spinner("custom");
self.revert_spinner_on_back = "custom".to_string();
self.current_spinner = "custom".to_string();
@@ -967,7 +999,11 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::RawOutput(_raw)) => {}
Ok(ProgressMsg::CompletedOk { name, interval, frames }) => {
Ok(ProgressMsg::CompletedOk {
name,
interval,
frames,
}) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
sm.is_loading.set(false);
sm.step.set(CreateStep::Review);
@@ -1145,7 +1181,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
Style::default().fg(theme.text),
)));
form_lines.push(Line::from(Span::styled(
"What sort of spinner would you like? (e.g. bouncing dot party, rocket ship emoji blast off)",
"What sort of spinner would you like? (e.g. bouncing dot party, cat eating a pizza)",
Style::default().fg(theme.text_dim)
)));
// Exactly one blank line above Description
@@ -1265,11 +1301,17 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
// selector chevron
spans.push(Span::styled(
if is_selected { "› " } else { " " }.to_string(),
Style::default().fg(if is_selected { theme.keyword } else { theme.text }),
Style::default().fg(if is_selected {
theme.keyword
} else {
theme.text
}),
));
// label color: dim when not selected; primary + bold when selected
let label_style = if is_selected {
Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
};