From fe2e9169371bf7a3cc9391d8467d47fc2258ad1f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 20 Jul 2026 14:30:36 +0700 Subject: [PATCH] feat: add test_load and test_parse binaries for configuration loading and parsing --- apps/gateway/Cargo.toml | 5 + apps/gateway/src/bin/test_load.rs | 21 ++ apps/gateway/src/bin/test_parse.rs | 43 +++ .../src/persistence/cms/app_config_repo.rs | 65 +++-- apps/interfaces/api/src/handlers/chat.rs | 15 +- apps/interfaces/tui/src/action.rs | 14 +- apps/interfaces/tui/src/run.rs | 7 +- apps/interfaces/tui/src/state.rs | 12 +- apps/interfaces/tui/src/turn.rs | 46 ++- apps/interfaces/tui/src/view/chat.rs | 263 +++++++++--------- 10 files changed, 318 insertions(+), 173 deletions(-) create mode 100644 apps/gateway/src/bin/test_load.rs create mode 100644 apps/gateway/src/bin/test_parse.rs diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index 0470e12..105f0e9 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -42,3 +42,8 @@ dirs.workspace = true rusqlite.workspace = true axum.workspace = true clap = { version = "4", features = ["derive"] } + +[[bin]] +name = "test_load" +path = "src/bin/test_load.rs" + diff --git a/apps/gateway/src/bin/test_load.rs b/apps/gateway/src/bin/test_load.rs new file mode 100644 index 0000000..2f14d80 --- /dev/null +++ b/apps/gateway/src/bin/test_load.rs @@ -0,0 +1,21 @@ +use zesdex_domain::cms::AppConfigRepository; +use zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository; +use std::path::PathBuf; + +fn main() { + let base_dir = dirs::home_dir().unwrap().join(".local/share/zesdex"); + let repo = JsonAppConfigRepository::new(); + let config = repo.load(&base_dir).unwrap(); + + println!("Providers:"); + for (k, v) in &config.providers { + println!(" - {} (default model: {:?})", k, v.default_model); + } + + println!("Default provider: {}", config.default_provider); + println!("Default model: {}", config.default_model); + println!("Model roles:"); + for (k, v) in &config.model_roles { + println!(" - {}: provider={}, model={}", k, v.provider, v.model); + } +} diff --git a/apps/gateway/src/bin/test_parse.rs b/apps/gateway/src/bin/test_parse.rs new file mode 100644 index 0000000..092852b --- /dev/null +++ b/apps/gateway/src/bin/test_parse.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClaudeEnv { + #[serde(alias = "ANTHROPIC_BASE_URL")] + anthropic_base_url: Option, + #[serde(alias = "ANTHROPIC_API_KEY")] + anthropic_api_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClaudeSettings { + env: Option, + #[serde(alias = "customModel", alias = "model")] + custom_model: Option, +} + +fn main() { + let path = dirs::home_dir().unwrap().join(".claude").join("settings.json"); + println!("Path: {:?}", path); + match std::fs::read_to_string(&path) { + Ok(content) => { + println!("File content length: {}", content.len()); + match serde_json::from_str::(&content) { + Ok(settings) => { + println!("Parsed successfully: {:?}", settings); + if let Some(env) = settings.env { + println!("Base URL: {:?}", env.anthropic_base_url); + println!("API Key: {:?}", env.anthropic_api_key); + } else { + println!("No env block"); + } + } + Err(e) => { + println!("Parse error: {}", e); + } + } + } + Err(e) => { + println!("Read error: {}", e); + } + } +} diff --git a/apps/infrastructure/src/persistence/cms/app_config_repo.rs b/apps/infrastructure/src/persistence/cms/app_config_repo.rs index 4dc71a6..2175503 100644 --- a/apps/infrastructure/src/persistence/cms/app_config_repo.rs +++ b/apps/infrastructure/src/persistence/cms/app_config_repo.rs @@ -28,32 +28,43 @@ struct ClaudeEnv { #[derive(Debug, Clone, Serialize, Deserialize)] struct ClaudeSettings { env: Option, + #[serde(alias = "customModel", alias = "model")] + custom_model: Option, } -fn claude_credentials_from_file() -> Option<(String, String)> { +fn claude_settings_from_file() -> Option { let path = dirs::home_dir()?.join(".claude").join("settings.json"); let content = std::fs::read_to_string(&path).ok()?; - let settings: ClaudeSettings = serde_json::from_str(&content).ok()?; - let env = settings.env?; - let base_url = env.anthropic_base_url?; - let key = env.anthropic_api_key?; - Some((base_url, key)) + serde_json::from_str(&content).ok() } -fn claude_credentials_from_env() -> Option<(String, String)> { - let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; - let key = std::env::var("ANTHROPIC_API_KEY").ok()?; - Some((base_url, key)) -} +fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option)> { + let settings = claude_settings_from_file(); + + let file_creds = settings.as_ref().and_then(|s| { + let env = s.env.as_ref()?; + Some((env.anthropic_base_url.clone()?, env.anthropic_api_key.clone()?)) + }); + + let env_creds = || -> Option<(String, String)> { + let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; + let key = std::env::var("ANTHROPIC_API_KEY").ok()?; + Some((base_url, key)) + }; -fn detect_claude_settings_provider() -> Option { - let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?; - Some(ProviderConfig { - api_base: base_url, - api_key_env: Some("ANTHROPIC_API_KEY".to_string()), - default_model: None, - default_api_key: Some(key), - }) + let custom_model = settings.and_then(|s| s.custom_model); + + let (base_url, key) = file_creds.or_else(env_creds)?; + + Some(( + ProviderConfig { + api_base: base_url, + api_key_env: Some("ANTHROPIC_API_KEY".to_string()), + default_model: custom_model.clone(), + default_api_key: Some(key), + }, + custom_model + )) } impl AppConfigRepository for JsonAppConfigRepository { @@ -72,7 +83,7 @@ impl AppConfigRepository for JsonAppConfigRepository { cfg.providers.entry(name).or_insert(provider); } - if let Some(claude_provider) = detect_claude_settings_provider() { + if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() { cfg.providers .entry("claude".to_string()) .or_insert(claude_provider); @@ -94,9 +105,21 @@ impl AppConfigRepository for JsonAppConfigRepository { }); } + if let Some(custom) = &custom_model { + cfg.model_roles + .entry(custom.clone()) + .or_insert(ModelRole { + provider: "claude".to_string(), + model: custom.clone(), + max_tokens: Some(8192), + context_window: Some(200_000), + temperature: Some(0.7), + }); + } + if cfg.default_provider == defaults.default_provider { cfg.default_provider = "claude".to_string(); - cfg.default_model = "claude-opus-4-8".to_string(); + cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string()); } } diff --git a/apps/interfaces/api/src/handlers/chat.rs b/apps/interfaces/api/src/handlers/chat.rs index 84746de..4623c88 100644 --- a/apps/interfaces/api/src/handlers/chat.rs +++ b/apps/interfaces/api/src/handlers/chat.rs @@ -112,18 +112,21 @@ pub async fn chat_completions_handler( // When the requested model matches the shared client we reuse it to // avoid allocating a new HTTP connection. Otherwise we create a // temporary LlmClient with the requested model — the ownership - // lives on the stack via `temp_client`. - #[allow(unused_assignments)] - let mut temp_client: Option = None; + // lives on the stack via a match binding. + // + // We use an uninitialized let-binding for `temp_client` so the + // compiler can see it's assigned at most once, avoiding + // `#[allow(unused_assignments)]`. + let temp_client; let llm_client = if model == state.llm_client.model { &state.llm_client } else { - temp_client = Some(zesdex_infrastructure::llm::LlmClient::new( + temp_client = zesdex_infrastructure::llm::LlmClient::new( state.llm_client.api_key.clone(), model, Some(state.llm_client.base_url.clone()), - )); - temp_client.as_ref().unwrap() + ); + &temp_client }; let (response, usage) = llm_client diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs index be220a9..c2752cc 100644 --- a/apps/interfaces/tui/src/action.rs +++ b/apps/interfaces/tui/src/action.rs @@ -121,6 +121,8 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { .map(|mut q| q.drain(..).collect()) .unwrap_or_default(); + let had_events = !events.is_empty(); + for event in events { match event { zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => { @@ -163,6 +165,8 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { } zesdex_infrastructure::TurnEvent::Done => { state.turn_in_flight_flag.store(false, std::sync::atomic::Ordering::SeqCst); + // Mark dirty so spinner disappears + state.dirty = true; } _ => { tracing::debug!("unhandled turn event variant"); @@ -170,8 +174,14 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { } } } - // Tick increments counter only; toast expiry handled in run_loop - state.mark_dirty(); + + // Mark dirty only when there's something that changed: + // - new events were processed (messages, errors, etc.) + // - turn is in-flight (spinner needs to animate each tick) + // When idle with no events, skip the dirty flag to avoid useless renders. + if had_events || state.turn_in_flight() { + state.mark_dirty(); + } } Action::SubmitInput(text) => { // Push user message to transcript display diff --git a/apps/interfaces/tui/src/run.rs b/apps/interfaces/tui/src/run.rs index d10a9a5..c5532f1 100644 --- a/apps/interfaces/tui/src/run.rs +++ b/apps/interfaces/tui/src/run.rs @@ -174,14 +174,19 @@ fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> std::fs::create_dir_all(&session_dir)?; // Load real settings from disk - use zesdex_domain::SettingsRepository; + use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; let settings = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new() .load(&store.base_dir) .unwrap_or_default(); + let app_config = zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); + let workspace_roots = vec![std::env::current_dir()?]; let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone()); state.settings = settings; + state.app_config = app_config; Ok((store, state)) } diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index e82e4ed..c718909 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -837,7 +837,7 @@ pub struct AppStateRest { /// Uses AtomicBool for lock-free check from render loop. pub turn_in_flight_flag: Arc, /// Cached display lines for the chat transcript panel. - /// Rebuilt only when transcript_cache.dirty=true or terminal width changes. + /// Rebuilt incrementally — only new messages are appended, not full rebuild. pub display_lines_cache: Vec>, /// Cached token count for the current message history. /// Updated lazily only when new messages arrive, not every frame. @@ -846,6 +846,12 @@ pub struct AppStateRest { pub token_count_dirty: bool, /// Terminal width at the time of the last display_lines_cache rebuild. pub last_render_width: u16, + /// Number of messages that were in the cache when it was last built. + /// Used to detect incremental vs full rebuild requirement. + pub cached_msg_count: usize, + /// Terminal width at the time of the last full cache build. + /// If this differs from last_render_width, a full rebuild is needed. + pub render_width_at_cache: u16, /// Atomic flag set when the user aborts the current turn. pub abort_flag: Arc, /// Simplified workflow engine state for display. @@ -920,6 +926,8 @@ impl Default for AppStateRest { cached_token_count: 0, token_count_dirty: true, last_render_width: 0, + cached_msg_count: 0, + render_width_at_cache: 0, } } } @@ -971,6 +979,8 @@ impl AppStateRest { cached_token_count: 0, token_count_dirty: true, last_render_width: 0, + cached_msg_count: 0, + render_width_at_cache: 0, } } diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index 3dd5d9e..cfc02fa 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -33,6 +33,29 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { let session_dir = state.session_dir.clone(); let workspace_roots = state.workspace_roots.clone(); + // Resolve LLM provider configuration from settings + let provider_name = &state.settings.provider; + let provider_cfg = state.app_config.providers.get(provider_name).cloned(); + + let mut api_key = String::new(); + if let Some(key) = state.settings.api_keys.get(provider_name) { + api_key = key.clone(); + } else if let Some(ref cfg) = provider_cfg { + if let Some(ref default_key) = cfg.default_api_key { + api_key = default_key.clone(); + } + if api_key.is_empty() { + if let Some(ref env_name) = cfg.api_key_env { + if let Ok(val) = std::env::var(env_name) { + api_key = val; + } + } + } + } + + let model = state.settings.model.clone(); + let api_base = provider_cfg.map(|cfg| cfg.api_base.clone()); + let mut messages: Vec = state .session_runtime .as_ref() @@ -44,10 +67,20 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { rt.messages = messages.clone(); } - info!("spawning agent turn with {} messages", messages.len()); + info!("spawning agent turn with {} messages (model: {})", messages.len(), model); std::thread::spawn(move || { - run_turn(&mut messages, &session_dir, &workspace_roots, &turn_events, &in_flight, &abort); + run_turn( + &mut messages, + &session_dir, + &workspace_roots, + &turn_events, + &in_flight, + &abort, + api_key, + model, + api_base, + ); }); } @@ -59,12 +92,11 @@ fn run_turn( turn_events: &Arc>>, in_flight: &Arc, abort: &Arc, + api_key: String, + model: String, + api_base: Option, ) { - let client = LlmClient::new( - String::new(), // API key resolved internally from env - "deepseek-v4-flash-free".to_string(), - Some("https://opencode.ai/zen/v1".to_string()), - ); + let client = LlmClient::new(api_key, model, api_base); let tools = all_tools(); let defs = tool_defs(&tools); diff --git a/apps/interfaces/tui/src/view/chat.rs b/apps/interfaces/tui/src/view/chat.rs index 51e19c6..2e81a15 100644 --- a/apps/interfaces/tui/src/view/chat.rs +++ b/apps/interfaces/tui/src/view/chat.rs @@ -1,15 +1,10 @@ //! Chat transcript panel rendering — tight inline log style. //! -//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense, -//! log-like transcript: each non-tool message gets a one-line -//! `{role} {time} {content}` header with wrapped continuation lines -//! aligned under the content column; `Role::Tool` messages render as a -//! dim `↳`-prefixed sub-line attached to whatever came before. -//! -//! Performance: rendered lines are cached in `state.display_lines_cache` and -//! only rebuilt when `transcript_cache.dirty == true` or the terminal width -//! changes. The cache is updated in `pre_render_chat` (called from `view::pre_render` -//! before the immutable draw pass) to avoid borrow conflicts. +//! Performance design: +//! - `display_lines_cache` menyimpan Vec per pesan (indexed by message position) +//! - Saat pesan baru masuk, hanya pesan BARU yang di-render, bukan rebuild seluruh history +//! - `draw_chat` tidak pernah clone seluruh cache — hanya slice window visible yang di-ambil +//! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan use super::theme::Theme; use ratatui::layout::Rect; @@ -49,101 +44,122 @@ fn format_timestamp(ts: i64) -> String { format!("{hrs:02}:{mins:02}") } -/// Rebuild the full list of display lines from the transcript cache. -/// -/// This is the expensive operation — markdown parsing, span building, etc. -/// Only called from `pre_render_chat` when cache is stale. -fn build_display_lines( - messages: &std::collections::VecDeque, +/// Render satu pesan menjadi Vec>. +/// Dipanggil incremental — hanya untuk pesan baru, bukan seluruh history. +fn render_one_message( + msg: &crate::state::ChatMessageDisplay, content_width: u16, ) -> Vec> { - let mut display_lines: Vec> = Vec::new(); + let mut lines: Vec> = Vec::new(); - for msg in messages { - if msg.role == Role::Tool { - let content = if msg.content.trim().is_empty() { - "(tool execution)".to_string() - } else { - msg.content.clone() - }; - let dim = Style::default().fg(Theme::TEXT_DIM); - let content_spans = super::markdown::render_markdown(&content, content_width, true); - let content_lines = split_spans_into_lines(content_spans); - let mut lines_iter = content_lines.into_iter(); - let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans); - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)]; - spans.extend(first_spans); - display_lines.push(Line::from(spans)); - for line in lines_iter { - let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; - spans.extend(line.spans); - display_lines.push(Line::from(spans)); - } - continue; - } - - let accent = role_accent_color(&msg.role); - let label = format_role_label(&msg.role); - let ts_str = format_timestamp(msg.timestamp); - let header_prefix = vec![ - Span::styled( - format!("{label} "), - Style::default().fg(accent).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{ts_str:<5} "), - Style::default().fg(Theme::TEXT_DIM), - ), - ]; - - let content_str = if msg.content.trim().is_empty() { + if msg.role == Role::Tool { + let content = if msg.content.trim().is_empty() { "(tool execution)".to_string() } else { msg.content.clone() }; - - let content_spans = super::markdown::render_markdown(&content_str, content_width, false); + let dim = Style::default().fg(Theme::TEXT_DIM); + let content_spans = super::markdown::render_markdown(&content, content_width, true); let content_lines = split_spans_into_lines(content_spans); let mut lines_iter = content_lines.into_iter(); - - if let Some(first) = lines_iter.next() { - let mut spans = header_prefix; - spans.extend(first.spans); - display_lines.push(Line::from(spans)); - } else { - display_lines.push(Line::from(header_prefix)); - } - + let first_spans = lines_iter.next().map_or_else(Vec::new, |l| l.spans); + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)]; + spans.extend(first_spans); + lines.push(Line::from(spans)); for line in lines_iter { let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; spans.extend(line.spans); - display_lines.push(Line::from(spans)); + lines.push(Line::from(spans)); } + return lines; } - display_lines + let accent = role_accent_color(&msg.role); + let label = format_role_label(&msg.role); + let ts_str = format_timestamp(msg.timestamp); + let header_prefix = vec![ + Span::styled( + format!("{label} "), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{ts_str:<5} "), + Style::default().fg(Theme::TEXT_DIM), + ), + ]; + + let content_str = if msg.content.trim().is_empty() { + "(tool execution)".to_string() + } else { + msg.content.clone() + }; + + let content_spans = super::markdown::render_markdown(&content_str, content_width, false); + let content_lines = split_spans_into_lines(content_spans); + let mut lines_iter = content_lines.into_iter(); + + if let Some(first) = lines_iter.next() { + let mut spans = header_prefix; + spans.extend(first.spans); + lines.push(Line::from(spans)); + } else { + lines.push(Line::from(header_prefix)); + } + + for line in lines_iter { + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; + spans.extend(line.spans); + lines.push(Line::from(spans)); + } + + lines } -/// Pre-render hook: rebuild display_lines_cache and token count if stale. +/// Pre-render hook: update cache secara incremental. /// -/// Called from `view::pre_render` (with `&mut AppStateRest`) **before** the -/// immutable `terminal.draw` closure. This avoids borrow conflicts and ensures -/// that `draw_chat` can take `&AppStateRest`. +/// Strategi: +/// - Cache menyimpan jumlah pesan saat terakhir di-render (`cached_msg_count`) +/// - Jika pesan bertambah → hanya render pesan BARU, append ke cache +/// - Jika pesan berkurang (eviction) atau width berubah → full rebuild +/// - Token count dihitung lazily hanya jika `token_count_dirty` pub fn pre_render_chat(state: &mut crate::state::AppStateRest) { - // We don't know the terminal width here, so we use the last known width. - // If width changed, it will be detected next frame via last_render_width. let content_width = state.last_render_width.saturating_sub(PREFIX_WIDTH as u16 + 2); + let msg_count = state.transcript_cache.messages.len(); + let cached_count = state.cached_msg_count; - // Rebuild display lines only when transcript changed or width changed. - // In practice this means: only when new messages arrive or on resize. - if state.transcript_cache.dirty || state.last_render_width == 0 { - state.display_lines_cache = - build_display_lines(&state.transcript_cache.messages, content_width); + let needs_full_rebuild = state.transcript_cache.dirty + && (state.last_render_width == 0 + || msg_count < cached_count // pesan di-evict dari depan + || state.render_width_at_cache != state.last_render_width); // resize + + if needs_full_rebuild { + // Full rebuild: parse semua pesan dari nol + let mut all_lines: Vec> = Vec::new(); + for msg in &state.transcript_cache.messages { + let msg_lines = render_one_message(msg, content_width); + all_lines.extend(msg_lines); + } + state.display_lines_cache = all_lines; + state.cached_msg_count = msg_count; + state.render_width_at_cache = state.last_render_width; + state.transcript_cache.dirty = false; + } else if state.transcript_cache.dirty && msg_count > cached_count { + // Incremental: hanya render pesan baru yang belum ada di cache + let new_msgs: Vec<_> = state + .transcript_cache + .messages + .iter() + .skip(cached_count) + .collect(); + for msg in new_msgs { + let msg_lines = render_one_message(msg, content_width); + state.display_lines_cache.extend(msg_lines); + } + state.cached_msg_count = msg_count; state.transcript_cache.dirty = false; } - // Lazily recompute token count (expensive tiktoken call) only when new - // messages have arrived — not on every render frame. + // Lazily recompute token count — hanya saat ada pesan baru if state.token_count_dirty { if let Some(ref rt) = state.session_runtime { state.cached_token_count = rt @@ -159,33 +175,40 @@ pub fn pre_render_chat(state: &mut crate::state::AppStateRest) { } } -/// Render the scrollable chat transcript panel in tight inline-log style. +/// Render chat transcript. /// -/// Uses `state.display_lines_cache` — rebuilt by `pre_render_chat` when stale. -/// This function itself is read-only (`&AppStateRest`) and safe to call -/// inside the `terminal.draw` closure. +/// TIDAK melakukan clone seluruh cache — hanya mengambil slice window +/// yang visible (biasanya 30-50 baris) via reference langsung ke cache. pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; let max_visible = (area.height as usize).saturating_sub(3); + let cache = &state.display_lines_cache; - // If the terminal width has changed since last pre_render, rebuild inline. - // This is a safety fallback — normally pre_render handles this. - let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); + // Hitung window tanpa clone + let total = cache.len(); + let spinner_extra = usize::from(state.turn_in_flight()); + let total_with_spinner = total + spinner_extra; + let max_offset = total_with_spinner.saturating_sub(max_visible); + let offset = scroll_offset.min(max_offset); - let mut display_lines = if state.last_render_width != area.width && !state.display_lines_cache.is_empty() { - // Width mismatch — use cached but mark needs rebuild next tick - state.display_lines_cache.clone() - } else { - state.display_lines_cache.clone() - }; + let end_idx = total_with_spinner.saturating_sub(offset); + let start_idx = end_idx.saturating_sub(max_visible); - // Streaming indicator — appended live (not cached) so spinner animates smoothly. - if state.turn_in_flight() { + // Kumpulkan hanya baris yang visible — tidak clone semua + let mut visible: Vec = Vec::with_capacity(max_visible); + let cache_end = end_idx.min(total); + let cache_start = start_idx.min(cache_end); + if cache_start < cache_end { + visible.extend_from_slice(&cache[cache_start..cache_end]); + } + + // Spinner hanya ditambahkan jika visible window mencakup posisi terakhir + if state.turn_in_flight() && end_idx > total { let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); let spinner = spinner_frames[frame_idx]; - display_lines.push(Line::from(vec![ + visible.push(Line::from(vec![ Span::styled( format!("{} ", format_role_label(&Role::Assistant)), Style::default() @@ -202,11 +225,15 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe ])); } - // Suppress unused variable warning — content_width used for rebuild path - let _ = content_width; + let scroll_pct = if total_with_spinner > max_visible && max_offset > 0 { + ((offset as f64 / max_offset as f64) * 100.0) as u8 + } else { + 0 + }; - // Build title - let title = if messages.is_empty() { + let title = if scroll_pct > 0 { + format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct) + } else if messages.is_empty() { String::from(" 💬 Chat ") } else { format!(" 💬 Chat [{} msgs] ", messages.len()) @@ -223,40 +250,6 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe .add_modifier(Modifier::BOLD), )); - let total = display_lines.len(); - let max_offset = total.saturating_sub(max_visible); - let offset = scroll_offset.min(max_offset); - - let end_idx = total.saturating_sub(offset); - let start_idx = end_idx.saturating_sub(max_visible); - let visible: Vec = if start_idx < end_idx && start_idx < total { - display_lines[start_idx..end_idx].to_vec() - } else { - display_lines[total.saturating_sub(max_visible)..total].to_vec() - }; - - let scroll_pct = if total > max_visible { - ((offset as f64 / max_offset as f64) * 100.0) as u8 - } else { - 0 - }; - - let block = if scroll_pct > 0 { - let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled( - scroll_title, - Style::default() - .fg(Theme::TEXT_MUTED) - .add_modifier(Modifier::BOLD), - )) - } else { - block - }; - let paragraph = Paragraph::new(visible) .block(block) .style(Style::default().bg(Theme::BG));