Chat tabs, chat mode, own modes, compaction notice, CLI binaries again

- Terminals and file managers as tabs of a chat; chat terminals keep running on
  the server (no idle timeout) and re-attach from any device
- "Just chat" in New session and a built-in chat mode with a lean prompt and only
  web tools; custom modes with kind (coding/conversation), read-only and tool picker
- Mode selector as a dropdown
- The chat shows running compactions (automatic: tokens and share of the limit)
  and labels summaries
- Docker image builds and serves the terminal client binaries again
- Fix: atomic session updates; background title/usage writes no longer undo each
  other (auto compaction triggered one turn late)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 11:24:58 +01:00
1 parent 242b80605c
commit c850586a81
29 files changed
+831 -187

No files matched your search

+32 -19
View File
@@ -284,7 +284,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
let threshold = app.config().server.auto_compact_threshold;
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
compact(app.clone(), session_id, cancel.clone()).await?;
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
continue;
}
}
@@ -746,7 +746,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
for m in &req.messages {
for p in &m.parts {
match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(),
Part::ToolCall { input, .. } => chars += input.to_string().len(),
Part::ToolResult { content, images: imgs, .. } => {
chars += content.len();
@@ -913,9 +913,7 @@ async fn stream_step(
app.store.add_message(&session.id, &message)?;
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
}
let mut s = app.session(&session.id)?;
s.last_usage = Some(usage);
app.save_session(s)?;
app.update_session(&session.id, |s| s.last_usage = Some(usage))?;
return Ok(Step { message, stop_reason });
}
}
@@ -995,11 +993,12 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
Ok((title, usage)) => {
record_side_usage(&app, &session, &spec, &model, &usage, "title");
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
if s.title.is_empty() {
s.title = title.chars().take(80).collect();
let _ = app.save_session(s);
}
if !title.is_empty() {
let _ = app.update_session(&session.id, |s| {
if s.title.is_empty() {
s.title = title.chars().take(80).collect();
}
});
}
}
Err(e) => tracing::debug!("title generation failed: {e:#}"),
@@ -1096,7 +1095,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
let root = app.root_session_id(session_id);
let Ok(mut s) = app.session(&root) else { return };
let Ok(s) = app.session(&root) else { return };
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
if let Some(from) = only_from {
if s.phase.as_deref() != Some(from) {
@@ -1113,8 +1112,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
if s.phase.as_deref() == Some(phase) {
return;
}
s.phase = Some(phase.to_string());
let _ = app.save_session(s);
let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string()));
}
/// Review the last turn against the user's request. Returns feedback for the
@@ -1217,7 +1215,7 @@ pub fn transcript_of(history: &[Message]) -> String {
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::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)))
@@ -1234,7 +1232,23 @@ pub fn transcript_of(history: &[Message]) -> String {
}
/// 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<()> {
/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a
/// nearly full context; the chat shows the progress and the reason.
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let event = |state: &str| Event::Compaction {
session_id: session_id.to_string(),
state: state.into(),
auto: auto.is_some(),
tokens: auto.map(|a| a.0),
limit: auto.map(|a| a.1),
};
app.emit(event("started"));
let res = compact_inner(app.clone(), session_id, cancel, auto).await;
app.emit(event(if res.is_ok() { "done" } else { "failed" }));
res
}
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let session = app.session(session_id)?;
let history = active_history(app.store.messages(session_id)?);
if history.is_empty() {
@@ -1273,12 +1287,11 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
if summary.trim().is_empty() {
bail!("compaction produced an empty summary");
}
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
let mut s = app.session(session_id)?;
s.last_usage = None;
let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens()));
save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?;
app.update_session(session_id, |s| s.last_usage = None)?;
// Files must be re-read after compaction before editing.
app.with_runtime(session_id, |r| r.read_files.clear());
app.save_session(s)?;
Ok(())
}