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:
1 parent
242b80605c
commit
c850586a81
29 files changed
+831
-187
No files matched your search
+32
-19
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user