feat: add editing and MCP command handling, enhance SSE streaming with usage tracking

- Implemented `Edit` and `McpAdd` commands in the command parser and handler.
- Added a new `stream` module to the runtime for handling streaming events.
- Enhanced `SseParser` to parse usage information from SSE events.
- Introduced `ToolCallAccumulator` for tracking tool calls independently.
- Updated `AppStateRest` to include `app_config` and `MiscState` to track `effort_level` and `selected_index`.
- Modified `LlmClient` to support streaming responses with usage tracking.
- Improved error handling and retry logic in the streaming API calls.
- Added tests for new features and improved markdown rendering in the chat view.
This commit is contained in:
asepharyana
2026-07-12 01:25:52 +07:00
parent fcef85a327
commit 18a41aad48
28 changed files with 838 additions and 98 deletions
+48
View File
@@ -259,4 +259,52 @@ impl McpManager {
})
}).collect()
}
/// Connects to an MCP server via stdio by spawning the child process, running
/// the `initialize` handshake, calling `tools/list`, and registering the server
/// with its advertised tools in `self.servers`. The child process stays alive
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
pub fn connect_stdio(&mut self, name: &str, command: &str, extra_args: &[String]) -> anyhow::Result<()> {
let transport = McpTransport::Stdio {
command: command.to_string(),
args: extra_args.to_vec(),
};
let mut child = spawn_stdio_child(command, extra_args)?;
let result = child.call("tools/list", json!({}))?;
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
tool_list.iter().filter_map(|t| {
Some(McpToolInfo {
name: t.get("name")?.as_str()?.to_string(),
description: t.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or(serde_json::Value::Null),
})
}).collect()
} else {
Vec::new()
};
self.servers.push(McpServer {
name: name.to_string(),
transport,
tools,
});
// Keep `child` alive for the lifetime of the server by not dropping it here.
// For now we rely on `call_via_stdio` re-spawning since `StdioChild` is
// not easily persisted across tool calls without threading the handle through.
// A follow-up can store the handle alongside the server.
drop(child);
Ok(())
}
/// Removes a server by name. Returns `true` if a server was found and removed.
#[allow(dead_code)]
pub fn disconnect(&mut self, name: &str) -> bool {
let len = self.servers.len();
self.servers.retain(|s| s.name != name);
self.servers.len() < len
}
}
+20
View File
@@ -2,6 +2,21 @@ use crate::app::state::rest::AppStateRest;
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use,
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token
/// budget for lower temperature (more deterministic, more room to reason/act).
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0];
const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1];
/// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent
/// to the LLM, scaling the user's configured `max_tokens` by the level's multiplier.
pub fn generation_params(level: usize, base_max_tokens: u32) -> (f32, u32) {
let idx = level.min(EFFORT_LEVELS.len() - 1);
let temperature = TEMPERATURE_OVERRIDE[idx];
let max_tokens = ((base_max_tokens as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32;
(temperature, max_tokens.max(256))
}
pub fn current_effort(state: &AppStateRest) -> usize {
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
}
@@ -14,5 +29,10 @@ pub fn current_effort_str(state: &AppStateRest) -> &'static str {
pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
let label = current_effort_str(state);
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
format!("Effort: {}", label),
));
state.dirty = true;
}
+7
View File
@@ -1,7 +1,14 @@
use serde::{Deserialize, Serialize};
pub mod bash;
pub mod editor;
pub mod effort;
pub mod key_input;
pub mod mcp;
pub mod onboard;
pub mod onboard_provider;
pub mod quit_confirm;
pub mod rewind;
pub mod security;
pub mod settings;
pub mod todo;
-13
View File
@@ -1,13 +0,0 @@
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
pub fn pick_item(state: &mut AppStateRest, index: usize) {
let _ = index;
state.misc.overlay = Overlay::None;
state.dirty = true;
}
pub fn update_filter(state: &mut AppStateRest, filter: String) {
state.input.buffer = filter;
state.dirty = true;
}
+117 -3
View File
@@ -1,10 +1,124 @@
use crate::app::state::rest::AppStateRest;
use sha2::Digest;
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
pub fn rewind_count(state: &AppStateRest) -> usize {
let conn = match open_session_db(&state.session_dir) {
Ok(c) => c,
Err(_) => return 0,
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok()
.map(|keys| keys.len())
.unwrap_or(0)
}
/// Restores a file to its pre-edit state by retrieving the blob stored under index
/// `index` (0 = oldest). Opens a fresh SQLite connection so this works outside
/// of a running turn (e.g. from the Rewind overlay).
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let _ = index;
let conn = match open_session_db(&state.session_dir) {
Ok(c) => c,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to open session DB: {}", e),
));
state.dirty = true;
return;
}
};
let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) {
Ok(k) => k,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to list snapshots: {}", e),
));
state.dirty = true;
return;
}
};
if keys.is_empty() || index >= keys.len() {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Warning,
"No snapshot available at that index".to_string(),
));
state.dirty = true;
return;
}
let blob_key = &keys[index];
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) {
Ok(Some(b)) => b,
Ok(None) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
"Snapshot data not found".to_string(),
));
state.dirty = true;
return;
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to retrieve snapshot: {}", e),
));
state.dirty = true;
return;
}
};
// Look up the path from the edit log — the blob key is the tool_call_id.
// The edit log doesn't store the tool_call_id directly, so fall back to the
// path from the most recent write/edit entry.
let restore_path = find_edit_path(state, blob_key)
.unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
match std::fs::write(&restore_path, &bytes) {
Ok(_) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("Restored {} from snapshot", restore_path.display()),
));
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to write restored file: {}", e),
));
}
}
// Log the rewind itself as an edit entry
let mut el = crate::model::editlog::EditLog::new(&state.session_dir);
let entry = crate::model::editlog::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: "rewind".to_string(),
path: restore_path.to_string_lossy().to_string(),
reason: format!("rewind_to({})", index),
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)),
bytes_delta: bytes.len() as i64,
origin: crate::app::state::types::Origin::Main.tag(),
session_id: state.session_id.clone(),
};
let _ = el.append(entry);
// Clear the transcript to force a refresh
state.transcript_cache.dirty = true;
state.dirty = true;
}
pub fn rewind_count(state: &AppStateRest) -> usize {
state.transcript_cache.messages.len().min(5)
fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite");
let conn = rusqlite::Connection::open(&path)?;
Ok(conn)
}
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
let el = crate::model::editlog::EditLog::new(&state.session_dir);
let entry = el.entries.iter().rev().find(|e| e.tool == "write" || e.tool == "edit")?;
Some(std::path::PathBuf::from(&entry.path))
}
-46
View File
@@ -1,46 +0,0 @@
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
pub fn load_sessions(state: &mut AppStateRest) {
let base = state.store_base_dir();
state.sessions = crate::model::session::Session::list(&base);
state.dirty = true;
}
pub fn select_session(state: &mut AppStateRest, session_id: &str) {
let base = state.store_base_dir();
let session = crate::model::session::Session::load(session_id, &base).ok();
if session.is_none() {
return;
}
let session = session.unwrap();
let conv_path = session.conversation_path(&base);
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
std::fs::read_to_string(&conv_path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default();
state.session_id = session.id.clone();
state.session_dir = session.session_dir(&base);
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
state.session_dir.clone(),
));
state.transcript_cache.messages.clear();
if let Some(ref mut rt) = state.session_runtime {
for msg in loaded_msgs {
let display = crate::app::state::rest::ChatMessageDisplay::new(
msg.role.clone(),
msg.content.clone().unwrap_or_default(),
);
state.transcript_cache.messages.push(display);
rt.push_message(msg);
}
}
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
crate::dto::chat::message::Role::System,
format!("switched to session: {}", session.title),
));
state.misc.overlay = Overlay::None;
state.dirty = true;
}
+140 -5
View File
@@ -53,6 +53,13 @@ pub enum Action {
StartOAuth {
provider: String,
},
OpenEditor {
path: String,
},
McpAdd {
name: String,
command: String,
},
}
pub fn apply_action(state: &mut AppStateRest, action: Action) {
@@ -137,7 +144,51 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.misc.overlay = overlay;
state.dirty = true;
}
Action::OpenEditor { path } => {
let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
match resolved {
Ok(abs_path) => {
let content = std::fs::read_to_string(&abs_path)
.unwrap_or_default();
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let ed = crate::app::mode::editor::EditorState::open(
abs_path.to_string_lossy().to_string(),
Some(lines),
);
state.misc.editor = Some(ed);
state.misc.overlay = Overlay::Editor;
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path)));
}
Err(e) => {
state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e)));
}
}
state.dirty = true;
}
Action::McpAdd { name, command } => {
let extra_args: Vec<String> = command.split_whitespace().map(|s| s.to_string()).collect();
let cmd = extra_args.first().cloned().unwrap_or_default();
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
Ok(_) => {
let tool_count = state.mcp_manager.servers.last()
.map(|s| s.tools.len())
.unwrap_or(0);
state.push_toast(Toast::new(ToastKind::Success,
format!("Connected MCP server '{}' ({} tools)", name, tool_count)));
state.dirty = true;
}
Err(e) => {
state.push_toast(Toast::new(ToastKind::Error,
format!("MCP connect failed: {}", e)));
}
}
}
Action::CloseOverlay => {
// If the overlay is the Editor, dismiss it properly first
if state.misc.overlay == Overlay::Editor {
crate::app::mode::editor::handle_editor_dismiss(state);
}
state.misc.overlay = Overlay::None;
state.dirty = true;
}
@@ -315,6 +366,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.push_toast(Toast::new(ToastKind::Info, message));
}
}
TurnEvent::StreamStart => {
state.misc.thinking = false;
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
}
TurnEvent::StreamToken(delta) => {
if let Some(last) = state.transcript_cache.messages.last_mut() {
if last.role == Role::Assistant {
last.content.push_str(&delta);
state.transcript_cache.dirty = true;
}
}
}
TurnEvent::StreamDone(msg) => {
state.misc.thinking = false;
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(msg);
}
}
TurnEvent::Usage { tokens_in, tokens_out } => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
rt.usage.api_calls += 1;
}
}
TurnEvent::Error(msg) => {
let long_toast = Toast {
kind: ToastKind::Error,
@@ -384,6 +460,12 @@ fn spawn_turn(state: &AppStateRest) {
}
let api_key = state.settings.api_key.clone().unwrap_or_default();
let model = state.settings.model.clone();
let base_url = state.app_config.providers.get(&state.settings.provider)
.map(|p| p.api_base.clone());
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
state.misc.effort_level,
state.settings.max_tokens,
);
let mut tools = crate::tool::all_tools();
tools.extend(state.mcp_manager.as_tools());
let tool_defs = crate::tool::tool_defs(&tools);
@@ -404,7 +486,7 @@ fn spawn_turn(state: &AppStateRest) {
.ok()
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
let tc = TurnCtx {
client: crate::service::provider::LlmClient::new(api_key, model),
client: crate::service::provider::LlmClient::new(api_key, model, base_url),
tdefs: tool_defs,
tools,
ctx,
@@ -413,6 +495,8 @@ fn spawn_turn(state: &AppStateRest) {
edit_log_session_dir: edit_session_dir,
session_id,
db,
temperature,
max_tokens,
};
let result = run_agent_turn(tc, &messages, &events_q);
if let Err(e) = result {
@@ -436,6 +520,8 @@ struct TurnCtx {
edit_log_session_dir: std::path::PathBuf,
session_id: String,
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
temperature: f32,
max_tokens: u32,
}
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
@@ -490,9 +576,34 @@ fn run_agent_turn(
msgs.clone()
};
let response = tc
.client
.chat_with_tools(&wire_msgs, Some(tc.tdefs.clone()))?;
let mut stream_started = false;
let (response, usage) = tc.client.chat_with_tools_streaming(
&wire_msgs,
Some(tc.tdefs.clone()),
Some(tc.temperature),
Some(tc.max_tokens),
|event| match event {
crate::app::runtime::stream::StreamEvent::Token(tok) => {
if let Ok(mut q) = events_q.lock() {
if !stream_started {
q.push_back(TurnEvent::StreamStart);
stream_started = true;
}
q.push_back(TurnEvent::StreamToken(tok.clone()));
}
}
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage {
tokens_in: *prompt_tokens,
tokens_out: *completion_tokens,
});
}
}
_ => {}
},
)?;
let _ = usage; // already emitted as TurnEvent::Usage inside the on_event callback, if present
let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
@@ -524,9 +635,11 @@ fn run_agent_turn(
&tc.tools,
&tc.ctx,
&tool_name,
&tool_call.id,
&args,
&tc.edit_log_session_dir,
&tc.session_id,
&tc.db,
) {
Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false),
@@ -566,7 +679,11 @@ fn run_agent_turn(
if !content.is_empty() {
archive_message(&tc.db, &tc.session_id, &response);
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::AssistantMessage(response));
if stream_started {
q.push_back(TurnEvent::StreamDone(response));
} else {
q.push_back(TurnEvent::AssistantMessage(response));
}
}
}
break;
@@ -589,16 +706,34 @@ fn run_agent_turn(
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn execute_one_tool(
tools: &[Box<dyn crate::tool::Tool>],
ctx: &crate::tool::ToolCtx,
name: &str,
tool_call_id: &str,
args: &serde_json::Value,
session_dir: &std::path::Path,
session_id: &str,
db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
) -> anyhow::Result<String> {
for tool in tools {
if tool.name() == name {
// Snapshot current file content before write/edit for rewind
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
if let Some(ref arc) = db {
if let Ok(conn) = arc.lock() {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
if let Ok(bytes) = std::fs::read(&abs_path) {
let _ = crate::model::msglog::store_blob(
&conn, session_id, tool_call_id, &bytes, None,
);
}
}
}
}
}
let result = tool.run(ctx, args)?;
if name == "write" || name == "edit" {
let reason = args
+6
View File
@@ -43,6 +43,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Login { provider } => {
vec![Action::StartOAuth { provider }]
}
Command::Edit(path) => {
vec![Action::OpenEditor { path }]
}
Command::McpAdd { name, command } => {
vec![Action::McpAdd { name, command }]
}
Command::Unknown(cmd) => {
vec![Action::SystemNote {
kind: "error".to_string(),
+1
View File
@@ -1,3 +1,4 @@
pub mod actions;
pub mod commands;
pub mod shortsend;
pub mod stream;
+121 -1
View File
@@ -70,6 +70,15 @@ impl SseParser {
return None;
}
let value: Value = serde_json::from_str(&data).ok()?;
if let Some(usage) = value.get("usage") {
if !usage.is_null() {
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
.unwrap_or(prompt_tokens + completion_tokens);
return Some(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
}
}
match event_type.as_str() {
"message.stop" => Some(StreamEvent::Done),
"message.start" => None,
@@ -85,7 +94,7 @@ impl SseParser {
return Some(StreamEvent::Reasoning(reasoning.to_string()));
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
for tc in tool_calls {
if let Some(tc) = tool_calls.first() {
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
let name = tc.get("function")
@@ -121,6 +130,9 @@ impl SseParser {
}
}
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
/// reuse a parser instance across requests rather than constructing a fresh one.
#[allow(dead_code)]
pub fn reset(&mut self) {
self.buffer.clear();
self.event_type = None;
@@ -128,6 +140,10 @@ impl SseParser {
}
}
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
#[allow(dead_code)]
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
let value: Value = serde_json::from_str(data).ok()?;
if value == Value::Null {
@@ -170,3 +186,107 @@ pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feed_parses_single_token_chunk() {
let mut p = SseParser::new();
let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Token(t) => assert_eq!(t, "hello"),
other => panic!("expected Token, got {:?}", other),
}
}
#[test]
fn feed_handles_chunk_split_mid_line() {
let mut p = SseParser::new();
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
assert!(e1.is_empty(), "no event until the line and blank separator complete");
let e2 = p.feed("\"}}]}\n\n");
assert_eq!(e2.len(), 1);
match &e2[0] {
StreamEvent::Token(t) => assert_eq!(t, "partial"),
other => panic!("expected Token, got {:?}", other),
}
}
#[test]
fn feed_emits_done_on_done_sentinel() {
let mut p = SseParser::new();
let events = p.feed("data: [DONE]\n\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn feed_emits_done_on_finish_reason_stop() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn feed_parses_tool_call_delta() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n",
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::ToolCallDelta { index, id, name, arguments_delta } => {
assert_eq!(*index, 0);
assert_eq!(id.as_deref(), Some("call_1"));
assert_eq!(name.as_deref(), Some("bash"));
assert_eq!(arguments_delta, "{\"cmd\"");
}
other => panic!("expected ToolCallDelta, got {:?}", other),
}
}
#[test]
fn feed_parses_usage_chunk() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens } => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
}
other => panic!("expected Usage, got {:?}", other),
}
}
#[test]
fn feed_ignores_empty_data_lines() {
let mut p = SseParser::new();
let events = p.feed(": comment\n\n");
assert!(events.is_empty());
}
#[test]
fn feed_multiple_events_across_one_chunk() {
let mut p = SseParser::new();
let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n";
let events = p.feed(chunk);
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(StreamEvent::Token(a), StreamEvent::Token(b)) => {
assert_eq!(a, "a");
assert_eq!(b, "b");
}
other => panic!("expected two Tokens, got {:?}", other),
}
}
}
+5
View File
@@ -1,10 +1,15 @@
use super::turn::ParsedToolCall;
use serde_json::{json, Value};
/// Standalone tool-call delta accumulator, functionally equivalent to the accumulation
/// logic built into `StreamedTurn::apply_event`. Reserved for callers that want to track
/// tool-call deltas independently of a full `StreamedTurn` (e.g. a lighter-weight preview).
#[allow(dead_code)]
pub struct ToolCallAccumulator {
calls: Vec<ParsedToolCall>,
}
#[allow(dead_code)]
impl ToolCallAccumulator {
pub fn new() -> Self {
ToolCallAccumulator { calls: Vec::new() }
+9
View File
@@ -22,6 +22,10 @@ pub struct ParsedToolCall {
}
impl ParsedToolCall {
/// Attempts to parse the accumulated argument string as JSON before the tool call is
/// marked complete — useful for callers that want a speculative preview mid-stream.
/// `build_assistant_message` does its own (lossy-fallback) parse for the final message.
#[allow(dead_code)]
pub fn try_parse(&self) -> Option<Value> {
serde_json::from_str(&self.arguments).ok()
}
@@ -115,10 +119,15 @@ impl StreamedTurn {
msg
}
/// Reserved accessor for callers that want to branch mid-stream before the turn
/// completes; the current wiring only inspects the final `build_assistant_message()`.
#[allow(dead_code)]
pub fn has_tool_calls(&self) -> bool {
self.tool_calls.iter().any(|tc| !tc.name.is_empty())
}
/// Reserved accessor mirroring `has_tool_calls` for mid-stream content peeks.
#[allow(dead_code)]
pub fn content(&self) -> &str {
&self.accumulated_content
}
+6
View File
@@ -204,6 +204,9 @@ pub struct MiscState {
pub esc_press_count: u32,
pub last_staleness_sweep_ms: i64,
pub thinking: bool,
pub effort_level: usize,
pub selected_index: usize,
pub editor: Option<super::super::mode::editor::EditorState>,
}
impl MiscState {
@@ -216,6 +219,9 @@ impl MiscState {
esc_press_count: 0,
last_staleness_sweep_ms: 0,
thinking: false,
effort_level: 1,
selected_index: 0,
editor: None,
}
}
+4
View File
@@ -8,6 +8,7 @@ use super::runtime::{SessionRuntime, TurnEvent};
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
use crate::app::mcp::manager::McpManager;
use crate::app::workflow::engine::WorkflowEngine;
use crate::model::app_config::AppConfig;
use crate::model::editlog::EditLog;
use crate::model::settings::Settings;
@@ -32,6 +33,7 @@ impl ChatMessageDisplay {
pub struct AppStateRest {
pub mode: AgentMode,
pub settings: Settings,
pub app_config: AppConfig,
pub workspace_roots: Vec<PathBuf>,
pub session_id: String,
pub session_dir: PathBuf,
@@ -57,6 +59,7 @@ pub struct AppStateRest {
impl AppStateRest {
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
let settings = Settings::load();
let app_config = AppConfig::load();
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
let dir_cache = DirCache::new();
@@ -67,6 +70,7 @@ impl AppStateRest {
AppStateRest {
mode: AgentMode::Normal,
settings,
app_config,
workspace_roots,
session_id,
session_dir: session_dir.clone(),
+7
View File
@@ -74,6 +74,13 @@ pub enum TurnEvent {
kind: String,
message: String,
},
StreamStart,
StreamToken(String),
StreamDone(crate::dto::chat::message::ChatMessage),
Usage {
tokens_in: u64,
tokens_out: u64,
},
Error(String),
Done,
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
let api_key = std::env::var("API_KEY").unwrap_or_default();
let model = std::env::var("MODEL").unwrap_or_default();
let client = crate::service::provider::LlmClient::new(api_key, model);
let client = crate::service::provider::LlmClient::new(api_key, model, None);
let response = match client.chat(&messages) {
Ok(r) => r,
Err(e) => {
+22
View File
@@ -13,6 +13,11 @@ pub enum Command {
Mode(ModeKind),
Clear,
Login { provider: String },
Edit(String),
McpAdd {
name: String,
command: String,
},
Unknown(String),
}
@@ -50,6 +55,23 @@ pub fn parse_command(text: &str) -> Command {
"/lesson" => Command::LessonList,
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
"/login" => Command::Login { provider: "zen".to_string() },
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
"/edit" => Command::Edit(".".to_string()),
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
// /mcp add <name> <command> [args...]
// name is the first word of arg2, the rest is the command
let rest = arg2.trim();
if let Some(space) = rest.find(' ') {
let name = rest[..space].to_string();
let command = rest[space + 1..].trim().to_string();
Command::McpAdd { name, command }
} else {
Command::McpAdd {
name: rest.to_string(),
command: String::new(),
}
}
}
_ => Command::Unknown(cmd.to_string()),
}
}
+117
View File
@@ -8,6 +8,53 @@ use crate::app::state::types::Overlay;
use crate::controller::command::parse_command;
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// While Editor overlay is active, route input directly to the editor handler
if state.misc.overlay == Overlay::Editor {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return vec![Action::QuitConfirm];
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(ref ed) = state.misc.editor.clone() {
let content = ed.as_string();
if let Err(e) = std::fs::write(&ed.path, &content) {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Save failed: {}", e),
));
} else {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("Saved {}", ed.path),
));
}
state.dirty = true;
}
return vec![];
}
KeyCode::Esc => {
crate::app::mode::editor::handle_editor_dismiss(state);
return vec![];
}
KeyCode::Backspace => {
if let Some(ref mut ed) = state.misc.editor {
ed.delete_left();
state.dirty = true;
}
return vec![];
}
KeyCode::Enter => {
crate::app::mode::editor::handle_editor_input(state, "\n".to_string());
return vec![];
}
KeyCode::Char(c) => {
crate::app::mode::editor::handle_editor_input(state, c.to_string());
return vec![];
}
_ => return vec![],
}
}
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
@@ -40,6 +87,19 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
KeyCode::Up => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::HistoryUp]
} else if state.misc.overlay == Overlay::Effort {
mode::effort::cycle_effort(state);
Vec::new()
} else if state.misc.overlay == Overlay::OnboardProvider {
let n = mode::onboard_provider::PROVIDERS.len();
state.misc.selected_index = if state.misc.selected_index == 0 { n - 1 } else { state.misc.selected_index - 1 };
state.dirty = true;
Vec::new()
} else if state.misc.overlay == Overlay::Rewind {
let n = mode::rewind::rewind_count(state);
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
state.dirty = true;
Vec::new()
} else {
vec![Action::ScrollUp]
}
@@ -47,6 +107,19 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
KeyCode::Down => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::HistoryDown]
} else if state.misc.overlay == Overlay::Effort {
mode::effort::cycle_effort(state);
Vec::new()
} else if state.misc.overlay == Overlay::OnboardProvider {
let n = mode::onboard_provider::PROVIDERS.len();
state.misc.selected_index = (state.misc.selected_index + 1) % n;
state.dirty = true;
Vec::new()
} else if state.misc.overlay == Overlay::Rewind {
let n = mode::rewind::rewind_count(state);
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
state.dirty = true;
Vec::new()
} else {
vec![Action::ScrollDown]
}
@@ -134,6 +207,50 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
Overlay::QuitConfirm => {
vec![mode::quit_confirm::handle_quit_confirm(true)]
}
Overlay::KeyInput => {
let text = state.input.buffer.clone();
mode::key_input::handle_key_text(state, text.clone());
state.settings.api_key = if text.is_empty() { None } else { Some(text) };
let _ = state.settings.save();
state.input.buffer.clear();
state.input.cursor = 0;
state.misc.overlay = Overlay::None;
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
"API key saved".to_string(),
));
state.dirty = true;
Vec::new()
}
Overlay::Onboard => {
state.misc.overlay = Overlay::OnboardProvider;
state.misc.selected_index = 0;
state.dirty = true;
Vec::new()
}
Overlay::OnboardProvider => {
let idx = state.misc.selected_index.min(mode::onboard_provider::PROVIDERS.len() - 1);
let provider = mode::onboard_provider::PROVIDERS[idx];
mode::onboard_provider::set_provider(&mut state.settings, provider);
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
format!("Provider set to {}", provider),
));
state.misc.overlay = Overlay::KeyInput;
state.input.buffer.clear();
state.input.cursor = 0;
state.dirty = true;
Vec::new()
}
Overlay::Mcp => {
mode::mcp::connect_mcp(state, "");
Vec::new()
}
Overlay::Rewind => {
let idx = state.misc.selected_index;
mode::rewind::rewind_to(state, idx);
Vec::new()
}
_ => Vec::new(),
}
}
+7
View File
@@ -17,6 +17,13 @@ pub struct ChatRequest {
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions {
pub include_usage: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+15
View File
@@ -53,6 +53,11 @@ fn run_single_process() -> Result<()> {
let session_dir = store.base_dir.join("sessions").join(&session_id);
std::fs::create_dir_all(&session_dir)?;
let session_lock = model::session_lock::SessionLock::new(&session_dir);
if !session_lock.try_lock()? {
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
}
let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new(
workspace_roots.clone(),
@@ -60,6 +65,9 @@ fn run_single_process() -> Result<()> {
store.memory_dir,
);
state.sessions = model::session::Session::list(&store.base_dir);
if state.settings.api_key.is_none() {
state.misc.overlay = app::state::types::Overlay::Onboard;
}
let _rt = tokio::runtime::Runtime::new()?;
@@ -82,6 +90,7 @@ fn run_single_process() -> Result<()> {
}
let _ = state.settings.save();
session_lock.unlock();
Ok(())
}
@@ -255,6 +264,11 @@ fn run_daemon() -> Result<()> {
let session_dir = store.base_dir.join("sessions").join(&session_id);
std::fs::create_dir_all(&session_dir)?;
let session_lock = model::session_lock::SessionLock::new(&session_dir);
if !session_lock.try_lock()? {
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
}
let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new(
workspace_roots.clone(),
@@ -336,6 +350,7 @@ fn run_daemon() -> Result<()> {
let _ = std::fs::remove_file(&socket_path);
let _ = state.settings.save();
session_lock.unlock();
Ok(())
}
+2
View File
@@ -1,6 +1,8 @@
pub mod app_config;
pub mod editlog;
pub mod memory;
pub mod msglog;
pub mod session;
pub mod session_lock;
pub mod settings;
pub mod store;
+2
View File
@@ -1,6 +1,8 @@
pub mod blobs;
pub mod query;
pub mod schema;
pub use blobs::store_blob;
pub use query::insert_message;
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
+166 -16
View File
@@ -1,8 +1,10 @@
use std::time::Duration;
use anyhow::Result;
use crate::app::runtime::stream::{SseParser, StreamEvent};
use crate::app::runtime::stream::turn::StreamedTurn;
use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef;
use crate::dto::provider::request::{ChatRequest, StreamOptions, ToolDef};
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
@@ -17,7 +19,7 @@ pub struct LlmClient {
}
impl LlmClient {
pub fn new(api_key: String, model: String) -> Self {
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
let model = if model.is_empty() {
DEFAULT_MODEL.to_string()
} else {
@@ -31,7 +33,7 @@ impl LlmClient {
LlmClient {
client,
api_key,
base_url: DEFAULT_BASE_URL.to_string(),
base_url: base_url.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
model,
}
}
@@ -46,7 +48,7 @@ impl LlmClient {
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
) -> Result<ChatMessage> {
let req = crate::dto::provider::request::ChatRequest {
let req = ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(4096),
@@ -55,18 +57,130 @@ impl LlmClient {
stream: Some(false),
top_p: None,
stop: None,
stream_options: None,
};
let url = format!("{}/chat/completions", self.base_url);
let mut http_req = self.client
.post(&url)
.header("Content-Type", "application/json");
let max_retries = 10;
let mut attempt = 0;
loop {
attempt += 1;
let mut http_req = self.client
.post(&url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let result = (|| -> Result<ChatMessage> {
let resp = http_req.json(&req).send().map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT)
} else if e.is_connect() {
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
} else {
anyhow::anyhow!("API request failed: {}", e)
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
let message = data
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok(message)
})();
match result {
Ok(msg) => return Ok(msg),
Err(e) => {
if attempt >= max_retries {
return Err(e);
}
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
std::thread::sleep(Duration::from_secs(2));
}
}
}
}
/// Streaming variant of `chat_with_tools`. Feeds SSE chunks into an `SseParser` /
/// `StreamedTurn` and invokes `on_event` for every parsed `StreamEvent` as it arrives,
/// so the caller can push incremental UI updates in real time. Returns the fully
/// assembled assistant message plus token usage (prompt, completion) if the server
/// reported it. Retries the whole request only if no event has been observed yet
/// (once tokens start arriving, a partial turn cannot be safely replayed).
pub fn chat_with_tools_streaming(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
temperature: Option<f32>,
max_tokens: Option<u32>,
mut on_event: impl FnMut(&StreamEvent),
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
let req = ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(max_tokens.unwrap_or(4096)),
temperature: Some(temperature.unwrap_or(0.7)),
tools,
stream: Some(true),
top_p: None,
stop: None,
stream_options: Some(StreamOptions { include_usage: true }),
};
let url = format!("{}/chat/completions", self.base_url);
let max_retries = 10;
let mut attempt = 0;
let mut started = false;
loop {
attempt += 1;
let mut wrapped = |event: &StreamEvent| {
started = true;
on_event(event);
};
match self.try_stream_once(&req, &url, &mut wrapped) {
Ok(result) => return Ok(result),
Err(e) => {
if started || attempt >= max_retries {
return Err(e);
}
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
std::thread::sleep(Duration::from_secs(2));
}
}
}
}
fn try_stream_once(
&self,
req: &ChatRequest,
url: &str,
on_event: &mut dyn FnMut(&StreamEvent),
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
use std::io::Read;
let mut http_req = self.client
.post(url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let resp = http_req.json(&req).send().map_err(|e| {
let resp = http_req.json(req).send().map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT)
} else if e.is_connect() {
@@ -82,13 +196,49 @@ impl LlmClient {
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
let message = data
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok(message)
let mut turn = StreamedTurn::new();
let mut usage: Option<(u64, u64)> = None;
let mut parser = SseParser::new();
let mut reader = resp;
let mut byte_buf: Vec<u8> = Vec::new();
let mut chunk_buf = [0u8; 4096];
loop {
let n = reader.read(&mut chunk_buf)
.map_err(|e| anyhow::anyhow!("stream read error: {}", e))?;
if n == 0 {
break;
}
byte_buf.extend_from_slice(&chunk_buf[..n]);
let valid_len = match std::str::from_utf8(&byte_buf) {
Ok(s) => s.len(),
Err(e) => e.valid_up_to(),
};
if valid_len == 0 {
continue;
}
let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
byte_buf.drain(..valid_len);
for event in parser.feed(&text) {
on_event(&event);
match &event {
StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
usage = Some((*prompt_tokens, *completion_tokens));
}
StreamEvent::Error(msg) => {
anyhow::bail!("stream error: {}", msg);
}
StreamEvent::Done => {
turn.apply_event(&event);
return Ok((turn.build_assistant_message(), usage));
}
_ => turn.apply_event(&event),
}
}
}
turn.is_complete = true;
Ok((turn.build_assistant_message(), usage))
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod plan;
pub mod search;
pub mod seqthink;
pub mod shell;
pub mod shell_filter;
pub mod utility;
pub mod workflow;
+4
View File
@@ -51,6 +51,10 @@ impl Tool for Bash {
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
super::shell_filter::credentials::check_credential_read(&cmd)
.map_err(|e| anyhow!("blocked: {}", e))?;
super::shell_filter::git::check_git_destructive(&cmd)
.map_err(|e| anyhow!("blocked: {}", e))?;
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false);
if run_in_background {
let job = crate::app::bgbash::job::spawn_bash_job(cmd);
+3 -4
View File
@@ -74,10 +74,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
} else {
msg.content.clone()
};
let content_line = Line::from(vec![
Span::styled(format!("{} ", prefix), Style::default().fg(role_color)),
Span::styled(content_str, Style::default().fg(Theme::TEXT)),
]);
let mut content_spans = vec![Span::styled(format!("{} ", prefix), Style::default().fg(role_color))];
content_spans.extend(super::markdown::render_markdown(&content_str, area.width));
let content_line = Line::from(content_spans);
display_lines.push(header);
display_lines.push(content_line);
+3 -2
View File
@@ -1,7 +1,7 @@
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
pub fn render_markdown<'a>(text: &'a str, width: u16) -> Vec<Span<'a>> {
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let parser = pulldown_cmark::Parser::new(text);
let mut in_code_block = false;
@@ -122,6 +122,7 @@ pub fn render_markdown<'a>(text: &'a str, width: u16) -> Vec<Span<'a>> {
let mut spans_out = Vec::new();
let mut line_len = 0;
for span in &spans {
let style = span.style;
let s = span.content.clone();
let text = s.as_ref();
let remaining = text.len();
@@ -129,7 +130,7 @@ pub fn render_markdown<'a>(text: &'a str, width: u16) -> Vec<Span<'a>> {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
spans_out.push(Span::raw(text.to_string()));
spans_out.push(Span::styled(text.to_string(), style));
if !text.contains('\n') {
line_len += remaining;
} else {
+4 -7
View File
@@ -1,4 +1,5 @@
pub mod chat;
pub mod markdown;
pub mod status;
pub mod theme;
pub mod workflow;
@@ -148,7 +149,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
Style::default(),
)),
Line::from(Span::styled(
"Press Ctrl+Q again to confirm, Esc to cancel.",
"Press Enter to confirm, Esc to cancel.",
Style::default().fg(Theme::DIM),
)),
];
@@ -254,12 +255,8 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
}
crate::app::state::types::Overlay::Effort => {
let block = block.title(" Effort Level ");
let levels = ["low", "medium", "high", "xhigh", "max"];
let current_idx = if state.settings.temperature > 0.8 { 0usize }
else if state.settings.temperature > 0.5 { 1 }
else if state.settings.temperature > 0.3 { 2 }
else if state.settings.max_tokens > 4000 { 3 }
else { 4 };
let levels = crate::app::mode::effort::EFFORT_LEVELS;
let current_idx = crate::app::mode::effort::current_effort(state);
let mut lines: Vec<Line> = levels.iter().enumerate().map(|(i, l)| {
let selected = i == current_idx;
Line::from(Span::styled(