Skip to content

Commit 70acf42

Browse files
Alex HolmbergAlex Holmberg
authored andcommitted
feat: Syncable Cli Agent now includes thinking and more smooth ui processing.
Added docker generation with shell command execution for high standard dockerfile generations. UI has also been drastically improved showing clear thinking process with different color schemes.
1 parent b14cea7 commit 70acf42

12 files changed

Lines changed: 2027 additions & 183 deletions

File tree

-229 KB
Binary file not shown.

src/agent/commands.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
99
use crate::agent::ui::colors::ansi;
1010
use crossterm::{
11-
cursor::{self, MoveTo, MoveUp, MoveToColumn},
12-
event::{self, Event, KeyCode, KeyEvent as CrosstermKeyEvent, KeyModifiers},
11+
cursor::{self, MoveUp, MoveToColumn},
12+
event::{self, Event, KeyCode},
1313
execute,
1414
terminal::{self, Clear, ClearType},
1515
};

src/agent/history.rs

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
//! Conversation history management with compaction support
2+
//!
3+
//! This module provides conversation history storage and automatic compaction
4+
//! when the token count exceeds a configurable threshold, similar to gemini-cli.
5+
6+
use rig::completion::Message;
7+
use serde::{Deserialize, Serialize};
8+
9+
/// Default threshold for compression as a fraction of context window (85%)
10+
pub const DEFAULT_COMPRESSION_THRESHOLD: f32 = 0.85;
11+
12+
/// Fraction of history to preserve after compression (keep last 30%)
13+
pub const COMPRESSION_PRESERVE_FRACTION: f32 = 0.3;
14+
15+
/// Rough token estimate: ~4 characters per token
16+
const CHARS_PER_TOKEN: usize = 4;
17+
18+
/// Maximum context window tokens (conservative estimate for most models)
19+
const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128_000;
20+
21+
/// A conversation turn containing user input and assistant response
22+
#[derive(Debug, Clone, Serialize, Deserialize)]
23+
pub struct ConversationTurn {
24+
pub user_message: String,
25+
pub assistant_response: String,
26+
/// Tool calls made during this turn (for context preservation)
27+
pub tool_calls: Vec<ToolCallRecord>,
28+
/// Estimated token count for this turn
29+
pub estimated_tokens: usize,
30+
}
31+
32+
/// Record of a tool call for history tracking
33+
#[derive(Debug, Clone, Serialize, Deserialize)]
34+
pub struct ToolCallRecord {
35+
pub tool_name: String,
36+
pub args_summary: String,
37+
pub result_summary: String,
38+
}
39+
40+
/// Conversation history manager with compaction support
41+
#[derive(Debug, Clone)]
42+
pub struct ConversationHistory {
43+
/// Full conversation turns
44+
turns: Vec<ConversationTurn>,
45+
/// Compressed summary of older turns (if any)
46+
compressed_summary: Option<String>,
47+
/// Total estimated tokens in history
48+
total_tokens: usize,
49+
/// Maximum tokens before triggering compaction
50+
compression_threshold_tokens: usize,
51+
}
52+
53+
impl Default for ConversationHistory {
54+
fn default() -> Self {
55+
Self::new()
56+
}
57+
}
58+
59+
impl ConversationHistory {
60+
pub fn new() -> Self {
61+
let max_tokens = DEFAULT_MAX_CONTEXT_TOKENS;
62+
Self {
63+
turns: Vec::new(),
64+
compressed_summary: None,
65+
total_tokens: 0,
66+
compression_threshold_tokens: (max_tokens as f32 * DEFAULT_COMPRESSION_THRESHOLD) as usize,
67+
}
68+
}
69+
70+
/// Create with custom compression threshold
71+
pub fn with_threshold(max_context_tokens: usize, threshold_fraction: f32) -> Self {
72+
Self {
73+
turns: Vec::new(),
74+
compressed_summary: None,
75+
total_tokens: 0,
76+
compression_threshold_tokens: (max_context_tokens as f32 * threshold_fraction) as usize,
77+
}
78+
}
79+
80+
/// Estimate tokens in a string
81+
fn estimate_tokens(text: &str) -> usize {
82+
text.len() / CHARS_PER_TOKEN
83+
}
84+
85+
/// Add a new conversation turn
86+
pub fn add_turn(&mut self, user_message: String, assistant_response: String, tool_calls: Vec<ToolCallRecord>) {
87+
let turn_tokens = Self::estimate_tokens(&user_message)
88+
+ Self::estimate_tokens(&assistant_response)
89+
+ tool_calls.iter().map(|tc| {
90+
Self::estimate_tokens(&tc.tool_name)
91+
+ Self::estimate_tokens(&tc.args_summary)
92+
+ Self::estimate_tokens(&tc.result_summary)
93+
}).sum::<usize>();
94+
95+
self.turns.push(ConversationTurn {
96+
user_message,
97+
assistant_response,
98+
tool_calls,
99+
estimated_tokens: turn_tokens,
100+
});
101+
self.total_tokens += turn_tokens;
102+
}
103+
104+
/// Check if compaction is needed
105+
pub fn needs_compaction(&self) -> bool {
106+
self.total_tokens > self.compression_threshold_tokens
107+
}
108+
109+
/// Get current token count
110+
pub fn token_count(&self) -> usize {
111+
self.total_tokens
112+
}
113+
114+
/// Get number of turns
115+
pub fn turn_count(&self) -> usize {
116+
self.turns.len()
117+
}
118+
119+
/// Clear all history
120+
pub fn clear(&mut self) {
121+
self.turns.clear();
122+
self.compressed_summary = None;
123+
self.total_tokens = 0;
124+
}
125+
126+
/// Perform compaction - summarize older turns and keep recent ones
127+
/// Returns the summary that was created (for logging/display)
128+
pub fn compact(&mut self) -> Option<String> {
129+
if self.turns.len() < 2 {
130+
return None; // Nothing to compact
131+
}
132+
133+
// Calculate split point - keep last 30% of turns
134+
let preserve_count = ((self.turns.len() as f32) * COMPRESSION_PRESERVE_FRACTION).ceil() as usize;
135+
let preserve_count = preserve_count.max(1); // Keep at least 1 turn
136+
let split_point = self.turns.len().saturating_sub(preserve_count);
137+
138+
if split_point == 0 {
139+
return None; // Nothing to compress
140+
}
141+
142+
// Create summary of older turns
143+
let turns_to_compress = &self.turns[..split_point];
144+
let summary = self.create_summary(turns_to_compress);
145+
146+
// Update compressed summary
147+
let new_summary = if let Some(existing) = &self.compressed_summary {
148+
format!("{}\n\n{}", existing, summary)
149+
} else {
150+
summary.clone()
151+
};
152+
self.compressed_summary = Some(new_summary);
153+
154+
// Keep only recent turns
155+
let preserved_turns: Vec<_> = self.turns[split_point..].to_vec();
156+
self.turns = preserved_turns;
157+
158+
// Recalculate token count
159+
self.total_tokens = Self::estimate_tokens(self.compressed_summary.as_deref().unwrap_or(""))
160+
+ self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
161+
162+
Some(summary)
163+
}
164+
165+
/// Create a text summary of conversation turns
166+
fn create_summary(&self, turns: &[ConversationTurn]) -> String {
167+
let mut summary_parts = Vec::new();
168+
169+
for (i, turn) in turns.iter().enumerate() {
170+
let mut turn_summary = format!(
171+
"Turn {}: User asked about: {}",
172+
i + 1,
173+
Self::truncate_text(&turn.user_message, 100)
174+
);
175+
176+
if !turn.tool_calls.is_empty() {
177+
let tool_names: Vec<_> = turn.tool_calls.iter()
178+
.map(|tc| tc.tool_name.as_str())
179+
.collect();
180+
turn_summary.push_str(&format!(". Tools used: {}", tool_names.join(", ")));
181+
}
182+
183+
turn_summary.push_str(&format!(
184+
". Response summary: {}",
185+
Self::truncate_text(&turn.assistant_response, 200)
186+
));
187+
188+
summary_parts.push(turn_summary);
189+
}
190+
191+
format!(
192+
"Previous conversation summary ({} turns compressed):\n{}",
193+
turns.len(),
194+
summary_parts.join("\n")
195+
)
196+
}
197+
198+
/// Truncate text with ellipsis
199+
fn truncate_text(text: &str, max_len: usize) -> String {
200+
if text.len() <= max_len {
201+
text.to_string()
202+
} else {
203+
format!("{}...", &text[..max_len.saturating_sub(3)])
204+
}
205+
}
206+
207+
/// Convert history to Rig Message format for the agent
208+
pub fn to_messages(&self) -> Vec<Message> {
209+
use rig::completion::message::{Text, UserContent, AssistantContent};
210+
use rig::OneOrMany;
211+
212+
let mut messages = Vec::new();
213+
214+
// Add compressed summary as initial context if present
215+
if let Some(summary) = &self.compressed_summary {
216+
// Add as a user message with the summary, followed by acknowledgment
217+
messages.push(Message::User {
218+
content: OneOrMany::one(UserContent::Text(Text {
219+
text: format!("[Previous conversation context]\n{}", summary),
220+
})),
221+
});
222+
messages.push(Message::Assistant {
223+
id: None,
224+
content: OneOrMany::one(AssistantContent::Text(Text {
225+
text: "I understand the previous context. How can I help you continue?".to_string(),
226+
})),
227+
});
228+
}
229+
230+
// Add recent turns
231+
for turn in &self.turns {
232+
// User message
233+
messages.push(Message::User {
234+
content: OneOrMany::one(UserContent::Text(Text {
235+
text: turn.user_message.clone(),
236+
})),
237+
});
238+
239+
// Assistant response (simplified - just the text response)
240+
// Note: Tool calls are implicitly part of the response context
241+
messages.push(Message::Assistant {
242+
id: None,
243+
content: OneOrMany::one(AssistantContent::Text(Text {
244+
text: turn.assistant_response.clone(),
245+
})),
246+
});
247+
}
248+
249+
messages
250+
}
251+
252+
/// Check if there's any history
253+
pub fn is_empty(&self) -> bool {
254+
self.turns.is_empty() && self.compressed_summary.is_none()
255+
}
256+
257+
/// Get a brief status string for display
258+
pub fn status(&self) -> String {
259+
let compressed_info = if self.compressed_summary.is_some() {
260+
" (with compressed history)"
261+
} else {
262+
""
263+
};
264+
format!(
265+
"{} turns, ~{} tokens{}",
266+
self.turns.len(),
267+
self.total_tokens,
268+
compressed_info
269+
)
270+
}
271+
}
272+
273+
#[cfg(test)]
274+
mod tests {
275+
use super::*;
276+
277+
#[test]
278+
fn test_add_turn() {
279+
let mut history = ConversationHistory::new();
280+
history.add_turn(
281+
"Hello".to_string(),
282+
"Hi there!".to_string(),
283+
vec![],
284+
);
285+
assert_eq!(history.turn_count(), 1);
286+
assert!(!history.is_empty());
287+
}
288+
289+
#[test]
290+
fn test_compaction() {
291+
let mut history = ConversationHistory::with_threshold(1000, 0.1); // Low threshold
292+
293+
// Add many turns to trigger compaction
294+
for i in 0..10 {
295+
history.add_turn(
296+
format!("Question {}", i),
297+
format!("Answer {} with lots of detail to increase token count", i),
298+
vec![ToolCallRecord {
299+
tool_name: "analyze".to_string(),
300+
args_summary: "path: .".to_string(),
301+
result_summary: "Found rust project".to_string(),
302+
}],
303+
);
304+
}
305+
306+
if history.needs_compaction() {
307+
let summary = history.compact();
308+
assert!(summary.is_some());
309+
assert!(history.turn_count() < 10);
310+
}
311+
}
312+
313+
#[test]
314+
fn test_to_messages() {
315+
let mut history = ConversationHistory::new();
316+
history.add_turn(
317+
"What is this project?".to_string(),
318+
"This is a Rust CLI tool.".to_string(),
319+
vec![],
320+
);
321+
322+
let messages = history.to_messages();
323+
assert_eq!(messages.len(), 2); // 1 user + 1 assistant
324+
}
325+
326+
#[test]
327+
fn test_clear() {
328+
let mut history = ConversationHistory::new();
329+
history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
330+
history.clear();
331+
assert!(history.is_empty());
332+
assert_eq!(history.token_count(), 0);
333+
}
334+
}

0 commit comments

Comments
 (0)