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

+33 -4
View File
@@ -18,13 +18,16 @@ use tokio::sync::broadcast;
/// Output kept for re-attaching clients.
const SCROLLBACK: usize = 256 * 1024;
/// Detached terminals are stopped after this time.
/// Detached terminals outside a chat are stopped after this time; terminals that
/// belong to a chat stay open until they are closed, exit or the chat is deleted.
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
const MAX_PER_USER: usize = 8;
const MAX_PER_USER: usize = 16;
pub struct Term {
pub id: String,
pub owner_id: String,
/// Chat the terminal is a tab of.
pub session_id: Option<String>,
pub cwd: PathBuf,
pub created_at: i64,
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
@@ -57,6 +60,7 @@ impl Term {
json!({
"id": self.id,
"cwd": self.cwd,
"session_id": self.session_id,
"created_at": self.created_at,
"alive": self.alive(),
"attached": *self.attached.lock().unwrap() > 0,
@@ -76,6 +80,30 @@ impl Terminals {
v.into_iter().map(|(_, i)| i).collect()
}
/// Terminals of one chat (oldest first).
pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec<Value> {
let mut v: Vec<_> = self
.map
.lock()
.unwrap()
.values()
.filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id))
.map(|t| (t.created_at, t.info()))
.collect();
v.sort_by_key(|(c, _)| *c);
v.into_iter().map(|(_, i)| i).collect()
}
/// Close all terminals of a deleted chat.
pub fn kill_session(&self, session_id: &str) {
let gone: Vec<Arc<Term>> = {
let mut map = self.map.lock().unwrap();
let ids: Vec<String> = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect();
ids.iter().filter_map(|id| map.remove(id)).collect()
};
gone.iter().for_each(|t| t.kill());
}
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
}
@@ -100,7 +128,7 @@ impl Terminals {
pub fn reap(&self) {
let mut map = self.map.lock().unwrap();
map.retain(|_, t| {
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
if stale || gone {
t.kill();
@@ -113,7 +141,7 @@ impl Terminals {
}
/// Start a shell for `user` in `cwd` (already access-checked).
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>, cols: u16, rows: u16) -> Result<Arc<Term>> {
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
}
@@ -142,6 +170,7 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
let term = Arc::new(Term {
id: crate::types::new_id(),
owner_id: user.id.clone(),
session_id,
cwd: cwd.to_path_buf(),
created_at: crate::types::now_ms(),
writer: tokio::sync::Mutex::new(writer),