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:
asepharyana
2026-07-12 13:40:58 +07:00
parent 8ad042139e
commit abc7a58e31
19 changed files with 1317 additions and 60 deletions
+329
View File
@@ -0,0 +1,329 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
const LSP_INIT_TIMEOUT_MS: u64 = 60_000;
const LSP_CALL_TIMEOUT_MS: u64 = 30_000;
const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
pub struct LspClient {
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
server_capabilities: Value,
}
fn file_path_to_uri(path: &str) -> String {
let abs_path = std::path::Path::new(path);
let abs_path = if abs_path.is_relative() {
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => abs_path.to_path_buf(),
}
} else {
abs_path.to_path_buf()
};
let canonical = abs_path.canonicalize().unwrap_or(abs_path);
let path_str = canonical.to_string_lossy();
if cfg!(windows) {
let path_str = path_str.replace('\\', "/");
if path_str.starts_with('/') {
format!("file://{}", path_str)
} else {
format!("file:///{}", path_str)
}
} else {
format!("file://{}", path_str)
}
}
impl LspClient {
pub fn spawn(command: &str, args: &[String]) -> anyhow::Result<Self> {
let mut cmd = Command::new(command);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{}': {}", command, e))?;
let stdin = child.stdin.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
let stdout = BufReader::new(child.stdout.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?);
let mut client = LspClient {
stdin,
stdout,
next_id: 0,
server_capabilities: Value::Null,
};
let init_params = json!({
"processId": std::process::id(),
"clientInfo": {
"name": "zesdex",
"version": "0.1.0"
},
"capabilities": {
"textDocument": {
"synchronization": {
"dynamicRegistration": true,
"willSave": false,
"willSaveWaitUntil": false,
"didSave": false
},
"hover": {
"dynamicRegistration": true,
"contentFormat": ["plaintext", "markdown"]
},
"completion": {
"dynamicRegistration": true,
"completionItem": {
"snippetSupport": false
}
},
"definition": {
"dynamicRegistration": true
},
"references": {
"dynamicRegistration": true
},
"documentSymbol": {
"dynamicRegistration": true,
"hierarchicalDocumentSymbolSupport": true
}
},
"workspace": {
"workspaceFolders": true
},
"general": {
"positionEncodings": ["utf-16"]
}
}
});
let result = client.call_with_timeout("initialize", init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
client.notify("initialized", json!({}))?;
Ok(client)
}
pub fn server_capabilities(&self) -> &Value {
&self.server_capabilities
}
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
}
fn call_with_timeout(&mut self, method: &str, params: Value, timeout: Duration) -> anyhow::Result<Value> {
self.next_id += 1;
let id = self.next_id;
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
self.send_frame(&req)?;
self.read_response(id, timeout)
}
pub fn notify(&mut self, method: &str, params: Value) -> anyhow::Result<()> {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params
});
self.send_frame(&req)
}
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
let body = serde_json::to_string(msg)
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {}", e))?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
self.stdin.write_all(header.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {}", e))?;
self.stdin.write_all(body.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {}", e))?;
self.stdin.flush()
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {}", e))?;
Ok(())
}
fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
if Instant::now() > deadline {
anyhow::bail!("LSP call timed out after {}ms", timeout.as_millis());
}
let frame = self.read_frame()?;
if frame.get("id") == Some(&json!(expected_id)) {
if let Some(err) = frame.get("error") {
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
anyhow::bail!("LSP error {}: {}", code, msg);
}
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
}
}
}
pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
if Instant::now() > deadline {
anyhow::bail!("timed out waiting for LSP notification '{}'", method);
}
let frame = self.read_frame()?;
if frame.get("method") == Some(&json!(method)) {
return Ok(frame.get("params").cloned().unwrap_or(Value::Null));
}
}
}
fn read_frame(&mut self) -> anyhow::Result<Value> {
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
match self.stdout.read_line(&mut line) {
Ok(0) => anyhow::bail!("LSP server closed the connection"),
Ok(_) => {}
Err(e) => anyhow::bail!("LSP read error: {}", e),
}
let trimmed = line.trim();
if trimmed.is_empty() {
break;
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
content_length = Some(
len_str.trim().parse::<usize>()
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?,
);
}
}
let length = content_length
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
let mut body = vec![0u8; length];
self.stdout.read_exact(&mut body)
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({} bytes): {}", length, e))?;
let json_str = String::from_utf8(body)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {}", e))?;
serde_json::from_str(&json_str)
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {}", e))
}
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
self.notify("textDocument/didOpen", json!({
"textDocument": {
"uri": uri,
"languageId": language_id,
"version": version,
"text": text
}
}))
}
#[allow(dead_code)]
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
self.notify("textDocument/didChange", json!({
"textDocument": {
"uri": uri,
"version": version
},
"contentChanges": [{
"text": text
}]
}))
}
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
self.notify("textDocument/didClose", json!({
"textDocument": {
"uri": uri
}
}))
}
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/hover", json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}))
}
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/completion", json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}))
}
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/definition", json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}))
}
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/references", json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character },
"context": {
"includeDeclaration": true
}
}))
}
#[allow(dead_code)]
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
self.call("textDocument/documentSymbol", json!({
"textDocument": { "uri": uri }
}))
}
pub fn collect_diagnostics(
&mut self,
uri: &str,
language_id: &str,
text: &str,
) -> anyhow::Result<Value> {
self.did_open(uri, language_id, 1, text)?;
let result = self.read_notification(
"textDocument/publishDiagnostics",
Duration::from_millis(LSP_DIAGNOSTICS_TIMEOUT_MS),
);
self.did_close(uri)?;
match result {
Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))),
Err(e) => Err(e),
}
}
pub fn shutdown(&mut self) -> anyhow::Result<()> {
let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5));
let _ = self.notify("exit", json!({}));
Ok(())
}
}
impl Drop for LspClient {
fn drop(&mut self) {
let _ = self.notify("exit", json!({}));
}
}
pub fn path_to_lsp_uri(path: &str) -> String {
file_path_to_uri(path)
}
+80
View File
@@ -0,0 +1,80 @@
use std::sync::{Arc, Mutex};
mod client;
pub use client::{path_to_lsp_uri, LspClient};
#[derive(Clone)]
pub struct LspServer {
#[allow(dead_code)]
pub name: String,
#[allow(dead_code)]
pub command: String,
#[allow(dead_code)]
pub args: Vec<String>,
pub language_id: String,
pub client: Arc<Mutex<LspClient>>,
}
#[derive(Clone)]
pub struct LspManager {
pub servers: Vec<LspServer>,
}
impl LspManager {
pub fn new() -> Self {
LspManager {
servers: Vec::new(),
}
}
pub fn connect(
&mut self,
name: &str,
command: &str,
args: &[String],
language_id: &str,
) -> anyhow::Result<()> {
if self.servers.iter().any(|s| s.name == name) {
anyhow::bail!("LSP server '{}' is already connected", name);
}
let client = LspClient::spawn(command, args)?;
self.servers.push(LspServer {
name: name.to_string(),
command: command.to_string(),
args: args.to_vec(),
language_id: language_id.to_string(),
client: Arc::new(Mutex::new(client)),
});
Ok(())
}
#[allow(dead_code)]
pub fn find_server(&self, name: &str) -> Option<&LspServer> {
self.servers.iter().find(|s| s.name == name)
}
pub fn get_client(&self, name: &str) -> Option<Arc<Mutex<LspClient>>> {
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone())
}
pub fn disconnect(&mut self, name: &str) -> bool {
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
if let Ok(mut client) = server.client.lock() {
let _ = client.shutdown();
}
}
let len = self.servers.len();
self.servers.retain(|s| s.name != name);
self.servers.len() < len
}
pub fn get_language_id(&self, name: &str) -> Option<String> {
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
}
}
impl Default for LspManager {
fn default() -> Self {
Self::new()
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
//! Top-level application module: harness, modes, runtime loop, state,
//! workflows, subagents, review, background bash, and MCP integration.
//! workflows, subagents, review, background bash, MCP integration, and
//! native LSP client.
pub mod harness;
pub mod mode;
pub mod runtime;
@@ -9,3 +10,4 @@ pub mod subagent;
pub mod review;
pub mod bgbash;
pub mod mcp;
pub mod lsp;
+3 -3
View File
@@ -13,11 +13,11 @@ 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) {
pub fn generation_params(level: usize, base_max_tokens: Option<u32>) -> (f32, Option<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))
let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32);
(temperature, max_tokens.map(|t| t.max(256)))
}
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
+56 -11
View File
@@ -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;
+3
View File
@@ -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(),
+74 -31
View File
@@ -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
}
+3
View File
@@ -277,6 +277,8 @@ pub struct MiscState {
pub selected_index: usize,
pub editor: Option<super::super::mode::editor::EditorState>,
pub api_connected: bool,
#[allow(dead_code)]
pub api_context_length: Option<u32>,
pub tick_count: u64,
pub todo_content: String,
}
@@ -294,6 +296,7 @@ impl MiscState {
selected_index: 0,
editor: None,
api_connected: false,
api_context_length: None,
tick_count: 0,
todo_content: String::new(),
}
+4
View File
@@ -12,6 +12,7 @@ use tokio::sync::RwLock;
use super::misc::{DirCache, InputState, MiscState, ScrollState};
use super::runtime::{SessionRuntime, TurnEvent};
use super::types::{Origin, Toast, TranscriptCache};
use crate::app::lsp::LspManager;
use crate::app::mcp::manager::McpManager;
use crate::app::workflow::engine::WorkflowEngine;
use crate::model::app_config::AppConfig;
@@ -66,6 +67,7 @@ pub struct AppStateRest {
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
pub workflow_engine: WorkflowEngine,
pub mcp_manager: McpManager,
pub lsp_manager: Arc<Mutex<LspManager>>,
pub dirty: bool,
pub quit: bool,
}
@@ -117,6 +119,7 @@ impl AppStateRest {
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(),
lsp_manager: Arc::new(Mutex::new(LspManager::new())),
sessions: Vec::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
@@ -196,6 +199,7 @@ impl AppStateRest {
dir_cache: self.dir_cache.clone(),
origin,
graduated_checks: Vec::new(),
lsp_manager: self.lsp_manager.clone(),
}
}
}
+5
View File
@@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
pub struct UsageStats {
pub tokens_in: u64,
pub tokens_out: u64,
#[serde(default)]
pub last_tokens_in: u64,
#[serde(default)]
pub last_tokens_out: u64,
pub api_calls: u64,
pub review_tokens: u64,
pub total_ms: u64,
@@ -96,6 +100,7 @@ pub enum TurnEvent {
tokens_in: u64,
tokens_out: u64,
},
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
Error(String),
Done,
}