mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user