From b3a056dfc8e68138c316021af0150ba4b69999b1 Mon Sep 17 00:00:00 2001 From: bjay kamwa watanabe Date: Tue, 10 Mar 2026 14:41:57 +0100 Subject: [PATCH 1/4] Update openapi --- .../openapi.tellers_public_api.yaml | 334 +++++++++++++++++- 1 file changed, 327 insertions(+), 7 deletions(-) diff --git a/src/tellers_api/openapi.tellers_public_api.yaml b/src/tellers_api/openapi.tellers_public_api.yaml index 7e083fd..eabf28b 100644 --- a/src/tellers_api/openapi.tellers_public_api.yaml +++ b/src/tellers_api/openapi.tellers_public_api.yaml @@ -554,6 +554,46 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agent/available-llm-models: + get: + tags: + - accepts-api-key + summary: Get Available Llm Models + operationId: get_available_llm_models_agent_available_llm_models_get + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + type: string + title: Response Get Available Llm Models Agent Available Llm Models + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agent/response: post: tags: @@ -595,6 +635,55 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agent/response/json: + post: + tags: + - accepts-api-key + - accepts-api-key + summary: Process Agent Message Json + description: 'Same as POST /response but runs with no_interaction=True. Emits + only SSE events + + `event: tellers.json_result` (progressing while running, then done with full + result). + + chat_id must be set by the client or is created at the start.' + operationId: process_agent_message_json_agent_response_json_post + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentMessageRequestWithoutNoInteraction' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agent/use-tool: post: tags: @@ -905,6 +994,19 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /settings: + get: + tags: + - accepts-api-key + summary: Get Settings + operationId: get_settings_settings_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AppSettings' components: schemas: AgentMessageRequest: @@ -956,18 +1058,24 @@ components: - $ref: '#/components/schemas/AppContext' - type: 'null' description: The app context when the response is sent. + llm_model: + type: string + enum: + - gpt-5.4-pro-2026-03-05 + - gpt-5.4-2026-03-05 + - gpt-5.3-codex + - gpt-5.2-2025-12-11 + - gpt-5-mini-2025-08-07 + title: Llm Model + description: The model to use to generate the response. + default: gpt-5.4-2026-03-05 tools: items: type: string type: array title: Tools - description: The tools to use to generate the response - default: - - generate_video - - create_project - - describe_video - - get_source_media_duration - - web_search_preview + description: The tools to use to generate the response. Defaults to the + same as GET /settings (AVAILABLE_AGENT_TOOLS if set, else all tools). custom_agent_id: anyOf: - type: string @@ -982,11 +1090,165 @@ components: will be executed in parallel. If False, the tool calls will be executed sequentially. default: false + override_llm_verbosity: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Override Llm Verbosity + description: Override the verbosity of the LLM, range of number of tokens + that can be generated by the LLM, higher values yield more verbose responses. + override_llm_reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Override Llm Reasoning Effort + description: 'Override the reasoning effort of the LLM, controls reasoning + effort: higher values yield more precise but slower responses.' + max_mode: + type: boolean + title: Max Mode + description: Whether to enable max mode. If True, the agent will use the + maximum number of tokens allowed by the model. + default: false + no_interaction: + type: boolean + title: No Interaction + description: Whether to disable interaction with the agent. If True, the + agent will try to generate a response without interacting with the user. + default: false additionalProperties: false type: object required: - message title: AgentMessageRequest + AgentMessageRequestWithoutNoInteraction: + properties: + message: + type: string + title: Message + description: The message to send to the agent + chat_id: + anyOf: + - type: string + - type: 'null' + title: Chat Id + description: Optional chat ID for conversation tracking + previous_response_id: + anyOf: + - type: string + - type: 'null' + title: Previous Response Id + description: The ID of the previous response to the model. Use this to create + multi-turn conversations. + llm_verbosity: + type: string + enum: + - low + - medium + - high + title: Llm Verbosity + description: Range of number of tokens that can be generated by the LLM, + higher values yield more verbose responses. + default: low + llm_reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Llm Reasoning Effort + description: 'Controls reasoning effort: higher values yield more precise + but slower responses.' + default: low + context: + anyOf: + - $ref: '#/components/schemas/AppContext' + - type: 'null' + description: The app context when the response is sent. + llm_model: + type: string + enum: + - gpt-5.4-pro-2026-03-05 + - gpt-5.4-2026-03-05 + - gpt-5.3-codex + - gpt-5.2-2025-12-11 + - gpt-5-mini-2025-08-07 + title: Llm Model + description: The model to use to generate the response. + default: gpt-5.4-2026-03-05 + tools: + items: + type: string + type: array + title: Tools + description: The tools to use to generate the response. Defaults to the + same as GET /settings (AVAILABLE_AGENT_TOOLS if set, else all tools). + custom_agent_id: + anyOf: + - type: string + - type: 'null' + title: Custom Agent Id + description: The ID of the custom agent to use to generate the response. + If not provided, the default custom agent will be used. + parallel_tool_calls: + type: boolean + title: Parallel Tool Calls + description: Whether to allow parallel tool calls. If True, the tool calls + will be executed in parallel. If False, the tool calls will be executed + sequentially. + default: false + override_llm_verbosity: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Override Llm Verbosity + description: Override the verbosity of the LLM, range of number of tokens + that can be generated by the LLM, higher values yield more verbose responses. + override_llm_reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Override Llm Reasoning Effort + description: 'Override the reasoning effort of the LLM, controls reasoning + effort: higher values yield more precise but slower responses.' + max_mode: + type: boolean + title: Max Mode + description: Whether to enable max mode. If True, the agent will use the + maximum number of tokens allowed by the model. + default: false + additionalProperties: false + type: object + required: + - message + title: AgentMessageRequestWithoutNoInteraction AppContext: properties: current_playback_time: @@ -1035,6 +1297,64 @@ components: additionalProperties: false type: object title: AppContext + AppSettings: + properties: + maintenance_mode: + type: boolean + title: Maintenance Mode + hd_enabled: + type: boolean + title: Hd Enabled + promo_product_hunt_enabled: + type: boolean + title: Promo Product Hunt Enabled + cost_per_transcript_character: + type: number + title: Cost Per Transcript Character + cost_upload_video: + type: number + title: Cost Upload Video + cost_script_to_video: + type: number + title: Cost Script To Video + cost_prompt_to_video: + type: number + title: Cost Prompt To Video + cost_generate_clip_sequence: + type: number + title: Cost Generate Clip Sequence + cost_wan_ai_hd_generation: + type: number + title: Cost Wan Ai Hd Generation + cost_wan_ai_sd_generation: + type: number + title: Cost Wan Ai Sd Generation + available_agent_tools: + items: + additionalProperties: true + type: object + type: array + title: Available Agent Tools + available_llm_models: + items: + type: string + type: array + title: Available Llm Models + type: object + required: + - maintenance_mode + - hd_enabled + - promo_product_hunt_enabled + - cost_per_transcript_character + - cost_upload_video + - cost_script_to_video + - cost_prompt_to_video + - cost_generate_clip_sequence + - cost_wan_ai_hd_generation + - cost_wan_ai_sd_generation + - available_agent_tools + - available_llm_models + title: AppSettings AssetUploadRequest: properties: content_length: From 02a478821163c32d2f530a03a4b86080c272a66f Mon Sep 17 00:00:00 2001 From: bjay kamwa watanabe Date: Tue, 10 Mar 2026 17:04:53 +0100 Subject: [PATCH 2/4] Add json response to prompt --- src/cli.rs | 20 ++ src/commands/prompt.rs | 499 ++++++++++++++++++++++++++++++++++++--- src/main.rs | 24 +- src/tui/checkbox_list.rs | 140 +++++++++++ src/tui/mod.rs | 2 + 5 files changed, 644 insertions(+), 41 deletions(-) create mode 100644 src/tui/checkbox_list.rs diff --git a/src/cli.rs b/src/cli.rs index 87585e1..cad45c3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,6 +12,26 @@ pub struct Cli { #[arg(long)] pub background: bool, + /// Disable interaction with the agent (single response, no REPL). + #[arg(long)] + pub no_interaction: bool, + + /// Use JSON response endpoint (no_interaction implied; SSE tellers.json_result events). + #[arg(long)] + pub json_response: bool, + + /// Tool(s) to enable (can be repeated). Omit for default tools. + #[arg(long = "tool", value_name = "TOOL_ID")] + pub tools: Vec, + + /// LLM model to use (e.g. gpt-5.4-2026-03-05). + #[arg(long, value_name = "MODEL")] + pub llm_model: Option, + + /// Interactively set json_response, no_interaction, tools, and llm_model. + #[arg(short, long)] + pub interactive: bool, + #[arg()] pub prompt: Option, diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs index 69940c4..7483dcf 100644 --- a/src/commands/prompt.rs +++ b/src/commands/prompt.rs @@ -3,22 +3,250 @@ use std::io::{self, Write}; use std::sync::mpsc; use std::thread; use tellers_api_client::apis::configuration::Configuration; -use tellers_api_client::models::AgentMessageRequest; +use tellers_api_client::models::agent_message_request::LlmModel; +use tellers_api_client::models::agent_message_request_without_no_interaction::LlmModel as LlmModelJson; +use tellers_api_client::models::{AgentMessageRequest, AgentMessageRequestWithoutNoInteraction}; use crate::commands::api_config; -pub fn run_interactive(prompt_text: String, _full_auto: bool) -> Result<(), String> { +/// Options for the prompt/agent request (no_interaction, json_response, tools, llm_model). +#[derive(Clone, Default, Debug)] +pub struct PromptOptions { + pub no_interaction: bool, + pub json_response: bool, + pub tools: Option>, + pub llm_model: Option, +} + +impl PromptOptions { + pub fn from_cli( + no_interaction: bool, + json_response: bool, + tools: Vec, + llm_model: Option, + ) -> Self { + Self { + no_interaction, + json_response, + tools: if tools.is_empty() { None } else { Some(tools) }, + llm_model, + } + } + +} + +/// Run interactive option setup: prompt for json_response, no_interaction, tools, llm_model. +pub fn run_interactive_options( + base: &PromptOptions, +) -> Result { + let cfg = api_config::create_config(); + let api_key = api_config::get_api_key(None)?; + let bearer_header = api_config::get_bearer_header(None); + + let (models, tool_ids, tool_default_checked) = tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async { + fetch_models_and_tool_ids(&cfg, &api_key, bearer_header.as_deref()).await + })?; + + let mut opts = base.clone(); + let mut stdout = io::stdout(); + let stdin = io::stdin(); + + // json_response + print!("Use JSON response endpoint? [y/N]: "); + let _ = stdout.flush(); + let mut line = String::new(); + let _ = stdin.read_line(&mut line); + if line.trim().eq_ignore_ascii_case("y") || line.trim().eq_ignore_ascii_case("yes") { + opts.json_response = true; + } + + // no_interaction (skipped when using JSON endpoint; it implies no interaction) + if !opts.json_response { + print!("No interaction (single response, no REPL)? [y/N]: "); + let _ = stdout.flush(); + line.clear(); + let _ = stdin.read_line(&mut line); + if line.trim().eq_ignore_ascii_case("y") || line.trim().eq_ignore_ascii_case("yes") { + opts.no_interaction = true; + } + } + + // tools (checkbox list TUI, pre-checked from settings "enabled" field) + if !tool_ids.is_empty() { + match crate::tui::run_checkbox_list( + "Select tools (Space=toggle, Enter=confirm)", + tool_ids, + Some(tool_default_checked), + ) { + Ok(selected) => { + if !selected.is_empty() { + opts.tools = Some(selected); + } + } + Err(e) => return Err(e), + } + } + + // llm_model + if !models.is_empty() { + println!("Available LLM models:"); + for (i, m) in models.iter().enumerate() { + println!(" {}: {}", i + 1, m); + } + print!("Select model (number or Enter to use default): "); + let _ = stdout.flush(); + line.clear(); + let _ = stdin.read_line(&mut line); + let input = line.trim(); + if !input.is_empty() { + if let Ok(n) = input.parse::() { + if n >= 1 && n <= models.len() { + opts.llm_model = Some(models[n - 1].clone()); + } + } else { + opts.llm_model = Some(input.to_string()); + } + } + } + + Ok(opts) +} + +/// Fetches GET /settings and returns (available_llm_models, tool_ids, default_checked for tools). +/// Each tool in available_agent_tools can have "enabled": true/false; missing means enabled (checked by default). +async fn fetch_models_and_tool_ids( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, +) -> Result<(Vec, Vec, Vec), String> { + let settings = fetch_settings(cfg, api_key, bearer_opt).await?; + let models: Vec = settings + .get("available_llm_models") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let (tool_ids, default_checked): (Vec, Vec) = settings + .get("available_agent_tools") + .and_then(|v| v.as_array()) + .map(|arr| { + let mut ids = Vec::with_capacity(arr.len()); + let mut checked = Vec::with_capacity(arr.len()); + for o in arr.iter() { + let obj = o.as_object()?; + let id = obj + .get("id") + .or_else(|| obj.get("name")) + .and_then(|v| v.as_str()) + .map(String::from); + if let Some(id) = id { + let enabled = obj + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + ids.push(id); + checked.push(enabled); + } + } + Some((ids, checked)) + }) + .and_then(|x| x) + .unwrap_or_else(|| (Vec::new(), Vec::new())); + Ok((models, tool_ids, default_checked)) +} + +async fn fetch_settings( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, +) -> Result { + let url = format!("{}/settings", cfg.base_path); + let client = reqwest::Client::new(); + let mut req = client.get(&url).header("x-api-key", api_key); + if let Some(b) = bearer_opt { + req = req.header(AUTHORIZATION, b); + } + let resp = req + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("request failed: status {} body: {}", status, body)); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("invalid response: {}", e))?; + Ok(body) +} + +fn parse_llm_model(s: &str) -> Option { + serde_json::from_str::(&format!("\"{}\"", s)).ok() +} + +fn build_agent_request(message: String, opts: &PromptOptions) -> AgentMessageRequest { + let mut req = AgentMessageRequest::new(message); + req.no_interaction = Some(opts.no_interaction); + if let Some(ref tools) = opts.tools { + req.tools = Some(tools.clone()); + } + if let Some(ref model) = opts.llm_model { + if let Some(lm) = parse_llm_model(model) { + req.llm_model = Some(lm); + } + } + req +} + +fn parse_llm_model_json(s: &str) -> Option { + serde_json::from_str::(&format!("\"{}\"", s)).ok() +} + +fn build_agent_request_json( + message: String, + opts: &PromptOptions, +) -> AgentMessageRequestWithoutNoInteraction { + let mut req = AgentMessageRequestWithoutNoInteraction::new(message); + if let Some(ref tools) = opts.tools { + req.tools = Some(tools.clone()); + } + if let Some(ref model) = opts.llm_model { + if let Some(lm) = parse_llm_model_json(model) { + req.llm_model = Some(lm); + } + } + req +} + +pub fn run_interactive( + prompt_text: String, + _full_auto: bool, + opts: PromptOptions, +) -> Result<(), String> { let cfg = api_config::create_config(); let api_key = api_config::get_api_key(None)?; let bearer_header = api_config::get_bearer_header(None); + let opts_clone = opts.clone(); stream_and_print( &cfg, &api_key, bearer_header.as_deref(), prompt_text.clone(), + &opts_clone, )?; + if opts.no_interaction || opts.json_response { + return Ok(()); + } + // Simple REPL: user can reply; Ctrl-C exits process let mut stdout = io::stdout(); let stdin = io::stdin(); @@ -33,24 +261,83 @@ pub fn run_interactive(prompt_text: String, _full_auto: bool) -> Result<(), Stri if message.is_empty() { continue; } - stream_and_print(&cfg, &api_key, bearer_header.as_deref(), message)?; + let opts_loop = opts.clone(); + stream_and_print(&cfg, &api_key, bearer_header.as_deref(), message, &opts_loop)?; } Ok(()) } -pub fn run_background(prompt_text: String, _full_auto: bool) -> Result { +pub fn run_background( + prompt_text: String, + _full_auto: bool, + opts: PromptOptions, +) -> Result { let cfg = api_config::create_config(); let api_key = api_config::get_api_key(None)?; let bearer_header = api_config::get_bearer_header(None); - let request = AgentMessageRequest::new(prompt_text); + let request = if opts.json_response { + let req = build_agent_request_json(prompt_text, &opts); + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + send_agent_message_json(&cfg, &api_key, bearer_header.as_deref(), req).await + })? + } else { + let request = build_agent_request(prompt_text, &opts); + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + send_agent_message(&cfg, &api_key, bearer_header.as_deref(), request).await + })? + }; + Ok(request) +} - let rendered = tokio::runtime::Runtime::new() - .map_err(|e| format!("failed to start runtime: {}", e))? - .block_on(async move { - send_agent_message(&cfg, &api_key, bearer_header.as_deref(), request).await - })?; - Ok(rendered) +async fn send_agent_message_json( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, + request: AgentMessageRequestWithoutNoInteraction, +) -> Result { + let url = format!("{}/agent/response/json", cfg.base_path); + let client = reqwest::Client::new(); + let mut req = client + .post(&url) + .header("x-api-key", api_key) + .header(ACCEPT, "text/event-stream, application/json") + .header(CONTENT_TYPE, "application/json"); + if let Some(b) = bearer_opt { + req = req.header(AUTHORIZATION, b); + } + let resp = req + .json(&request) + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("request failed: status {} body: {}", status, body)); + } + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = resp.text().await.unwrap_or_default(); + if ctype.starts_with("text/event-stream") { + // Parse SSE and return only the last tellers.json_result payload. + let last = parse_last_json_result_from_sse(&text); + return Ok(last.unwrap_or(text)); + } + if ctype.contains("application/json") { + if let Ok(json) = serde_json::from_str::(&text) { + return Ok(serde_json::to_string_pretty(&json).unwrap_or(text)); + } + } + Ok(text) } async fn send_agent_message( @@ -114,7 +401,7 @@ async fn send_agent_message_stream( let url = format!("{}/agent/response", cfg.base_path); let client = reqwest::Client::new(); let mut req = client - .post(url) + .post(&url) .header("x-api-key", api_key) .header(ACCEPT, "text/event-stream, application/json") .header(CONTENT_TYPE, "application/json"); @@ -151,13 +438,11 @@ async fn send_agent_message_stream( let mut inserted_gap = false; while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? { buffer.push_str(&String::from_utf8_lossy(&chunk)); - // Normalize line endings let normalized = buffer.replace("\r\n", "\n"); let mut parts = normalized .split("\n\n") .map(|s| s.to_string()) .collect::>(); - // If the buffer doesn't end with a full event (double newline), keep the last partial buffer = match parts.pop() { Some(tail) if !normalized.ends_with("\n\n") => tail, Some(_) => String::new(), @@ -189,9 +474,7 @@ async fn send_agent_message_stream( let _ = tx.send(delta.to_string()); } } - // Suppress all non-delta JSON events (no raw JSON output) } - // Suppress non-JSON or unexpected events silently as well } } } @@ -210,36 +493,182 @@ async fn send_agent_message_stream( Ok(()) } +/// Parse raw SSE text and return the last `tellers.json_result` event payload as pretty JSON. +fn parse_last_json_result_from_sse(sse_text: &str) -> Option { + let normalized = sse_text.replace("\r\n", "\n"); + let mut last_data: Option = None; + for ev in normalized.split("\n\n") { + let mut event_name: Option = None; + let mut data_lines: Vec<&str> = Vec::new(); + for line in ev.lines() { + if let Some(rest) = line.strip_prefix("event:") { + event_name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim_start()); + } + } + let data_str = data_lines.join("\n"); + if event_name.as_deref().map_or(false, |n| n.contains("tellers.json_result")) { + if let Ok(v) = serde_json::from_str::(&data_str) { + last_data = Some(serde_json::to_string_pretty(&v).unwrap_or(data_str)); + } + } + } + last_data +} + +async fn send_agent_message_stream_json( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, + request: AgentMessageRequestWithoutNoInteraction, + tx: mpsc::Sender, +) -> Result<(), String> { + let url = format!("{}/agent/response/json", cfg.base_path); + let client = reqwest::Client::new(); + let mut req = client + .post(&url) + .header("x-api-key", api_key) + .header(ACCEPT, "text/event-stream, application/json") + .header(CONTENT_TYPE, "application/json"); + if let Some(b) = bearer_opt { + req = req.header(AUTHORIZATION, b); + } + + let mut resp = req + .json(&request) + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let _ = tx.send(format!( + "request failed: status {} body: {}\n", + status, body + )); + return Err("request failed".to_string()); + } + + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if ctype.starts_with("text/event-stream") { + let mut buffer = String::new(); + let mut last_json_result: Option = None; + while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + let normalized = buffer.replace("\r\n", "\n"); + let mut parts = normalized + .split("\n\n") + .map(|s| s.to_string()) + .collect::>(); + buffer = match parts.pop() { + Some(tail) if !normalized.ends_with("\n\n") => tail, + Some(_) => String::new(), + None => String::new(), + }; + for ev in parts { + let mut event_name: Option = None; + let mut data_lines: Vec = Vec::new(); + for line in ev.lines() { + if let Some(rest) = line.strip_prefix("event:") { + event_name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim_start().to_string()); + } + } + let data_str = data_lines.join("\n"); + if let Some(name) = &event_name { + if name.contains("tellers.json_result") { + if let Ok(v) = serde_json::from_str::(&data_str) { + let pretty = serde_json::to_string_pretty(&v).unwrap_or(data_str); + last_json_result = Some(pretty); + } + } + } + } + } + if let Some(pretty) = last_json_result { + let _ = tx.send(pretty); + let _ = tx.send("\n".to_string()); + } + return Ok(()); + } + + let text = resp.text().await.unwrap_or_default(); + if ctype.contains("application/json") { + if let Ok(json) = serde_json::from_str::(&text) { + let pretty = serde_json::to_string_pretty(&json).unwrap_or(text); + let _ = tx.send(pretty); + return Ok(()); + } + } + let _ = tx.send(text); + Ok(()) +} + fn stream_and_print( cfg: &Configuration, api_key: &str, bearer_opt: Option<&str>, message: String, + opts: &PromptOptions, ) -> Result<(), String> { let (tx, rx) = mpsc::channel::(); let cfg_clone = cfg.clone(); let api_key_clone = api_key.to_string(); let bearer_clone = bearer_opt.map(|s| s.to_string()); - thread::spawn(move || { - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(err) => { - let _ = tx.send(format!("runtime error: {}\n", err)); - return; - } - }; - let request = AgentMessageRequest::new(message); - rt.block_on(async move { - let _ = send_agent_message_stream( - &cfg_clone, - &api_key_clone, - bearer_clone.as_deref(), - request, - tx, - ) - .await; + let opts_clone = opts.clone(); + + if opts.json_response { + let request = build_agent_request_json(message, opts); + thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(err) => { + let _ = tx.send(format!("runtime error: {}\n", err)); + return; + } + }; + rt.block_on(async move { + let _ = send_agent_message_stream_json( + &cfg_clone, + &api_key_clone, + bearer_clone.as_deref(), + request, + tx, + ) + .await; + }); + }); + } else { + let request = build_agent_request(message, &opts_clone); + thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(err) => { + let _ = tx.send(format!("runtime error: {}\n", err)); + return; + } + }; + rt.block_on(async move { + let _ = send_agent_message_stream( + &cfg_clone, + &api_key_clone, + bearer_clone.as_deref(), + request, + tx, + ) + .await; + }); }); - }); + } let mut stdout = io::stdout(); while let Ok(chunk) = rx.recv() { diff --git a/src/main.rs b/src/main.rs index 15c8619..605a1a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,19 +35,31 @@ fn main() { } None => { if let Some(prompt_text) = cli.prompt { - if cli.background { - match commands::prompt::run_background(prompt_text, cli.full_auto) { - Ok(chat_id) => { - // Only print chat id in background mode - println!("{}", chat_id); + let mut opts = commands::prompt::PromptOptions::from_cli( + cli.no_interaction, + cli.json_response, + cli.tools.clone(), + cli.llm_model.clone(), + ); + if cli.interactive { + match commands::prompt::run_interactive_options(&opts) { + Ok(interactive_opts) => opts = interactive_opts, + Err(error) => { + eprintln!("error: {}", error); + std::process::exit(1); } + } + } + if cli.background { + match commands::prompt::run_background(prompt_text, cli.full_auto, opts) { + Ok(result) => println!("{}", result), Err(error) => { eprintln!("error: {}", error); std::process::exit(1); } } } else if let Err(error) = - commands::prompt::run_interactive(prompt_text, cli.full_auto) + commands::prompt::run_interactive(prompt_text, cli.full_auto, opts) { eprintln!("error: {}", error); std::process::exit(1); diff --git a/src/tui/checkbox_list.rs b/src/tui/checkbox_list.rs new file mode 100644 index 0000000..cc4b4f2 --- /dev/null +++ b/src/tui/checkbox_list.rs @@ -0,0 +1,140 @@ +//! A simple TUI checkbox list for multi-select. Space toggles, Enter confirms. + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Layout}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use std::io; + +/// Run a checkbox list TUI. Returns the selected items (those with checkbox checked). +/// - Space: toggle current item +/// - a: toggle all +/// - Enter: confirm and return selected +/// - q / Esc: confirm and return current selection (same as Enter) +/// +/// `default_checked`: initial checkbox state per item (true = checked). If shorter than +/// `items`, remaining items are unchecked; if longer, extra values are ignored. +pub fn run_checkbox_list( + title: &str, + items: Vec, + default_checked: Option>, +) -> Result, String> { + if items.is_empty() { + return Ok(Vec::new()); + } + + let mut stdout = io::stdout(); + enable_raw_mode().map_err(|e| format!("terminal: {}", e))?; + execute!(stdout, EnterAlternateScreen).map_err(|e| format!("terminal: {}", e))?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal: {}", e))?; + + let mut checked: Vec = if let Some(d) = default_checked { + (0..items.len()) + .map(|i| d.get(i).copied().unwrap_or(false)) + .collect() + } else { + items.iter().map(|_| false).collect() + }; + let mut list_state = ListState::default(); + list_state.select(Some(0)); + + let result = loop { + terminal + .draw(|f| draw_checkbox_list(f, title, &items, &checked, &mut list_state)) + .map_err(|e| format!("draw: {}", e))?; + + if let Event::Key(key) = event::read().map_err(|e| format!("event: {}", e))? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break collect_selected(&items, &checked), + KeyCode::Enter => break collect_selected(&items, &checked), + KeyCode::Up => { + let i = list_state.selected().unwrap_or(0); + list_state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = list_state.selected().unwrap_or(0); + let next = (i + 1).min(items.len().saturating_sub(1)); + list_state.select(Some(next)); + } + KeyCode::Char(' ') => { + if let Some(i) = list_state.selected() { + if i < checked.len() { + checked[i] = !checked[i]; + } + } + } + KeyCode::Char('a') | KeyCode::Char('A') => { + let all = checked.iter().all(|&c| c); + for c in &mut checked { + *c = !all; + } + } + _ => {} + } + } + }; + + execute!(io::stdout(), LeaveAlternateScreen).map_err(|e| format!("terminal: {}", e))?; + disable_raw_mode().map_err(|e| format!("terminal: {}", e))?; + terminal.show_cursor().map_err(|e| format!("terminal: {}", e))?; + + Ok(result) +} + +fn draw_checkbox_list( + f: &mut Frame, + title: &str, + items: &[String], + checked: &[bool], + list_state: &mut ListState, +) { + let area = f.area(); + let chunks = Layout::default() + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(area); + + let list_items: Vec = items + .iter() + .enumerate() + .map(|(i, label)| { + let mark = if checked.get(i).copied().unwrap_or(false) { + "[x]" + } else { + "[ ]" + }; + ListItem::new(Line::from(format!("{} {}", mark, label))) + }) + .collect(); + + let list = List::new(list_items) + .block(Block::default().title(title).borders(Borders::ALL)) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, chunks[0], list_state); + + let help = Paragraph::new("↑/↓ move Space toggle a toggle all Enter confirm q quit"); + f.render_widget(help, chunks[1]); +} + +fn collect_selected(items: &[String], checked: &[bool]) -> Vec { + items + .iter() + .zip(checked.iter().copied()) + .filter_map(|(id, c)| if c { Some(id.clone()) } else { None }) + .collect() +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1b53090..7cccae7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,7 @@ +mod checkbox_list; mod inline_progress; mod two_queue_progress; +pub use checkbox_list::run_checkbox_list; pub use inline_progress::{InlineProgress, ProgressHandle}; pub use two_queue_progress::{TwoQueueProgress, TwoQueueProgressHandle}; From ff7c5d9047233d9a1fd78b2489451425f1008500 Mon Sep 17 00:00:00 2001 From: bjay kamwa watanabe Date: Tue, 10 Mar 2026 17:08:58 +0100 Subject: [PATCH 3/4] update README --- README.md | 40 +++++++++++++++++++++++++++++++++++++--- src/commands/prompt.rs | 12 ------------ src/tui/checkbox_list.rs | 10 ---------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 61e0e92..868d609 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,44 @@ export TELLERS_API_BASE=https://api.tellers.ai ## Usage -### Chat Commands +### Chat / Prompt -- `tellers "prompt"` — displays a minimal chat TUI from a streamed response -- `tellers --full-auto --background "prompt"` — starts a chat and prints only the chat id +Run a prompt against the Tellers agent: + +- **`tellers "prompt"`** — Streams the response and starts a minimal REPL (reply in the terminal; Ctrl-C to exit). +- **`tellers --background "prompt"`** — Single request, no REPL; prints the response text (or last JSON result when using `--json-response`). +- **`tellers --full-auto --background "prompt"`** — Same as `--background` with full-auto behavior. + +**Prompt options:** + +| Flag | Description | +|------|-------------| +| `--no-interaction` | Single response only, no REPL. | +| `--json-response` | Use the JSON endpoint; output is the last `tellers.json_result` event (no interaction implied). | +| `--tool ` | Enable a tool (repeat for multiple). Omit to use default tools from settings. | +| `--llm-model ` | LLM model (e.g. `gpt-5.4-2026-03-05`). | +| `--interactive`, `-i` | Interactively set options: JSON response (y/N), no interaction (y/N), tool selection (checkbox list), and LLM model (list). | + +**Examples:** + +```bash +# Streamed chat with REPL +tellers "Generate a video, with cats" + +# Single response, no follow-up +tellers --no-interaction "Generate a video, with stock footage video of cats" + +# JSON endpoint: only the last JSON result is printed +tellers --json-response "Generate a video, with stock footage video of cats" + +# Choose model and tools via flags +tellers --llm-model gpt-5.4-2026-03-05 --tool tool_a --tool tool_b "Your prompt" + +# Interactive: prompts for JSON, no-interaction, checkbox list for tools, model picker +tellers -i "Generate a video, with stock footage video of cats" +``` + +**Interactive tool selection:** When you use `-i` and the settings include available tools, a TUI checkbox list is shown. Use **↑/↓** to move, **Space** to toggle, **a** to toggle all, **Enter** to confirm. Checkboxes are pre-set from each tool’s `enabled` field in the settings JSON (missing = enabled). ### Upload Command diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs index 7483dcf..7789057 100644 --- a/src/commands/prompt.rs +++ b/src/commands/prompt.rs @@ -9,7 +9,6 @@ use tellers_api_client::models::{AgentMessageRequest, AgentMessageRequestWithout use crate::commands::api_config; -/// Options for the prompt/agent request (no_interaction, json_response, tools, llm_model). #[derive(Clone, Default, Debug)] pub struct PromptOptions { pub no_interaction: bool, @@ -35,7 +34,6 @@ impl PromptOptions { } -/// Run interactive option setup: prompt for json_response, no_interaction, tools, llm_model. pub fn run_interactive_options( base: &PromptOptions, ) -> Result { @@ -53,7 +51,6 @@ pub fn run_interactive_options( let mut stdout = io::stdout(); let stdin = io::stdin(); - // json_response print!("Use JSON response endpoint? [y/N]: "); let _ = stdout.flush(); let mut line = String::new(); @@ -62,7 +59,6 @@ pub fn run_interactive_options( opts.json_response = true; } - // no_interaction (skipped when using JSON endpoint; it implies no interaction) if !opts.json_response { print!("No interaction (single response, no REPL)? [y/N]: "); let _ = stdout.flush(); @@ -73,7 +69,6 @@ pub fn run_interactive_options( } } - // tools (checkbox list TUI, pre-checked from settings "enabled" field) if !tool_ids.is_empty() { match crate::tui::run_checkbox_list( "Select tools (Space=toggle, Enter=confirm)", @@ -89,7 +84,6 @@ pub fn run_interactive_options( } } - // llm_model if !models.is_empty() { println!("Available LLM models:"); for (i, m) in models.iter().enumerate() { @@ -114,8 +108,6 @@ pub fn run_interactive_options( Ok(opts) } -/// Fetches GET /settings and returns (available_llm_models, tool_ids, default_checked for tools). -/// Each tool in available_agent_tools can have "enabled": true/false; missing means enabled (checked by default). async fn fetch_models_and_tool_ids( cfg: &Configuration, api_key: &str, @@ -247,7 +239,6 @@ pub fn run_interactive( return Ok(()); } - // Simple REPL: user can reply; Ctrl-C exits process let mut stdout = io::stdout(); let stdin = io::stdin(); loop { @@ -328,7 +319,6 @@ async fn send_agent_message_json( .to_string(); let text = resp.text().await.unwrap_or_default(); if ctype.starts_with("text/event-stream") { - // Parse SSE and return only the last tellers.json_result payload. let last = parse_last_json_result_from_sse(&text); return Ok(last.unwrap_or(text)); } @@ -377,7 +367,6 @@ async fn send_agent_message( .to_string(); if ctype.starts_with("text/event-stream") { - // Collect the full SSE stream and return raw text for display let text = resp.text().await.unwrap_or_default(); return Ok(text); } @@ -493,7 +482,6 @@ async fn send_agent_message_stream( Ok(()) } -/// Parse raw SSE text and return the last `tellers.json_result` event payload as pretty JSON. fn parse_last_json_result_from_sse(sse_text: &str) -> Option { let normalized = sse_text.replace("\r\n", "\n"); let mut last_data: Option = None; diff --git a/src/tui/checkbox_list.rs b/src/tui/checkbox_list.rs index cc4b4f2..8685e56 100644 --- a/src/tui/checkbox_list.rs +++ b/src/tui/checkbox_list.rs @@ -1,5 +1,3 @@ -//! A simple TUI checkbox list for multi-select. Space toggles, Enter confirms. - use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, execute, @@ -15,14 +13,6 @@ use ratatui::{ }; use std::io; -/// Run a checkbox list TUI. Returns the selected items (those with checkbox checked). -/// - Space: toggle current item -/// - a: toggle all -/// - Enter: confirm and return selected -/// - q / Esc: confirm and return current selection (same as Enter) -/// -/// `default_checked`: initial checkbox state per item (true = checked). If shorter than -/// `items`, remaining items are unchecked; if longer, extra values are ignored. pub fn run_checkbox_list( title: &str, items: Vec, From c41f9c3e888f81daa66f42ddc3beb28ba49dd299 Mon Sep 17 00:00:00 2001 From: bjay kamwa watanabe Date: Tue, 10 Mar 2026 18:06:25 +0100 Subject: [PATCH 4/4] Remove information --- .../openapi.tellers_public_api.yaml | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/src/tellers_api/openapi.tellers_public_api.yaml b/src/tellers_api/openapi.tellers_public_api.yaml index eabf28b..2366c77 100644 --- a/src/tellers_api/openapi.tellers_public_api.yaml +++ b/src/tellers_api/openapi.tellers_public_api.yaml @@ -1305,30 +1305,6 @@ components: hd_enabled: type: boolean title: Hd Enabled - promo_product_hunt_enabled: - type: boolean - title: Promo Product Hunt Enabled - cost_per_transcript_character: - type: number - title: Cost Per Transcript Character - cost_upload_video: - type: number - title: Cost Upload Video - cost_script_to_video: - type: number - title: Cost Script To Video - cost_prompt_to_video: - type: number - title: Cost Prompt To Video - cost_generate_clip_sequence: - type: number - title: Cost Generate Clip Sequence - cost_wan_ai_hd_generation: - type: number - title: Cost Wan Ai Hd Generation - cost_wan_ai_sd_generation: - type: number - title: Cost Wan Ai Sd Generation available_agent_tools: items: additionalProperties: true @@ -1344,14 +1320,6 @@ components: required: - maintenance_mode - hd_enabled - - promo_product_hunt_enabled - - cost_per_transcript_character - - cost_upload_video - - cost_script_to_video - - cost_prompt_to_video - - cost_generate_clip_sequence - - cost_wan_ai_hd_generation - - cost_wan_ai_sd_generation - available_agent_tools - available_llm_models title: AppSettings