feat(tui/spinner): LLM-driven custom spinner flow with live streaming, JSON schema, and preview\n\n- Replace manual custom form with prompt-based generator\n- Send text.format=json_schema (strict), enforce interval 50–300ms, 2–50 frames, 1–20 chars; truncate + left-pad frames\n- Live in-box streaming during generation with wrapping; dimmed line with postfix\n- Loading view: title + spinner + "Generating spinner with AI…"\n- Review view: list-style preview row; [Save] [Retry] below preview\n- Enter on Description submits immediately\n- Add CLI: for side-channel structured requests\n- Core: add TextFormat + Prompt.text_format; include text.format in requests

This commit is contained in:
James Peter
2025-09-10 21:07:43 +10:00
parent 2af739cabe
commit d772837593
11 changed files with 685 additions and 80 deletions
+3
View File
@@ -819,6 +819,7 @@ dependencies = [
"codex-tui",
"codex-version",
"flate2",
"futures",
"regex",
"reqwest",
"serde",
@@ -828,6 +829,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
"which",
"zip",
]
@@ -1113,6 +1115,7 @@ dependencies = [
"color-eyre",
"crossterm",
"diffy",
"futures",
"image 0.25.8",
"insta",
"lazy_static",
+31 -8
View File
@@ -10,21 +10,44 @@ use crate::{EmbeddedApplyPatch};
pub(crate) fn find_embedded_apply_patch(script: &str) -> Result<Option<EmbeddedApplyPatch>, ()> {
let _bytes = script.as_bytes();
let mut i = 0usize;
while let Some(pos) = script[i..].find("apply_patch") {
let start = i + pos;
// Support both command spellings accepted by apply_patch tooling.
// We scan for the next occurrence of either token and treat the first one
// we find (closest to the current cursor) as the candidate.
const CMD1: &str = "apply_patch";
const CMD2: &str = "applypatch";
while i < script.len() {
// Find next match of either token from i
let p1 = script[i..].find(CMD1).map(|p| (p, CMD1.len()));
let p2 = script[i..].find(CMD2).map(|p| (p, CMD2.len()));
let (rel, cmd_len) = match (p1, p2) {
(Some(a), Some(b)) => if a.0 <= b.0 { a } else { b },
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => break,
};
let start = i + rel;
// Ensure token boundary (start or whitespace/punct before, and space or '<' after)
let ok_before = start == 0 || script[..start].chars().rev().next().map(|c| c.is_whitespace() || ";|&".contains(c)).unwrap_or(true);
let after = script[start..].chars().skip("apply_patch".len()).next();
let ok_before = start == 0
|| script[..start]
.chars()
.rev()
.next()
.map(|c| c.is_whitespace() || ";|&".contains(c))
.unwrap_or(true);
let after = script[start..].chars().skip(cmd_len).next();
let ok_after = after.map(|c| c.is_whitespace() || c == '<').unwrap_or(false);
if !ok_before || !ok_after { i = start + "apply_patch".len(); continue; }
if !ok_before || !ok_after {
i = start + cmd_len;
continue;
}
// Find heredoc op: << (allow spaces between)
let rest = &script[start + "apply_patch".len()..];
let rest = &script[start + cmd_len..];
let mut j = 0usize;
// Skip spaces
while j < rest.len() && rest.as_bytes()[j].is_ascii_whitespace() { j += 1; }
if j + 1 >= rest.len() || rest[j..].get(..2).unwrap_or("") != "<<" {
i = start + "apply_patch".len();
i = start + cmd_len;
continue;
}
j += 2; // past <<
@@ -55,7 +78,7 @@ pub(crate) fn find_embedded_apply_patch(script: &str) -> Result<Option<EmbeddedA
// Find end of header line (newline)
let header_slice = &rest[after_delim_idx..];
let Some(nl_rel) = header_slice.find('\n') else { break };
let header_end = start + "apply_patch".len() + after_delim_idx + nl_rel + 1; // pos after newline
let header_end = start + cmd_len + after_delim_idx + nl_rel + 1; // pos after newline
// Search for terminator line equal to delim
let mut scan = header_end;
+2
View File
@@ -47,3 +47,5 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
tar = "0.4"
tempfile = "3"
which = "6"
futures = "0.3"
uuid = { version = "1", features = ["v4"] }
+171
View File
@@ -0,0 +1,171 @@
use std::path::PathBuf;
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::ModelClient;
use codex_core::ModelProviderInfo;
use codex_core::AuthManager;
use codex_core::Prompt;
use codex_core::TextFormat;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
#[derive(Debug, Parser)]
pub struct LlmCli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
pub cmd: LlmSubcommand,
}
#[derive(Debug, clap::Subcommand)]
pub enum LlmSubcommand {
/// Send a one-off structured request to the model (side-channel; no TUI events)
Request(RequestArgs),
}
#[derive(Debug, Parser)]
pub struct RequestArgs {
/// Developer message to prepend (kept separate from system instructions)
#[arg(long)]
pub developer: String,
/// Primary user message/content
#[arg(long)]
pub message: String,
/// `text.format.type` (e.g. json_schema)
#[arg(long = "format-type", default_value = "json_schema")]
pub format_type: String,
/// Optional `text.format.name`
#[arg(long = "format-name")]
pub format_name: Option<String>,
/// Set `text.format.strict`
#[arg(long = "format-strict", default_value_t = true)]
pub format_strict: bool,
/// Inline JSON for the schema (mutually exclusive with --schema-file)
#[arg(long = "schema-json")]
pub schema_json: Option<String>,
/// Path to a JSON schema file (mutually exclusive with --schema-json)
#[arg(long = "schema-file")]
pub schema_file: Option<PathBuf>,
/// Optional model override (e.g. gpt-4.1, gpt-5)
#[arg(long)]
pub model: Option<String>,
}
pub async fn run_llm(opts: LlmCli) -> anyhow::Result<()> {
match opts.cmd {
LlmSubcommand::Request(req) => run_llm_request(opts.config_overrides, req).await,
}
}
async fn run_llm_request(
cli_overrides: CliConfigOverrides,
args: RequestArgs,
) -> anyhow::Result<()> {
let overrides_vec = cli_overrides.parse_overrides().map_err(anyhow::Error::msg)?;
let overrides = if let Some(model) = &args.model {
ConfigOverrides { model: Some(model.clone()), ..ConfigOverrides::default() }
} else { ConfigOverrides::default() };
let config = Config::load_with_cli_overrides(overrides_vec, overrides)?;
// Build Prompt with custom developer + user messages, no extra tools
let mut input: Vec<ResponseItem> = Vec::new();
input.push(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText { text: args.developer.clone() }],
});
input.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: args.message.clone() }],
});
// Resolve schema
let schema_val: Option<serde_json::Value> = if let Some(s) = &args.schema_json {
Some(serde_json::from_str::<serde_json::Value>(s)?)
} else if let Some(p) = &args.schema_file {
let data = std::fs::read_to_string(p)?;
Some(serde_json::from_str::<serde_json::Value>(&data)?)
} else {
None
};
let text_format = TextFormat {
r#type: args.format_type.clone(),
name: args.format_name.clone(),
strict: Some(args.format_strict),
schema: schema_val,
};
let mut prompt = Prompt::default();
prompt.input = input;
prompt.store = true;
prompt.user_instructions = None;
prompt.environment_context = None;
prompt.status_items = vec![];
prompt.base_instructions_override = None;
prompt.text_format = Some(text_format);
// Auth + provider
let auth_mgr = AuthManager::shared(
config.codex_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let provider: ModelProviderInfo = config.model_provider.clone();
let client = ModelClient::new(
std::sync::Arc::new(config.clone()),
Some(auth_mgr),
provider,
config.model_reasoning_effort,
config.model_reasoning_summary,
config.model_text_verbosity,
uuid::Uuid::new_v4(),
std::sync::Arc::new(std::sync::Mutex::new(codex_core::debug_logger::DebugLogger::new(false)?)),
);
// Collect the assistant message text from the stream (no TUI events)
let mut stream = client.stream(&prompt).await?;
let mut final_text: String = String::new();
tracing::info!("LLM: created");
while let Some(ev) = stream.next().await {
let ev = ev?;
match ev {
codex_core::ResponseEvent::ReasoningSummaryDelta { delta, .. } => { tracing::info!(target: "llm", "thinking: {}", delta); }
codex_core::ResponseEvent::ReasoningContentDelta { delta, .. } => { tracing::info!(target: "llm", "reasoning: {}", delta); }
codex_core::ResponseEvent::OutputItemDone { item, .. } => {
if let ResponseItem::Message { content, .. } = item {
for c in content {
if let ContentItem::OutputText { text } = c {
final_text.push_str(&text);
}
}
}
}
codex_core::ResponseEvent::OutputTextDelta { delta, .. } => {
tracing::info!(target: "llm", "delta: {}", delta);
// For completeness, but we only print at the end to stay simple
final_text.push_str(&delta);
}
codex_core::ResponseEvent::Completed { .. } => { tracing::info!("LLM: completed"); break; }
_ => {}
}
}
println!("{}", final_text);
Ok(())
}
+9
View File
@@ -12,6 +12,8 @@ use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_logout;
use codex_cli::proto;
mod llm;
use llm::{LlmCli, run_llm};
use codex_common::CliConfigOverrides;
use codex_exec::Cli as ExecCli;
use codex_tui::Cli as TuiCli;
@@ -87,6 +89,9 @@ enum Subcommand {
/// Download and run preview artifact by slug.
Preview(PreviewArgs),
/// Side-channel LLM utilities (no TUI events).
Llm(LlmCli),
}
#[derive(Debug, Parser)]
@@ -255,6 +260,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::Preview(args)) => {
preview_main(args).await?;
}
Some(Subcommand::Llm(mut llm_cli)) => {
prepend_config_flags(&mut llm_cli.config_overrides, cli.config_overrides);
run_llm(llm_cli).await?;
}
}
Ok(())
+6 -2
View File
@@ -214,12 +214,16 @@ impl ModelClient {
let input_with_instructions = prompt.get_formatted_input();
// Create text parameter with verbosity (not supported with ChatGPT auth)
let text = if auth_mode == Some(AuthMode::ChatGPT) {
// Create text parameter with verbosity and optional format.
// Historically not supported with ChatGPT auth, but allow it when a
// caller explicitly requests a `text.format` (side‑channel usage).
let want_format = prompt.text_format.as_ref();
let text = if auth_mode == Some(AuthMode::ChatGPT) && want_format.is_none() {
None
} else {
Some(crate::client_common::Text {
verbosity: self.verbosity.into(),
format: prompt.text_format.clone(),
})
};
+18
View File
@@ -58,6 +58,9 @@ pub struct Prompt {
/// Optional override for the built-in BASE_INSTRUCTIONS.
pub base_instructions_override: Option<String>,
/// Optional `text.format` for structured outputs (used by side-channel requests).
pub text_format: Option<TextFormat>,
}
impl Prompt {
@@ -212,6 +215,8 @@ pub(crate) struct Reasoning {
#[derive(Debug, Serialize)]
pub(crate) struct Text {
pub(crate) verbosity: OpenAiTextVerbosity,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) format: Option<TextFormat>,
}
/// OpenAI text verbosity level for serialization.
@@ -234,6 +239,19 @@ impl From<TextVerbosityConfig> for OpenAiTextVerbosity {
}
}
/// Optional structured output format for `text.format` in the Responses API.
#[derive(Debug, Serialize, Clone)]
pub struct TextFormat {
#[serde(rename = "type")]
pub r#type: String, // e.g. "json_schema"
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<serde_json::Value>,
}
/// Limits the number of screenshots in the input to a maximum of 5.
/// Keeps the first screenshot and the last 4 screenshots.
/// Replaces removed screenshots with a placeholder message.
+3
View File
@@ -2197,6 +2197,7 @@ async fn run_turn(
Some(sess.user_shell.clone()),
)),
status_items, // Include status items with this request
text_format: None,
};
// Start a new scratchpad for this HTTP attempt
@@ -2263,6 +2264,7 @@ async fn run_turn(
base_instructions_override: Some(SUMMARIZATION_PROMPT.to_string()),
environment_context: None,
status_items: Vec::new(),
text_format: None,
};
match drain_to_completed(sess, &sub_id, &compact_prompt).await {
@@ -2652,6 +2654,7 @@ async fn run_compact_agent(
tools: Vec::new(),
base_instructions_override: Some(compact_instructions.clone()),
status_items, // Include status items with this request
text_format: None,
};
let attempt_result = drain_to_completed(&sess, &sub_id, &prompt).await;
+1
View File
@@ -83,6 +83,7 @@ pub use codex_protocol::models as models;
// Keep these stable to avoid breaking callers.
pub use crate::client::ModelClient;
pub use crate::client_common::Prompt;
pub use crate::client_common::TextFormat;
pub use crate::client_common::ResponseEvent;
pub use crate::client_common::ResponseStream;
pub use codex_protocol::models::ContentItem;
+1
View File
@@ -85,6 +85,7 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
url = "2"
uuid = "1"
futures = "0.3"
syntect = { version = "5", features = ["yaml-load", "plist-load", "parsing", "default-syntaxes"] }
which = "6"
urlencoding = "2.1"
@@ -237,21 +237,196 @@ impl ThemeSelectionView {
}
self.mode = Mode::Overview;
}
/// Spawn a background task that creates a custom spinner using the LLM with a JSON schema
fn kickoff_spinner_creation(&self, user_prompt: String, progress_tx: std::sync::mpsc::Sender<ProgressMsg>) {
let tx = self.app_event_tx.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Failed to start runtime: {}", e))); return; }
};
let _ = rt.block_on(async move {
// Load current config (CLI-style) and construct a one-off ModelClient
let cfg = match codex_core::config::Config::load_with_cli_overrides(vec![], codex_core::config::ConfigOverrides::default()) {
Ok(c) => c,
Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Config error: {}", e))); return; }
};
let auth_mgr = codex_core::AuthManager::shared(
cfg.codex_home.clone(),
codex_protocol::mcp_protocol::AuthMode::ApiKey,
cfg.responses_originator_header.clone(),
);
let client = codex_core::ModelClient::new(
std::sync::Arc::new(cfg.clone()),
Some(auth_mgr),
cfg.model_provider.clone(),
cfg.model_reasoning_effort,
cfg.model_reasoning_summary,
cfg.model_text_verbosity,
uuid::Uuid::new_v4(),
std::sync::Arc::new(std::sync::Mutex::new(codex_core::debug_logger::DebugLogger::new(false).unwrap_or_else(|_| codex_core::debug_logger::DebugLogger::new(false).expect("debug logger")))),
);
// Build developer guidance and input
let developer = "You are performing a custom task to create a terminal spinner.\n\nRequirements:\n- Output JSON ONLY, no prose.\n- `interval` is the delay in milliseconds between frames; MUST be between 50 and 300 inclusive.\n- `frames` is an array of strings; each element is a frame displayed sequentially at the given interval.\n- The spinner SHOULD have between 2 and 50 frames.\n- Each frame SHOULD be between 1 and 20 characters wide. ALL frames MUST be the SAME width (same number of characters). If you propose frames with varying widths, PAD THEM ON THE LEFT with spaces so they are uniform.\n- You MAY use ASCII and Unicode characters (e.g., box drawing, braille, arrows). Use EMOJIS ONLY if the user explicitly requests emojis in their prompt.\n- Use characters that render well in typical monospaced terminals.\n".to_string();
let mut input: Vec<codex_protocol::models::ResponseItem> = Vec::new();
input.push(codex_protocol::models::ResponseItem::Message { id: None, role: "developer".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: developer }] });
input.push(codex_protocol::models::ResponseItem::Message { id: None, role: "user".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: user_prompt }] });
// JSON schema for structured output
let schema = serde_json::json!({
"type": "object",
"properties": {
"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."
}
},
"required": ["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) };
let mut prompt = codex_core::Prompt::default();
prompt.input = input;
prompt.store = true;
prompt.text_format = Some(format);
// Stream and collect final JSON
use futures::StreamExt;
let _ = progress_tx.send(ProgressMsg::ThinkingDelta("(connecting to model)".to_string()));
let mut stream = match client.stream(&prompt).await { Ok(s) => s, Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Request error: {}", e))); tracing::info!("spinner request error: {}", e); return; } };
let mut out = String::new();
let mut think_sum = String::new();
while let Some(ev) = stream.next().await {
match ev {
Ok(codex_core::ResponseEvent::Created) => { tracing::info!("LLM: created"); }
Ok(codex_core::ResponseEvent::ReasoningSummaryDelta { delta, .. }) => { tracing::info!(target: "spinner", "LLM[thinking]: {}", delta); let _ = progress_tx.send(ProgressMsg::ThinkingDelta(delta.clone())); think_sum.push_str(&delta); }
Ok(codex_core::ResponseEvent::ReasoningContentDelta { delta, .. }) => { tracing::info!(target: "spinner", "LLM[reasoning]: {}", delta); }
Ok(codex_core::ResponseEvent::OutputTextDelta { delta, .. }) => { tracing::info!(target: "spinner", "LLM[delta]: {}", delta); let _ = progress_tx.send(ProgressMsg::OutputDelta(delta.clone())); out.push_str(&delta); }
Ok(codex_core::ResponseEvent::OutputItemDone { item, .. }) => {
if let codex_protocol::models::ResponseItem::Message { content, .. } = item {
for c in content { if let codex_protocol::models::ContentItem::OutputText { text } = c { out.push_str(&text); } }
}
tracing::info!(target: "spinner", "LLM[item_done]");
}
Ok(codex_core::ResponseEvent::Completed { .. }) => { tracing::info!("LLM: completed"); break; }
Err(e) => { tracing::info!("LLM stream error: {}", e); }
_ => {}
}
}
let _ = progress_tx.send(ProgressMsg::RawOutput(out.clone()));
// Parse JSON; on failure, attempt to salvage a top-level object and log raw output
let v: serde_json::Value = match serde_json::from_str(&out) {
Ok(v) => v,
Err(e) => {
tracing::info!(target: "spinner", "Strict JSON parse failed: {}", e);
tracing::info!(target: "spinner", "Raw output: {}", out);
fn extract_first_json_object(s: &str) -> Option<String> {
let mut depth = 0usize;
let mut in_str = false;
let mut esc = false;
let mut start: Option<usize> = None;
for (i, ch) in s.char_indices() {
if in_str {
if esc { esc = false; }
else if ch == '\\' { esc = true; }
else if ch == '"' { in_str = false; }
continue;
}
match ch {
'"' => in_str = true,
'{' => { if depth == 0 { start = Some(i); } depth += 1; },
'}' => { if depth > 0 { depth -= 1; if depth == 0 { let end = i + ch.len_utf8(); return start.map(|st| s[st..end].to_string()); } } },
_ => {}
}
}
None
}
if let Some(obj) = extract_first_json_object(&out) {
match serde_json::from_str::<serde_json::Value>(&obj) {
Ok(v) => v,
Err(e2) => { let _ = progress_tx.send(ProgressMsg::CompletedErr { error: format!("{}", e2), _raw_snippet: out.chars().take(200).collect::<String>() }); return; }
}
} else {
let _ = progress_tx.send(ProgressMsg::CompletedErr { error: format!("{}", e), _raw_snippet: out.chars().take(200).collect::<String>() }); return;
}
}
};
let interval = v.get("interval").and_then(|x| x.as_u64()).unwrap_or(120).clamp(50, 300);
let mut frames: Vec<String> = v
.get("frames")
.and_then(|x| x.as_array())
.map(|arr| arr.iter().filter_map(|f| f.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
// Enforce frame width limit (truncate to first 20 characters)
const MAX_CHARS: usize = 20;
frames = frames
.into_iter()
.map(|f| f.chars().take(MAX_CHARS).collect::<String>())
.filter(|f| !f.is_empty())
.collect();
// Enforce count 2–50
if frames.len() > 50 { frames.truncate(50); }
if frames.len() < 2 { let _ = progress_tx.send(ProgressMsg::CompletedErr { error: "too few frames after validation".to_string(), _raw_snippet: out.chars().take(200).collect::<String>() }); return; }
// Normalize: left-pad frames to equal length if needed
let max_len = frames.iter().map(|f| f.chars().count()).max().unwrap_or(0);
let norm_frames: Vec<String> = frames
.into_iter()
.map(|f| {
let cur = f.chars().count();
if cur >= max_len { f } else { format!("{}{}", " ".repeat(max_len - cur), f) }
})
.collect();
// Persist + activate
let _ = progress_tx.send(ProgressMsg::CompletedOk { interval, frames: norm_frames });
});
});
}
}
#[derive(Clone, PartialEq)]
enum Mode { Overview, Themes, Spinner, CreateSpinner(CreateState) }
#[derive(Clone, PartialEq)]
struct CreateState {
step: CreateStep,
interval: String,
frames: String,
action_idx: usize, // 0 = Save, 1 = Cancel
step: std::cell::Cell<CreateStep>,
/// Freeform prompt describing the desired spinner
prompt: String,
/// While true, we render a loading indicator and disable input
is_loading: std::cell::Cell<bool>,
action_idx: usize, // 0 = Create/Save, 1 = Cancel/Retry
/// Live stream messages from the background task
rx: Option<std::sync::mpsc::Receiver<ProgressMsg>>,
/// Accumulated thinking/output lines for live display (completed)
thinking_lines: std::cell::RefCell<Vec<String>>,
/// In‑progress line assembled from deltas
thinking_current: std::cell::RefCell<String>,
/// Parsed proposal waiting for review
proposed_interval: std::cell::Cell<Option<u64>>,
proposed_frames: std::cell::RefCell<Option<Vec<String>>>,
}
#[derive(Clone, PartialEq)]
enum CreateStep { Interval, Frames, Action }
#[derive(Copy, Clone, PartialEq)]
enum CreateStep { Prompt, Action, Review }
enum ProgressMsg {
ThinkingDelta(String),
OutputDelta(String),
RawOutput(String),
CompletedOk { interval: u64, frames: Vec<String> },
// `_raw_snippet` is captured for potential future display/debugging
CompletedErr { error: String, _raw_snippet: String },
}
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
fn desired_height(&self, _width: u16) -> u16 {
@@ -285,13 +460,14 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
} => {
// In create form, Up navigates fields/buttons
if let Mode::CreateSpinner(ref mut s) = self.mode {
s.step = match s.step {
CreateStep::Interval => CreateStep::Action,
CreateStep::Frames => CreateStep::Interval,
CreateStep::Action => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Action }
let new_step = match s.step.get() {
CreateStep::Prompt => CreateStep::Action,
CreateStep::Action => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Action },
CreateStep::Review => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Review },
};
s.step.set(new_step);
} else {
match self.mode.clone() {
match self.mode {
Mode::Overview => {
self.overview_selected_index = self.overview_selected_index.saturating_sub(1) % 2;
}
@@ -306,13 +482,14 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
} => {
// In create form, Down navigates fields/buttons
if let Mode::CreateSpinner(ref mut s) = self.mode {
s.step = match s.step {
CreateStep::Interval => CreateStep::Frames,
CreateStep::Frames => CreateStep::Action,
CreateStep::Action => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Action }
let new_step = match s.step.get() {
CreateStep::Prompt => CreateStep::Action,
CreateStep::Action => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Action },
CreateStep::Review => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Review },
};
s.step.set(new_step);
} else {
match &self.mode.clone() {
match &self.mode {
Mode::Overview => {
self.overview_selected_index = (self.overview_selected_index + 1) % 2;
}
@@ -351,32 +528,75 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
// If tail row selected (Create your own…), open create form
let names = crate::spinner::spinner_names();
if self.selected_spinner_index >= names.len() {
self.mode = Mode::CreateSpinner(CreateState { step: CreateStep::Interval, interval: String::from("120"), frames: String::new(), action_idx: 0 });
self.mode = Mode::CreateSpinner(CreateState {
step: std::cell::Cell::new(CreateStep::Prompt),
prompt: String::new(),
is_loading: std::cell::Cell::new(false),
action_idx: 0,
rx: None,
thinking_lines: std::cell::RefCell::new(Vec::new()),
thinking_current: std::cell::RefCell::new(String::new()),
proposed_interval: std::cell::Cell::new(None),
proposed_frames: std::cell::RefCell::new(None),
});
} else {
self.confirm_spinner()
}
}
Mode::CreateSpinner(mut s) => {
let mut go_overview = false;
match s.step {
CreateStep::Interval => { s.step = CreateStep::Frames; }
CreateStep::Frames => { s.step = CreateStep::Action; }
match s.step.get() {
// Enter from Prompt submits immediately
CreateStep::Prompt => {
if !s.is_loading.get() {
let user_prompt = s.prompt.clone();
s.is_loading.set(true);
s.thinking_lines.borrow_mut().clear();
s.thinking_current.borrow_mut().clear();
s.proposed_interval.set(None);
s.proposed_frames.replace(None);
let (txp, rxp) = std::sync::mpsc::channel::<ProgressMsg>();
s.rx = Some(rxp);
self.kickoff_spinner_creation(user_prompt, txp);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
CreateStep::Action => {
if s.action_idx == 0 && !s.is_loading.get() {
let user_prompt = s.prompt.clone();
s.is_loading.set(true);
s.thinking_lines.borrow_mut().clear();
s.thinking_current.borrow_mut().clear();
s.proposed_interval.set(None);
s.proposed_frames.replace(None);
let (txp, rxp) = std::sync::mpsc::channel::<ProgressMsg>();
s.rx = Some(rxp);
self.kickoff_spinner_creation(user_prompt, txp);
self.app_event_tx.send(AppEvent::RequestRedraw);
} else { /* Cancel or already loading → return to overview */ go_overview = true; }
}
CreateStep::Review => {
if s.action_idx == 0 {
let interval = s.interval.trim().parse::<u64>().unwrap_or(120);
let frames: Vec<String> = s.frames.split(',').map(|f| f.trim().to_string()).filter(|f| !f.is_empty()).collect();
if !frames.is_empty() {
// Save
if let (Some(interval), Some(frames)) = (s.proposed_interval.get(), s.proposed_frames.borrow().clone()) {
if let Ok(home) = codex_core::config::find_codex_home() { let _ = codex_core::config::set_custom_spinner(&home, "custom", interval, &frames); }
crate::spinner::add_custom_spinner("custom".to_string(), "Custom".to_string(), interval, frames);
crate::spinner::switch_spinner("custom");
self.revert_spinner_on_back = "custom".to_string();
self.current_spinner = "custom".to_string();
self.revert_spinner_on_back = self.current_spinner.clone();
self.app_event_tx.send(AppEvent::UpdateSpinner("custom".to_string()));
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("✓ Custom spinner saved".to_string()));
go_overview = true;
} else {
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("Provide at least one frame (comma-separated)".to_string()));
}
} else { /* Cancel → return to overview */ go_overview = true; }
} else {
// Retry -> return to input (Prompt) without running
s.thinking_lines.borrow_mut().clear();
s.thinking_current.borrow_mut().clear();
s.proposed_interval.set(None);
s.proposed_frames.replace(None);
s.step.set(CreateStep::Prompt);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
}
if go_overview { self.mode = Mode::Overview; } else { self.mode = Mode::CreateSpinner(s); }
@@ -396,13 +616,14 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, .. } => {
if let Mode::CreateSpinner(ref mut s) = self.mode {
match s.step { CreateStep::Interval => s.interval.push(c), CreateStep::Frames => s.frames.push(c), CreateStep::Action => {} }
if s.is_loading.get() { return; }
match s.step.get() { CreateStep::Prompt => s.prompt.push(c), CreateStep::Action | CreateStep::Review => {} }
}
}
KeyEvent { code: KeyCode::Backspace, .. } => {
if let Mode::CreateSpinner(ref mut s) = self.mode {
let tgt = match s.step { CreateStep::Interval => &mut s.interval, CreateStep::Frames => &mut s.frames, CreateStep::Action => { return; } };
tgt.pop();
if s.is_loading.get() { return; }
match s.step.get() { CreateStep::Prompt => { s.prompt.pop(); }, CreateStep::Action | CreateStep::Review => { return; } }
}
}
_ => {}
@@ -560,52 +781,201 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
// Inline form for custom spinner with visible selection & caret
let theme = crate::theme::current_theme();
if let Mode::CreateSpinner(s) = &self.mode {
// Drain progress messages if streaming
if let Some(rx) = &s.rx {
for _ in 0..100 { // limit per render to keep UI snappy
match rx.try_recv() {
Ok(ProgressMsg::ThinkingDelta(d)) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
let mut cur = sm.thinking_current.borrow_mut();
let mut hist = sm.thinking_lines.borrow_mut();
cur.push_str(&d);
if let Some(pos) = cur.rfind('\n') {
// Split on last newline: commit completed portion, keep remainder
let (complete, remainder) = cur.split_at(pos);
if !complete.trim().is_empty() { hist.push(complete.trim().to_string()); }
*cur = remainder.trim_start_matches('\n').to_string();
let keep = 10usize;
let len = hist.len();
if len > keep { hist.drain(0..len-keep); }
}
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::OutputDelta(d)) => {
// Treat assistant text deltas the same as thinking: append to current; on newline commit to history
if let Mode::CreateSpinner(ref sm) = self.mode {
let mut cur = sm.thinking_current.borrow_mut();
let mut hist = sm.thinking_lines.borrow_mut();
cur.push_str(&d);
if let Some(pos) = cur.rfind('\n') {
let (complete, remainder) = cur.split_at(pos);
if !complete.trim().is_empty() { hist.push(complete.trim().to_string()); }
*cur = remainder.trim_start_matches('\n').to_string();
let keep = 10usize;
let len = hist.len();
if len > keep { hist.drain(0..len-keep); }
}
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::RawOutput(_raw)) => {}
Ok(ProgressMsg::CompletedOk { interval, frames }) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
sm.is_loading.set(false);
sm.step.set(CreateStep::Review);
sm.proposed_interval.set(Some(interval));
sm.proposed_frames.replace(Some(frames));
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::CompletedErr { error, _raw_snippet: _ }) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
sm.is_loading.set(false); sm.step.set(CreateStep::Action);
sm.thinking_lines.borrow_mut().push(format!("Error: {}", error));
sm.thinking_current.borrow_mut().clear();
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
}
let mut form_lines = Vec::new();
// While loading: replace the entire content with spinner + latest message
if s.is_loading.get() {
form_lines.push(Line::from(Span::styled(
"Overview » Change Spinner » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
// One blank line between title and spinner line
form_lines.push(Line::from(" "));
use std::time::{SystemTime, UNIX_EPOCH};
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let diamond = ["◇", "◆", "◇", "◆"]; let frame = diamond[((now_ms / 120) as usize) % diamond.len()].to_string();
form_lines.push(Line::from(vec![Span::styled(frame, Style::default().fg(crate::colors::info())), Span::raw(" Generating spinner with AI…")]));
// Latest message only
// Show the latest in‑progress line if present, otherwise last completed line
let cur = s.thinking_current.borrow();
let latest = if !cur.trim().is_empty() {
cur.trim().to_string()
} else {
s.thinking_lines
.borrow()
.iter()
.rev()
.find(|l| !l.trim().is_empty())
.cloned()
.unwrap_or_else(|| "Waiting for model…".to_string())
};
let mut latest_render = latest.to_string();
if !latest_render.ends_with('…') {
latest_render.push_str(" …");
}
form_lines.push(Line::from(Span::styled(latest_render, Style::default().fg(theme.text_dim))));
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(120)));
Paragraph::new(form_lines)
.alignment(Alignment::Left)
.wrap(ratatui::widgets::Wrap { trim: false })
.render(body_area, buf);
return;
}
// After completion (review): show preview + Save/Retry
if matches!(s.step.get(), CreateStep::Review) {
form_lines.push(Line::from(Span::styled(
"Overview » Change Spinner » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
form_lines.push(Line::from(" "));
// Preview styled like selection rows: border rules + spinner + label
if let (Some(interval), Some(frames)) = (s.proposed_interval.get(), s.proposed_frames.borrow().as_ref()) {
use std::time::{SystemTime, UNIX_EPOCH};
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let idx = if frames.is_empty() { 0 } else { ((now_ms / interval) as usize) % frames.len() };
let preview = frames.get(idx).cloned().unwrap_or_default();
form_lines.push(Line::from(" "));
// Compute layout similar to the list row: left rule | spinner | label | right rule
let label = "Preview";
let max_frame_len: u16 = preview.chars().count() as u16;
let border = Style::default().fg(crate::colors::border());
let fg = Style::default().fg(crate::colors::info());
let text_len = (label.chars().count() as u16).saturating_add(3); // label + dots
let x: u16 = max_frame_len.saturating_add(5);
let left_rule = x.saturating_sub(max_frame_len);
let right_rule = x.saturating_sub(text_len);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled("─".repeat(left_rule as usize), border));
spans.push(Span::raw(" "));
spans.push(Span::styled(preview, fg));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{}...", label), fg));
spans.push(Span::raw(" "));
spans.push(Span::styled("─".repeat(right_rule as usize), border));
form_lines.push(Line::from(spans));
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(interval)));
}
// Add spacing line between preview and buttons
form_lines.push(Line::from(" "));
// Buttons row moved to bottom
let mut spans: Vec<Span> = Vec::new();
let primary_selected = s.action_idx == 0;
let secondary_selected = s.action_idx == 1;
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Save ]", sel(primary_selected)));
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Retry ]", sel(secondary_selected)));
form_lines.push(Line::from(spans));
Paragraph::new(form_lines)
.alignment(Alignment::Left)
.wrap(ratatui::widgets::Wrap { trim: false })
.render(body_area, buf);
return;
}
// Default (idle): header, description input, border, and Generate/Cancel buttons
form_lines.push(Line::from(Span::styled(
"Overview » Change Spinner » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
form_lines.push(Line::from(Span::styled(
"Code can generate a custom spinner just for you!",
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)",
Style::default().fg(theme.text_dim)
)));
// Exactly one blank line above Description
form_lines.push(Line::from(" "));
let caret = Span::styled("▏", Style::default().fg(theme.info));
// Interval line
{
let active = matches!(s.step, CreateStep::Interval);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(if active { "› " } else { " " }, Style::default().fg(theme.keyword)));
spans.push(Span::styled("Interval (ms): ", Style::default().fg(theme.keyword)));
if s.interval.is_empty() && active { spans.push(Span::styled("(e.g., 120)", Style::default().fg(theme.text_dim))); spans.push(caret.clone()); }
else if active { spans.push(Span::raw(s.interval.clone())); spans.push(caret.clone()); }
else { spans.push(Span::raw(s.interval.clone())); }
form_lines.push(Line::from(spans));
}
// Frames line
{
let active = matches!(s.step, CreateStep::Frames);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(if active { "› " } else { " " }, Style::default().fg(theme.keyword)));
spans.push(Span::styled("Frames (comma-separated): ", Style::default().fg(theme.keyword)));
if s.frames.is_empty() && active { spans.push(Span::styled("(e.g., ., .., …)", Style::default().fg(theme.text_dim))); spans.push(caret.clone()); }
else if active { spans.push(Span::raw(s.frames.clone())); spans.push(caret.clone()); }
else { spans.push(Span::raw(s.frames.clone())); }
form_lines.push(Line::from(spans));
}
let mut desc_spans: Vec<Span> = Vec::new();
desc_spans.push(Span::styled("Description: ", Style::default().fg(theme.keyword)));
let active = matches!(s.step.get(), CreateStep::Prompt);
desc_spans.push(Span::raw(s.prompt.clone()));
if active { desc_spans.push(caret.clone()); }
form_lines.push(Line::from(desc_spans));
form_lines.push(Line::from(Span::styled("─".repeat((body_area.width.saturating_sub(4)) as usize), Style::default().fg(crate::colors::border()))));
// Buttons
let mut spans: Vec<Span> = Vec::new();
let on_actions = matches!(s.step.get(), CreateStep::Action);
let primary_selected = on_actions && s.action_idx == 0;
let secondary_selected = on_actions && s.action_idx == 1;
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Generate... ]", sel(primary_selected)));
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Cancel ]", sel(secondary_selected)));
form_lines.push(Line::from(spans));
// Action buttons (Save / Cancel)
{
let mut spans: Vec<Span> = Vec::new();
let active = matches!(s.step, CreateStep::Action);
let save_selected = active && s.action_idx == 0;
let cancel_selected = active && s.action_idx == 1;
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Save ]", sel(save_selected)));
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Cancel ]", sel(cancel_selected)));
form_lines.push(Line::from(spans));
}
Paragraph::new(form_lines).alignment(Alignment::Left).render(body_area, buf);
Paragraph::new(form_lines)
.alignment(Alignment::Left)
.wrap(ratatui::widgets::Wrap { trim: false })
.render(body_area, buf);
}
return;
} else {