feat: add test_load and test_parse binaries for configuration loading and parsing

This commit is contained in:
asepharyana
2026-07-20 14:47:55 +07:00
parent 66ac4dbf02
commit fe2e916937
10 changed files with 318 additions and 173 deletions
+5
View File
@@ -42,3 +42,8 @@ dirs.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
axum.workspace = true axum.workspace = true
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
[[bin]]
name = "test_load"
path = "src/bin/test_load.rs"
+21
View File
@@ -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);
}
}
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv {
#[serde(alias = "ANTHROPIC_BASE_URL")]
anthropic_base_url: Option<String>,
#[serde(alias = "ANTHROPIC_API_KEY")]
anthropic_api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings {
env: Option<ClaudeEnv>,
#[serde(alias = "customModel", alias = "model")]
custom_model: Option<String>,
}
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::<ClaudeSettings>(&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);
}
}
}
@@ -28,32 +28,43 @@ struct ClaudeEnv {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings { struct ClaudeSettings {
env: Option<ClaudeEnv>, env: Option<ClaudeEnv>,
#[serde(alias = "customModel", alias = "model")]
custom_model: Option<String>,
} }
fn claude_credentials_from_file() -> Option<(String, String)> { fn claude_settings_from_file() -> Option<ClaudeSettings> {
let path = dirs::home_dir()?.join(".claude").join("settings.json"); let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?; let content = std::fs::read_to_string(&path).ok()?;
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?; 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))
} }
fn claude_credentials_from_env() -> Option<(String, String)> { fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option<String>)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; let settings = claude_settings_from_file();
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key))
}
fn detect_claude_settings_provider() -> Option<ProviderConfig> { let file_creds = settings.as_ref().and_then(|s| {
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?; let env = s.env.as_ref()?;
Some(ProviderConfig { Some((env.anthropic_base_url.clone()?, env.anthropic_api_key.clone()?))
api_base: base_url, });
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: None, let env_creds = || -> Option<(String, String)> {
default_api_key: Some(key), let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
}) let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, 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 { impl AppConfigRepository for JsonAppConfigRepository {
@@ -72,7 +83,7 @@ impl AppConfigRepository for JsonAppConfigRepository {
cfg.providers.entry(name).or_insert(provider); 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 cfg.providers
.entry("claude".to_string()) .entry("claude".to_string())
.or_insert(claude_provider); .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 { if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string(); 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());
} }
} }
+9 -6
View File
@@ -112,18 +112,21 @@ pub async fn chat_completions_handler(
// When the requested model matches the shared client we reuse it to // When the requested model matches the shared client we reuse it to
// avoid allocating a new HTTP connection. Otherwise we create a // avoid allocating a new HTTP connection. Otherwise we create a
// temporary LlmClient with the requested model — the ownership // temporary LlmClient with the requested model — the ownership
// lives on the stack via `temp_client`. // lives on the stack via a match binding.
#[allow(unused_assignments)] //
let mut temp_client: Option<zesdex_infrastructure::llm::LlmClient> = None; // 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 { let llm_client = if model == state.llm_client.model {
&state.llm_client &state.llm_client
} else { } else {
temp_client = Some(zesdex_infrastructure::llm::LlmClient::new( temp_client = zesdex_infrastructure::llm::LlmClient::new(
state.llm_client.api_key.clone(), state.llm_client.api_key.clone(),
model, model,
Some(state.llm_client.base_url.clone()), Some(state.llm_client.base_url.clone()),
)); );
temp_client.as_ref().unwrap() &temp_client
}; };
let (response, usage) = llm_client let (response, usage) = llm_client
+12 -2
View File
@@ -121,6 +121,8 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
.map(|mut q| q.drain(..).collect()) .map(|mut q| q.drain(..).collect())
.unwrap_or_default(); .unwrap_or_default();
let had_events = !events.is_empty();
for event in events { for event in events {
match event { match event {
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => { 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 => { zesdex_infrastructure::TurnEvent::Done => {
state.turn_in_flight_flag.store(false, std::sync::atomic::Ordering::SeqCst); 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"); 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) => { Action::SubmitInput(text) => {
// Push user message to transcript display // Push user message to transcript display
+6 -1
View File
@@ -174,14 +174,19 @@ fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)>
std::fs::create_dir_all(&session_dir)?; std::fs::create_dir_all(&session_dir)?;
// Load real settings from disk // 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() let settings = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new()
.load(&store.base_dir) .load(&store.base_dir)
.unwrap_or_default(); .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 workspace_roots = vec![std::env::current_dir()?];
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone()); let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
state.settings = settings; state.settings = settings;
state.app_config = app_config;
Ok((store, state)) Ok((store, state))
} }
+11 -1
View File
@@ -837,7 +837,7 @@ pub struct AppStateRest {
/// Uses AtomicBool for lock-free check from render loop. /// Uses AtomicBool for lock-free check from render loop.
pub turn_in_flight_flag: Arc<AtomicBool>, pub turn_in_flight_flag: Arc<AtomicBool>,
/// Cached display lines for the chat transcript panel. /// 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<Line<'static>>, pub display_lines_cache: Vec<Line<'static>>,
/// Cached token count for the current message history. /// Cached token count for the current message history.
/// Updated lazily only when new messages arrive, not every frame. /// Updated lazily only when new messages arrive, not every frame.
@@ -846,6 +846,12 @@ pub struct AppStateRest {
pub token_count_dirty: bool, pub token_count_dirty: bool,
/// Terminal width at the time of the last display_lines_cache rebuild. /// Terminal width at the time of the last display_lines_cache rebuild.
pub last_render_width: u16, 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. /// Atomic flag set when the user aborts the current turn.
pub abort_flag: Arc<AtomicBool>, pub abort_flag: Arc<AtomicBool>,
/// Simplified workflow engine state for display. /// Simplified workflow engine state for display.
@@ -920,6 +926,8 @@ impl Default for AppStateRest {
cached_token_count: 0, cached_token_count: 0,
token_count_dirty: true, token_count_dirty: true,
last_render_width: 0, last_render_width: 0,
cached_msg_count: 0,
render_width_at_cache: 0,
} }
} }
} }
@@ -971,6 +979,8 @@ impl AppStateRest {
cached_token_count: 0, cached_token_count: 0,
token_count_dirty: true, token_count_dirty: true,
last_render_width: 0, last_render_width: 0,
cached_msg_count: 0,
render_width_at_cache: 0,
} }
} }
+39 -7
View File
@@ -33,6 +33,29 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
let session_dir = state.session_dir.clone(); let session_dir = state.session_dir.clone();
let workspace_roots = state.workspace_roots.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<ChatMessage> = state let mut messages: Vec<ChatMessage> = state
.session_runtime .session_runtime
.as_ref() .as_ref()
@@ -44,10 +67,20 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
rt.messages = messages.clone(); 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 || { 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<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
in_flight: &Arc<AtomicBool>, in_flight: &Arc<AtomicBool>,
abort: &Arc<AtomicBool>, abort: &Arc<AtomicBool>,
api_key: String,
model: String,
api_base: Option<String>,
) { ) {
let client = LlmClient::new( let client = LlmClient::new(api_key, model, api_base);
String::new(), // API key resolved internally from env
"deepseek-v4-flash-free".to_string(),
Some("https://opencode.ai/zen/v1".to_string()),
);
let tools = all_tools(); let tools = all_tools();
let defs = tool_defs(&tools); let defs = tool_defs(&tools);
+128 -135
View File
@@ -1,15 +1,10 @@
//! Chat transcript panel rendering — tight inline log style. //! Chat transcript panel rendering — tight inline log style.
//! //!
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense, //! Performance design:
//! log-like transcript: each non-tool message gets a one-line //! - `display_lines_cache` menyimpan Vec<Line> per pesan (indexed by message position)
//! `{role} {time} {content}` header with wrapped continuation lines //! - Saat pesan baru masuk, hanya pesan BARU yang di-render, bukan rebuild seluruh history
//! aligned under the content column; `Role::Tool` messages render as a //! - `draw_chat` tidak pernah clone seluruh cache — hanya slice window visible yang di-ambil
//! dim `↳`-prefixed sub-line attached to whatever came before. //! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan
//!
//! 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.
use super::theme::Theme; use super::theme::Theme;
use ratatui::layout::Rect; use ratatui::layout::Rect;
@@ -49,101 +44,122 @@ fn format_timestamp(ts: i64) -> String {
format!("{hrs:02}:{mins:02}") format!("{hrs:02}:{mins:02}")
} }
/// Rebuild the full list of display lines from the transcript cache. /// Render satu pesan menjadi Vec<Line<'static>>.
/// /// Dipanggil incremental — hanya untuk pesan baru, bukan seluruh history.
/// This is the expensive operation — markdown parsing, span building, etc. fn render_one_message(
/// Only called from `pre_render_chat` when cache is stale. msg: &crate::state::ChatMessageDisplay,
fn build_display_lines(
messages: &std::collections::VecDeque<crate::state::ChatMessageDisplay>,
content_width: u16, content_width: u16,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut display_lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
for msg in messages { if msg.role == Role::Tool {
if msg.role == Role::Tool { let content = if msg.content.trim().is_empty() {
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() {
"(tool execution)".to_string() "(tool execution)".to_string()
} else { } else {
msg.content.clone() msg.content.clone()
}; };
let dim = Style::default().fg(Theme::TEXT_DIM);
let content_spans = super::markdown::render_markdown(&content_str, content_width, false); let content_spans = super::markdown::render_markdown(&content, content_width, true);
let content_lines = split_spans_into_lines(content_spans); let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter(); let mut lines_iter = content_lines.into_iter();
let first_spans = lines_iter.next().map_or_else(Vec::new, |l| l.spans);
if let Some(first) = lines_iter.next() { let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("", dim)];
let mut spans = header_prefix; spans.extend(first_spans);
spans.extend(first.spans); lines.push(Line::from(spans));
display_lines.push(Line::from(spans));
} else {
display_lines.push(Line::from(header_prefix));
}
for line in lines_iter { for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans); 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 /// Strategi:
/// immutable `terminal.draw` closure. This avoids borrow conflicts and ensures /// - Cache menyimpan jumlah pesan saat terakhir di-render (`cached_msg_count`)
/// that `draw_chat` can take `&AppStateRest`. /// - 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) { 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 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. let needs_full_rebuild = state.transcript_cache.dirty
// In practice this means: only when new messages arrive or on resize. && (state.last_render_width == 0
if state.transcript_cache.dirty || state.last_render_width == 0 { || msg_count < cached_count // pesan di-evict dari depan
state.display_lines_cache = || state.render_width_at_cache != state.last_render_width); // resize
build_display_lines(&state.transcript_cache.messages, content_width);
if needs_full_rebuild {
// Full rebuild: parse semua pesan dari nol
let mut all_lines: Vec<Line<'static>> = 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; state.transcript_cache.dirty = false;
} }
// Lazily recompute token count (expensive tiktoken call) only when new // Lazily recompute token count — hanya saat ada pesan baru
// messages have arrived — not on every render frame.
if state.token_count_dirty { if state.token_count_dirty {
if let Some(ref rt) = state.session_runtime { if let Some(ref rt) = state.session_runtime {
state.cached_token_count = rt 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. /// TIDAK melakukan clone seluruh cache — hanya mengambil slice window
/// This function itself is read-only (`&AppStateRest`) and safe to call /// yang visible (biasanya 30-50 baris) via reference langsung ke cache.
/// inside the `terminal.draw` closure.
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let messages = &state.transcript_cache.messages; let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset; let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3); 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. // Hitung window tanpa clone
// This is a safety fallback — normally pre_render handles this. let total = cache.len();
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); 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() { let end_idx = total_with_spinner.saturating_sub(offset);
// Width mismatch — use cached but mark needs rebuild next tick let start_idx = end_idx.saturating_sub(max_visible);
state.display_lines_cache.clone()
} else {
state.display_lines_cache.clone()
};
// Streaming indicator — appended live (not cached) so spinner animates smoothly. // Kumpulkan hanya baris yang visible — tidak clone semua
if state.turn_in_flight() { let mut visible: Vec<Line> = 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 spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
let spinner = spinner_frames[frame_idx]; let spinner = spinner_frames[frame_idx];
display_lines.push(Line::from(vec![ visible.push(Line::from(vec![
Span::styled( Span::styled(
format!("{} ", format_role_label(&Role::Assistant)), format!("{} ", format_role_label(&Role::Assistant)),
Style::default() 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 scroll_pct = if total_with_spinner > max_visible && max_offset > 0 {
let _ = content_width; ((offset as f64 / max_offset as f64) * 100.0) as u8
} else {
0
};
// Build title let title = if scroll_pct > 0 {
let title = if messages.is_empty() { format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct)
} else if messages.is_empty() {
String::from(" 💬 Chat ") String::from(" 💬 Chat ")
} else { } else {
format!(" 💬 Chat [{} msgs] ", messages.len()) 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), .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<Line> = 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) let paragraph = Paragraph::new(visible)
.block(block) .block(block)
.style(Style::default().bg(Theme::BG)); .style(Style::default().bg(Theme::BG));