Add file badges, menu icons, LSP diagnostics, robust compaction
- Files in chat: preview with type/size badges and file name; attachment chips - Menu and settings tabs with icons - Heatmap: distinct colour for empty hours, hourly labels - Context chip shows used/total tokens (e.g. 221k/240k) plus percent - LSP client: diagnostics appended to edit/write results and a diagnostics tool (typescript-language-server, pyright, rust-analyzer, gopls, clangd; sandboxed) - Compaction summarizes a plain transcript and retries once on empty output - files/stat endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b569b6835
commit
ec1b9b4925
22 files changed
+1036
-48
No files matched your search
+87
-19
@@ -462,12 +462,26 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
}
|
||||
let input_path = input["path"].as_str().map(String::from);
|
||||
let res = if name == "task" {
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
Ok(mut out) if tool.is_edit() && !out.is_error => {
|
||||
if let Some(path) = input_path.as_deref().and_then(|p| ctx.resolve(p).ok()) {
|
||||
let wait = std::time::Duration::from_secs(4);
|
||||
if let Some(Ok(d)) = crate::lsp::diagnostics(app, &path, &ctx.cwd, ctx.sandbox.as_ref(), wait).await {
|
||||
out.content.push_str(&crate::lsp::format(input_path.as_deref().unwrap_or(""), &d, false));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
match res {
|
||||
Ok(out) => Part::ToolResult {
|
||||
call_id: call_id.into(),
|
||||
@@ -860,6 +874,54 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
|
||||
const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points.";
|
||||
|
||||
/// Remove a leading `<think>…</think>` block some models put into the text.
|
||||
fn strip_think(text: &str) -> String {
|
||||
let t = text.trim_start();
|
||||
if t.starts_with("<think>") {
|
||||
if let Some(end) = t.find("</think>") {
|
||||
return t[end + 8..].trim().to_string();
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{} […{} more bytes]", &s[..end], s.len() - end)
|
||||
}
|
||||
|
||||
/// The history as readable text for summarizing (tool output shortened).
|
||||
fn transcript_of(history: &[Message]) -> String {
|
||||
let mut out = String::new();
|
||||
for m in history {
|
||||
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
let mut body = String::new();
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
||||
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
||||
Part::ToolResult { name, content, is_error, .. } => {
|
||||
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
||||
}
|
||||
Part::Image { .. } => body.push_str("[image]\n"),
|
||||
Part::Reasoning { .. } => {}
|
||||
}
|
||||
}
|
||||
if !body.trim().is_empty() {
|
||||
out.push_str(&format!("### {role}\n{}\n", body.trim_end()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
|
||||
let session = app.session(session_id)?;
|
||||
@@ -869,27 +931,33 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
}
|
||||
let spec = session.model.clone();
|
||||
let (provider, model) = app.registry().resolve(&spec)?;
|
||||
let mut messages = history;
|
||||
// Strip images to keep the summary request small.
|
||||
for m in &mut messages {
|
||||
m.parts.retain(|p| !matches!(p, Part::Image { .. }));
|
||||
for p in &mut m.parts {
|
||||
if let Part::ToolResult { images, .. } = p {
|
||||
images.clear();
|
||||
}
|
||||
// The history goes in as a plain transcript: with real tool calls in the
|
||||
// history (and no tools offered) some models answer with a tool call or
|
||||
// only think, leaving the summary empty.
|
||||
let transcript = transcript_of(&history);
|
||||
let mut summary = String::new();
|
||||
for attempt in 0..2 {
|
||||
let prompt = if attempt == 0 {
|
||||
format!("<transcript>\n{transcript}\n</transcript>\n\n{COMPACT_PROMPT}")
|
||||
} else {
|
||||
format!("<transcript>\n{transcript}\n</transcript>\n\n{COMPACT_PROMPT}\n\nAnswer directly with the summary as plain Markdown text. Do not call tools.")
|
||||
};
|
||||
let req = ChatRequest {
|
||||
system: "You are a precise summarizer of software engineering sessions. You never call tools; you only write the requested summary.".into(),
|
||||
messages: vec![Message::new(Role::User, vec![Part::Text { text: prompt }])],
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
summary = strip_think(&text);
|
||||
if !summary.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
tracing::warn!("compaction of {session_id}: empty summary (attempt {})", attempt + 1);
|
||||
}
|
||||
messages.push(Message::new(Role::User, vec![Part::Text { text: COMPACT_PROMPT.into() }]));
|
||||
let req = ChatRequest {
|
||||
system: "You are a precise summarizer of software engineering sessions.".into(),
|
||||
messages,
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (summary, usage) = complete_text(&app, &spec, provider, req, cancel).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
if summary.trim().is_empty() {
|
||||
bail!("compaction produced an empty summary");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user