feat(lsp): implement LSP client and server management
- Added LspClient for handling communication with LSP servers, including methods for initialization, notifications, and requests. - Introduced LspManager to manage multiple LSP server connections, allowing for connection, disconnection, and retrieval of server capabilities. - Created tools for connecting to LSP servers, retrieving diagnostics, hover information, code completion, definitions, and references. - Enhanced UI rendering to display token usage and settings in the overlay. - Updated status bar to show current token usage and selected provider/model.
This commit is contained in:
@@ -81,6 +81,7 @@ pub enum Action {
|
||||
},
|
||||
ModelList,
|
||||
AbortTurn,
|
||||
Compact,
|
||||
}
|
||||
|
||||
/// Apply an `Action` to the application state.
|
||||
@@ -441,6 +442,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in += tokens_in;
|
||||
rt.usage.tokens_out += tokens_out;
|
||||
rt.usage.last_tokens_in = tokens_in;
|
||||
rt.usage.last_tokens_out = tokens_out;
|
||||
rt.usage.api_calls += 1;
|
||||
}
|
||||
}
|
||||
@@ -463,6 +466,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.misc.thinking = false;
|
||||
turn_finished = true;
|
||||
}
|
||||
TurnEvent::Compacted(new_msgs) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.messages = new_msgs;
|
||||
state.push_toast(Toast::new(ToastKind::Info, "History auto-compacted by AI.".to_string()));
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if turn_finished {
|
||||
@@ -476,6 +486,23 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
||||
}
|
||||
Action::Compact => {
|
||||
let max_wire_tokens = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let total_chars: usize = rt.messages.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(|c| c.len())
|
||||
.sum();
|
||||
let token_estimate = total_chars / 4;
|
||||
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
|
||||
state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string()));
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
@@ -533,6 +560,10 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
let model = state.settings.model.clone();
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||
.map(|p| p.api_base.clone());
|
||||
let context_window = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||
api_key = provider_cfg.api_key_env.as_ref()
|
||||
@@ -570,10 +601,11 @@ 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, base_url),
|
||||
client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url),
|
||||
tdefs: tool_defs,
|
||||
tools,
|
||||
ctx,
|
||||
context_window,
|
||||
|
||||
workspace_roots,
|
||||
edit_log_session_dir: edit_session_dir,
|
||||
@@ -601,13 +633,14 @@ struct TurnCtx {
|
||||
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||
ctx: crate::tool::ToolCtx,
|
||||
context_window: usize,
|
||||
|
||||
workspace_roots: Vec<std::path::PathBuf>,
|
||||
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,
|
||||
max_tokens: Option<u32>,
|
||||
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
@@ -786,14 +819,26 @@ fn run_agent_turn(
|
||||
}
|
||||
|
||||
loop {
|
||||
let wire_msgs = if crate::app::runtime::shortsend::should_shape(msgs.len(), prev_shaped) {
|
||||
let total_chars: usize = msgs.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(|c| c.len())
|
||||
.sum();
|
||||
let token_estimate = total_chars / 4;
|
||||
let total_chars: usize = msgs.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(|c| c.len())
|
||||
.sum();
|
||||
let token_estimate = total_chars / 4;
|
||||
let max_wire_tokens = tc.context_window;
|
||||
|
||||
let wire_msgs = if crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) {
|
||||
prev_shaped = true;
|
||||
crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate)
|
||||
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
|
||||
|
||||
// Dispatch the compacted messages to the main thread so the local session history
|
||||
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Compacted(compacted.clone()));
|
||||
}
|
||||
|
||||
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||
msgs = compacted.clone();
|
||||
compacted
|
||||
} else {
|
||||
prev_shaped = false;
|
||||
msgs.clone()
|
||||
@@ -805,9 +850,9 @@ fn run_agent_turn(
|
||||
let mut usage = None;
|
||||
let result = tc.client.chat_with_tools_streaming(
|
||||
&wire_msgs,
|
||||
Some(tc.tdefs.clone()),
|
||||
if tc.tdefs.is_empty() { None } else { Some(tc.tdefs.clone()) },
|
||||
Some(tc.temperature),
|
||||
Some(tc.max_tokens),
|
||||
tc.max_tokens,
|
||||
|event| -> bool {
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return false;
|
||||
|
||||
@@ -78,6 +78,9 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::ModelList => {
|
||||
vec![Action::ModelList]
|
||||
}
|
||||
Command::Compact => {
|
||||
vec![Action::Compact]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
|
||||
@@ -3,34 +3,27 @@
|
||||
//! LLM API.
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
const MAX_WIRE_TOKENS: usize = 2_000_000;
|
||||
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
|
||||
const ENGAGE_HYSTERESIS: usize = 5;
|
||||
|
||||
/// Decide whether the message list should be shaped (compacted) before
|
||||
/// sending to the LLM.
|
||||
///
|
||||
/// Flow: skip shaping if fewer than `MIN_MESSAGES_BEFORE_SHAPE` messages
|
||||
/// → once past that threshold, use hysteresis (require 5 more messages
|
||||
/// before re-engaging if shaping is currently active) to avoid oscillation.
|
||||
/// Flow: trigger based on token estimate. If `token_estimate` exceeds
|
||||
/// `MAX_WIRE_TOKENS * 0.8`, we shape. We also apply hysteresis so it doesn't
|
||||
/// flutter.
|
||||
///
|
||||
/// Return: `true` if shaping should be applied.
|
||||
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
||||
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
|
||||
return false;
|
||||
}
|
||||
pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool {
|
||||
let threshold = if prev_shaped {
|
||||
MIN_MESSAGES_BEFORE_SHAPE + ENGAGE_HYSTERESIS
|
||||
(max_wire_tokens as f32 * 0.85) as usize
|
||||
} else {
|
||||
MIN_MESSAGES_BEFORE_SHAPE
|
||||
(max_wire_tokens as f32 * 0.90) as usize
|
||||
};
|
||||
total_messages >= threshold
|
||||
token_estimate >= threshold
|
||||
}
|
||||
|
||||
/// Compact a long message list by dropping middle messages and inserting
|
||||
/// a summary placeholder.
|
||||
///
|
||||
/// Flow: if the estimated token count is within budget, return messages
|
||||
/// Flow: if the estimated token count is within budget and not forced, return messages
|
||||
/// unchanged → otherwise keep the system message and the most recent
|
||||
/// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior
|
||||
/// conversation compacted]` system message in between.
|
||||
@@ -38,25 +31,75 @@ pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
||||
/// Why: keeps context-size overhead roughly constant regardless of
|
||||
/// session length.
|
||||
///
|
||||
/// Return: a new Vec<ChatMessage> that preserves the first message and
|
||||
/// the tail.
|
||||
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
|
||||
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
|
||||
pub fn shape_messages(
|
||||
messages: &[ChatMessage],
|
||||
token_count: usize,
|
||||
max_wire_tokens: usize,
|
||||
force: bool,
|
||||
client: Option<&crate::service::provider::LlmClient>,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
|
||||
return messages.to_vec();
|
||||
}
|
||||
let keep_recent = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take(MAX_WIRE_TOKENS / 200)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut result = Vec::new();
|
||||
if let Some(first) = messages.first() {
|
||||
result.push(first.clone());
|
||||
|
||||
let target_tokens = (max_wire_tokens as f32 * 0.70) as usize;
|
||||
let mut current_tokens = 0;
|
||||
let mut keep_recent = Vec::new();
|
||||
let mut dropped_msgs = Vec::new();
|
||||
|
||||
// Always keep the very first message (System Prompt) which we don't count here
|
||||
// as we just blindly preserve it later.
|
||||
let mut msgs_to_eval = messages.to_vec();
|
||||
let first = if !msgs_to_eval.is_empty() {
|
||||
Some(msgs_to_eval.remove(0))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Iterate backwards from the most recent to oldest
|
||||
for m in msgs_to_eval.into_iter().rev() {
|
||||
let text = m.content.as_deref().unwrap_or("");
|
||||
let msg_tokens = text.len() / 4;
|
||||
|
||||
if current_tokens + msg_tokens <= target_tokens {
|
||||
current_tokens += msg_tokens;
|
||||
keep_recent.push(m);
|
||||
} else {
|
||||
dropped_msgs.push(m); // These will end up in reverse chronological order
|
||||
}
|
||||
}
|
||||
result.push(ChatMessage::system(
|
||||
"[prior conversation compacted]".to_string(),
|
||||
));
|
||||
|
||||
// Reverse dropped_msgs so they are back in chronological order
|
||||
dropped_msgs.reverse();
|
||||
|
||||
let mut result = Vec::new();
|
||||
if let Some(f) = first {
|
||||
result.push(f);
|
||||
}
|
||||
|
||||
if !dropped_msgs.is_empty() {
|
||||
let mut summary_text = "[prior conversation compacted]".to_string();
|
||||
|
||||
if let Some(llm) = client {
|
||||
let prompt = format!(
|
||||
"Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}",
|
||||
dropped_msgs.iter()
|
||||
.map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or("")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
);
|
||||
|
||||
let req_msgs = vec![ChatMessage::user(prompt)];
|
||||
if let Ok(resp) = llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
||||
if let Some(content) = resp.0.content {
|
||||
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(ChatMessage::system(summary_text));
|
||||
}
|
||||
|
||||
result.extend(keep_recent.into_iter().rev());
|
||||
result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user