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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:50:12 +01:00
1 parent 5b569b6835
commit ec1b9b4925
22 files changed
+1036 -48

No files matched your search

+87 -19
View File
@@ -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");
}