fix: route warnings to file and add in-app toast notifications
- Replace all eprintln! with tracing::warn! to avoid TUI corruption via stderr writes during alternate screen mode - Route tracing output to ~/.local/share/zesdex/zesdex.log instead of stderr by configuring tracing_subscriber with a Mutex<File> writer - Add render_toasts() widget: floating notification stack at top-right of the terminal, color-coded by severity (Info/Success/Warning/Error/ Lesson), auto-expires after 5s, max 4 visible - Apply to 12 files: stream parser, MCP client, subagent engine, state/rest, controller/input, app_config, provider, OAuth, agent_def, dto/chat/tool Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e29dfadaa7
commit
89b63b1bc2
@@ -95,7 +95,7 @@ impl StdioChild {
|
|||||||
anyhow::bail!("MCP error: {}", err);
|
anyhow::bail!("MCP error: {}", err);
|
||||||
}
|
}
|
||||||
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
|
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
|
||||||
eprintln!("[mcp] stdio response missing 'result' field: {}", trimmed);
|
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
|
||||||
Value::Null
|
Value::Null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -190,7 +190,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
|||||||
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
|
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
eprintln!("[mcp] HTTP client builder failed: {}, using default client without timeouts", e);
|
tracing::warn!("[mcp] HTTP client builder failed: {}, using default client without timeouts", e);
|
||||||
reqwest::blocking::Client::new()
|
reqwest::blocking::Client::new()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let text = resp.text().unwrap_or_else(|e| {
|
let text = resp.text().unwrap_or_else(|e| {
|
||||||
eprintln!("[mcp] failed to read HTTP response body: {}", e);
|
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
|
||||||
String::new()
|
String::new()
|
||||||
});
|
});
|
||||||
anyhow::bail!("MCP HTTP server returned {}: {}", status, text);
|
anyhow::bail!("MCP HTTP server returned {}: {}", status, text);
|
||||||
@@ -228,7 +228,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = response.get("result").cloned().unwrap_or_else(|| {
|
let result = response.get("result").cloned().unwrap_or_else(|| {
|
||||||
eprintln!("[mcp] HTTP response missing 'result' field");
|
tracing::warn!("[mcp] HTTP response missing 'result' field");
|
||||||
Value::Null
|
Value::Null
|
||||||
});
|
});
|
||||||
extract_text_content(&result)
|
extract_text_content(&result)
|
||||||
@@ -250,7 +250,7 @@ fn extract_text_content(result: &Value) -> anyhow::Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
Ok(serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
||||||
eprintln!("[mcp] failed to pretty-print result: {}", e);
|
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
|
||||||
result.to_string()
|
result.to_string()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -336,11 +336,11 @@ impl McpManager {
|
|||||||
Some(McpToolInfo {
|
Some(McpToolInfo {
|
||||||
name: t.get("name")?.as_str()?.to_string(),
|
name: t.get("name")?.as_str()?.to_string(),
|
||||||
description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| {
|
description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| {
|
||||||
eprintln!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
||||||
""
|
""
|
||||||
}).to_string(),
|
}).to_string(),
|
||||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
||||||
eprintln!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
||||||
serde_json::Value::Null
|
serde_json::Value::Null
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,23 +70,23 @@ impl SseParser {
|
|||||||
let value: Value = match serde_json::from_str(&data) {
|
let value: Value = match serde_json::from_str(&data) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[stream] failed to parse chunk: {}", e);
|
tracing::warn!("[stream] failed to parse chunk: {}", e);
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(usage) = value.get("usage") {
|
if let Some(usage) = value.get("usage") {
|
||||||
if !usage.is_null() {
|
if !usage.is_null() {
|
||||||
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
||||||
eprintln!("[stream] prompt_tokens missing in usage chunk");
|
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||||
0
|
0
|
||||||
});
|
});
|
||||||
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
||||||
eprintln!("[stream] completion_tokens missing in usage chunk");
|
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||||
0
|
0
|
||||||
});
|
});
|
||||||
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
|
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
eprintln!("[stream] total_tokens missing in usage chunk");
|
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||||
prompt_tokens + completion_tokens
|
prompt_tokens + completion_tokens
|
||||||
});
|
});
|
||||||
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
|
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
|
||||||
@@ -125,7 +125,7 @@ impl SseParser {
|
|||||||
let mut events = Vec::with_capacity(tool_calls.len());
|
let mut events = Vec::with_capacity(tool_calls.len());
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
||||||
eprintln!("[stream] tool call delta missing index, defaulting to 0");
|
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||||
0
|
0
|
||||||
}) as usize;
|
}) as usize;
|
||||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||||
@@ -202,7 +202,7 @@ pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
|||||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||||
if let Some(tc) = tool_calls.first() {
|
if let Some(tc) = tool_calls.first() {
|
||||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
||||||
eprintln!("[stream] fallback parser: tool call missing index, defaulting to 0");
|
tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0");
|
||||||
0
|
0
|
||||||
}) as usize;
|
}) as usize;
|
||||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ impl AppStateRest {
|
|||||||
let settings = Settings::load();
|
let settings = Settings::load();
|
||||||
let app_config = AppConfig::load();
|
let app_config = AppConfig::load();
|
||||||
let download_dir = memory_dir.parent().unwrap_or_else(|| {
|
let download_dir = memory_dir.parent().unwrap_or_else(|| {
|
||||||
eprintln!("[state] memory_dir '{}' has no parent, using it for downloads", memory_dir.display());
|
tracing::warn!("[state] memory_dir '{}' has no parent, using it for downloads", memory_dir.display());
|
||||||
&memory_dir
|
&memory_dir
|
||||||
}).join("downloads");
|
}).join("downloads");
|
||||||
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
|
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
|
||||||
eprintln!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display());
|
tracing::warn!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display());
|
||||||
&memory_dir
|
&memory_dir
|
||||||
}).join("worktrees");
|
}).join("worktrees");
|
||||||
let dir_cache = DirCache::new();
|
let dir_cache = DirCache::new();
|
||||||
@@ -74,7 +74,7 @@ impl AppStateRest {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
eprintln!("[state] session_dir has no file_name component, using empty session_id");
|
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
|
||||||
String::new()
|
String::new()
|
||||||
});
|
});
|
||||||
AppStateRest {
|
AppStateRest {
|
||||||
@@ -107,7 +107,7 @@ impl AppStateRest {
|
|||||||
|
|
||||||
pub fn turn_in_flight(&self) -> bool {
|
pub fn turn_in_flight(&self) -> bool {
|
||||||
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
|
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
|
||||||
eprintln!("[state] turn_in_flight mutex poisoned");
|
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||||||
false
|
false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -133,11 +133,11 @@ impl AppStateRest {
|
|||||||
.and_then(|p| p.parent())
|
.and_then(|p| p.parent())
|
||||||
.map(|p| p.to_path_buf())
|
.map(|p| p.to_path_buf())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
eprintln!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
|
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
|
||||||
self.session_dir.parent()
|
self.session_dir.parent()
|
||||||
.map(|p| p.to_path_buf())
|
.map(|p| p.to_path_buf())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
eprintln!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
|
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
|
||||||
self.session_dir.clone()
|
self.session_dir.clone()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ fn resolve_provider_config() -> (String, String, Option<String>) {
|
|||||||
let app_config = crate::model::app_config::AppConfig::load();
|
let app_config = crate::model::app_config::AppConfig::load();
|
||||||
|
|
||||||
let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_else(|| {
|
let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_else(|| {
|
||||||
eprintln!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider);
|
tracing::warn!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider);
|
||||||
String::new()
|
String::new()
|
||||||
});
|
});
|
||||||
let model = settings.model.clone();
|
let model = settings.model.clone();
|
||||||
@@ -45,7 +45,7 @@ fn resolve_provider_config() -> (String, String, Option<String>) {
|
|||||||
.and_then(|env| std::env::var(env).ok())
|
.and_then(|env| std::env::var(env).ok())
|
||||||
.or_else(|| provider_cfg.default_api_key.clone())
|
.or_else(|| provider_cfg.default_api_key.clone())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
eprintln!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider);
|
tracing::warn!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider);
|
||||||
String::new()
|
String::new()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
if let Some(provider) = providers.get(state.misc.selected_index) {
|
if let Some(provider) = providers.get(state.misc.selected_index) {
|
||||||
if let Some(cfg) = state.app_config.providers.get(provider) {
|
if let Some(cfg) = state.app_config.providers.get(provider) {
|
||||||
let model = cfg.default_model.clone().unwrap_or_else(|| {
|
let model = cfg.default_model.clone().unwrap_or_else(|| {
|
||||||
eprintln!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider);
|
tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider);
|
||||||
"claude-opus-4-8".to_string()
|
"claude-opus-4-8".to_string()
|
||||||
});
|
});
|
||||||
state.settings.provider = provider.clone();
|
state.settings.provider = provider.clone();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
|||||||
match serde_json::from_str::<Value>(s) {
|
match serde_json::from_str::<Value>(s) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
tracing::warn!(
|
||||||
"warning: tool argument is a JSON string but failed to parse: {}. Using raw string.",
|
"warning: tool argument is a JSON string but failed to parse: {}. Using raw string.",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
|
|||||||
+16
@@ -1,5 +1,6 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::sync::Mutex;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
@@ -23,11 +24,26 @@ fn main() -> Result<()> {
|
|||||||
.position(|a| a == "--attach")
|
.position(|a| a == "--attach")
|
||||||
.and_then(|i| args.get(i + 1).cloned());
|
.and_then(|i| args.get(i + 1).cloned());
|
||||||
|
|
||||||
|
let log_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join("zesdex");
|
||||||
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
|
let log_path = log_dir.join("zesdex.log");
|
||||||
|
let log_file = std::fs::OpenOptions::new()
|
||||||
|
.create(true).append(true).open(&log_path)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
// Fallback: /dev/null so the TUI isn't corrupted by stderr writes
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.write(true).open("/dev/null")
|
||||||
|
.expect("cannot open /dev/null")
|
||||||
|
});
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
)
|
)
|
||||||
|
.with_writer(Mutex::new(log_file))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
if is_daemon && attach_session.is_some() {
|
if is_daemon && attach_session.is_some() {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
|||||||
match std::fs::read_to_string(&agents_file) {
|
match std::fs::read_to_string(&agents_file) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
eprintln!("[session] failed to parse agents.json: {}", e);
|
tracing::warn!("[session] failed to parse agents.json: {}", e);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ impl AppConfig {
|
|||||||
Ok(s) => match serde_json::from_str(&s) {
|
Ok(s) => match serde_json::from_str(&s) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
tracing::warn!(
|
||||||
"warning: failed to parse config file '{}': {}. Loading defaults.",
|
"warning: failed to parse config file '{}': {}. Loading defaults.",
|
||||||
path.display(), e
|
path.display(), e
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ impl OAuthManager {
|
|||||||
let mut url = match url::Url::parse(&self.config.auth_url) {
|
let mut url = match url::Url::parse(&self.config.auth_url) {
|
||||||
Ok(u) if !self.config.auth_url.is_empty() => u,
|
Ok(u) if !self.config.auth_url.is_empty() => u,
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!(
|
tracing::warn!(
|
||||||
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
||||||
self.config.auth_url
|
self.config.auth_url
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ fn rand_byte() -> u8 {
|
|||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
eprintln!("[pkce] system time before UNIX_EPOCH, using 0 for random byte");
|
tracing::warn!("[pkce] system time before UNIX_EPOCH, using 0 for random byte");
|
||||||
std::time::Duration::default()
|
std::time::Duration::default()
|
||||||
})
|
})
|
||||||
.subsec_nanos();
|
.subsec_nanos();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl LlmClient {
|
|||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("warning: failed to build reqwest client with timeouts: {}. Using default client without timeouts.", e);
|
tracing::warn!("warning: failed to build reqwest client with timeouts: {}. Using default client without timeouts.", e);
|
||||||
reqwest::blocking::Client::new()
|
reqwest::blocking::Client::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -118,7 +118,7 @@ impl LlmClient {
|
|||||||
if attempt >= max_retries || is_auth_error {
|
if attempt >= max_retries || is_auth_error {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
tracing::warn!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ impl LlmClient {
|
|||||||
if started || attempt >= max_retries || is_auth_error {
|
if started || attempt >= max_retries || is_auth_error {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
tracing::warn!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
|||||||
|
|
||||||
render_input_bar(frame, input_area, state);
|
render_input_bar(frame, input_area, state);
|
||||||
status::draw_status_bar(frame, status_area, state);
|
status::draw_status_bar(frame, status_area, state);
|
||||||
|
|
||||||
|
// Toast notifications at top-right (like Hyprland)
|
||||||
|
render_toasts(frame, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
@@ -564,6 +567,49 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re
|
|||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render active toasts as a floating stack at top-right of the terminal.
|
||||||
|
/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once.
|
||||||
|
fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter()
|
||||||
|
.filter(|t| !t.expired(now_ms))
|
||||||
|
.collect();
|
||||||
|
if active.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let area = frame.area();
|
||||||
|
let toast_w: u16 = 45;
|
||||||
|
let x = area.width.saturating_sub(toast_w).saturating_sub(2);
|
||||||
|
let mut y: u16 = 1;
|
||||||
|
|
||||||
|
for toast in active.iter().rev().take(4) {
|
||||||
|
let line_count = toast.message.lines().count().max(1) as u16;
|
||||||
|
let h = line_count + 2; // border top + border bottom
|
||||||
|
let toast_area = Rect { x, y, width: toast_w, height: h };
|
||||||
|
if toast_area.bottom() > area.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, toast_area);
|
||||||
|
|
||||||
|
let border_color = match toast.kind {
|
||||||
|
crate::app::state::types::ToastKind::Success => Theme::SUCCESS,
|
||||||
|
crate::app::state::types::ToastKind::Warning => Theme::WARNING,
|
||||||
|
crate::app::state::types::ToastKind::Error => Theme::ERROR,
|
||||||
|
crate::app::state::types::ToastKind::Info => Theme::INFO,
|
||||||
|
crate::app::state::types::ToastKind::Lesson => Theme::PRIMARY,
|
||||||
|
};
|
||||||
|
let block = ratatui::widgets::Block::default()
|
||||||
|
.borders(ratatui::widgets::Borders::ALL)
|
||||||
|
.border_style(ratatui::style::Style::default().fg(border_color))
|
||||||
|
.style(ratatui::style::Style::default().bg(ratatui::style::Color::Black));
|
||||||
|
let paragraph = ratatui::widgets::Paragraph::new(toast.message.as_str())
|
||||||
|
.block(block)
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, toast_area);
|
||||||
|
y = y.saturating_add(h).saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||||
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
|
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
|
||||||
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
|
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user