From ec1b9b49255c1796929dc3392c889923dcbf9831 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 8 Nov 2026 19:50:12 +0100 Subject: [PATCH] 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 --- Dockerfile | 3 + config.example.toml | 18 ++ server/src/api.rs | 1 + server/src/app.rs | 3 + server/src/config.rs | 35 ++ server/src/files.rs | 13 + server/src/lsp.rs | 511 ++++++++++++++++++++++++++++++ server/src/main.rs | 2 + server/src/runner.rs | 106 +++++-- server/src/tools/mod.rs | 1 + web/src/components/Charts.tsx | 8 +- web/src/components/ChatView.tsx | 51 ++- web/src/components/Composer.tsx | 6 +- web/src/components/FileFigure.tsx | 146 +++++++++ web/src/components/Markdown.tsx | 24 +- web/src/components/Settings.tsx | 16 + web/src/components/Sidebar.tsx | 5 + web/src/components/ToolCard.tsx | 15 +- web/src/i18n/de.ts | 1 + web/src/i18n/en.ts | 1 + web/src/styles.css | 114 +++++++ web/src/themes/base.css | 4 +- 22 files changed, 1036 insertions(+), 48 deletions(-) create mode 100644 server/src/lsp.rs create mode 100644 web/src/components/FileFigure.tsx diff --git a/Dockerfile b/Dockerfile index 0b2ffe0..a506597 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \ chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \ && rm -rf /var/lib/apt/lists/* +# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be +# installed by agents: rustup component add rust-analyzer, go install …). +RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft COPY --from=web /web/dist /app/web COPY server/ghidra /app/ghidra diff --git a/config.example.toml b/config.example.toml index abb757f..8df9b1b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -182,6 +182,7 @@ bash_output = "allow" kill_shell = "allow" web_fetch = "ask" web_search = "allow" +diagnostics = "allow" browser_snapshot = "allow" image_compare = "allow" generate_image = "ask" # costs money per image @@ -299,6 +300,23 @@ url = "" api_key = "" +# ----------------------------------------------------------------------------- +# Language servers (diagnostics after edits, tool "diagnostics") +# ----------------------------------------------------------------------------- +# Built in: rust (rust-analyzer), typescript (typescript-language-server), +# python (pyright-langserver), go (gopls), c (clangd) – used when installed. +# TypeScript and Python servers ship with the image. Servers run in the +# user's sandbox and stop after 15 minutes without use. +# ----------------------------------------------------------------------------- +[lsp] +enabled = true +# Add or override a server: +# [lsp.servers.zig] +# command = "zls" +# extensions = ["zig"] +# root_markers = ["build.zig"] + + # ----------------------------------------------------------------------------- # Sandbox # ----------------------------------------------------------------------------- diff --git a/server/src/api.rs b/server/src/api.rs index a03bf38..f75e495 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -92,6 +92,7 @@ pub fn router(app: AppState) -> Router { .route("/files/list", get(crate::files::list)) .route("/files/read", get(crate::files::read)) .route("/files/raw", get(crate::files::raw)) + .route("/files/stat", get(crate::files::stat)) .route("/files/write", put(crate::files::write)) .route("/files/create", post(crate::files::create)) .route("/files/move", post(crate::files::move_or_copy)) diff --git a/server/src/app.rs b/server/src/app.rs index 31d8e40..4df72a8 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -69,6 +69,8 @@ pub struct App { pub tool_install: crate::reverse::ToolInstall, /// Background shells of all sessions. pub shells: crate::shells::Shells, + /// Language servers (diagnostics after edits). + pub lsp: crate::lsp::Lsp, } /// What a user may do with a session. @@ -105,6 +107,7 @@ impl App { login_failures: Mutex::default(), tool_install: Mutex::default(), shells: Mutex::default(), + lsp: Default::default(), }); crate::auth::bootstrap(&app)?; app.clone().start_mcp(); diff --git a/server/src/config.rs b/server/src/config.rs index 152f548..87ed6c4 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -23,8 +23,43 @@ pub struct Config { pub images: ImagesConfig, pub sandbox: SandboxConfig, pub search: SearchConfig, + pub lsp: LspConfig, } +/// Language servers for diagnostics after edits. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct LspConfig { + pub enabled: bool, + /// Extra or overridden servers (built-in: rust, typescript, python, go, c). + pub servers: std::collections::BTreeMap, +} + +impl Default for LspConfig { + fn default() -> Self { + Self { enabled: true, servers: Default::default() } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LspServer { + pub command: String, + #[serde(default)] + pub args: Vec, + /// File extensions without dot. + #[serde(default)] + pub extensions: Vec, + /// Files that mark the project root (e.g. Cargo.toml). + #[serde(default)] + pub root_markers: Vec, + /// initializationOptions sent to the server. + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub init_options: serde_json::Value, + #[serde(default = "yes")] + pub enabled: bool, +} + + /// Web search for the web_search tool. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] diff --git a/server/src/files.rs b/server/src/files.rs index 68eb2cf..86be21d 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -259,6 +259,19 @@ pub async fn raw(Extension(user): Extension, State(app): State, .into_response()) } +#[derive(Deserialize)] +pub struct StatQuery { + path: String, +} + +/// Size, type and modification time of a file (captions in the chat). +pub async fn stat(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let p = checked_path(&app, &user, &q.path, true)?; + let m = tokio::fs::metadata(&p).await?; + let mtime = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); + Ok(Json(json!({"size": m.len(), "dir": m.is_dir(), "mime": mime_for(&p), "mtime": mtime}))) +} + #[derive(Deserialize)] pub struct WriteBody { path: String, diff --git a/server/src/lsp.rs b/server/src/lsp.rs new file mode 100644 index 0000000..378a93a --- /dev/null +++ b/server/src/lsp.rs @@ -0,0 +1,511 @@ +//! Minimal Language Server Protocol client for diagnostics after edits. +//! +//! Language servers are started on demand per (user, server, project root), +//! run in the user's sandbox like shell commands, and are stopped after a +//! while without use. After the agent changes a file, the file is synced to +//! the server and the errors it reports are appended to the tool result. + +use crate::app::App; +use crate::config::LspServer; +use crate::sandbox::SandboxSpec; +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin}; +use tokio::sync::{Notify, oneshot}; + +const IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const MAX_SHOWN: usize = 20; + +/// Built-in servers (used when the config defines none). +pub fn default_servers() -> Vec<(String, LspServer)> { + let s = |cmd: &str, args: &[&str], ext: &[&str], roots: &[&str]| LspServer { + command: cmd.into(), + args: args.iter().map(|a| a.to_string()).collect(), + extensions: ext.iter().map(|a| a.to_string()).collect(), + root_markers: roots.iter().map(|a| a.to_string()).collect(), + init_options: Value::Null, + enabled: true, + }; + // Global TypeScript as fallback when the project has none in node_modules. + let home = std::env::var("HOME").unwrap_or_default(); + let ts_lib = ["/usr/local/lib/node_modules/typescript/lib".to_string(), format!("{home}/.npm-global/lib/node_modules/typescript/lib")] + .into_iter() + .find(|p| Path::new(p).is_dir()); + let mut ts = s("typescript-language-server", &["--stdio"], &["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"], &["tsconfig.json", "jsconfig.json", "package.json"]); + if let Some(lib) = ts_lib { + ts.init_options = json!({"tsserver": {"fallbackPath": lib}}); + } + vec![ + ("rust".into(), s("rust-analyzer", &[], &["rs"], &["Cargo.toml"])), + ("typescript".into(), ts), + ("python".into(), s("pyright-langserver", &["--stdio"], &["py", "pyi"], &["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pyrightconfig.json"])), + ("go".into(), s("gopls", &[], &["go"], &["go.mod"])), + ("c".into(), s("clangd", &[], &["c", "h", "cc", "cpp", "cxx", "hpp", "hh"], &["compile_commands.json", "CMakeLists.txt", "Makefile"])), + ] +} + +#[derive(Debug, Clone)] +pub struct Diagnostic { + pub line: u64, + pub col: u64, + pub severity: u64, + pub message: String, + pub source: String, +} + +struct Client { + stdin: tokio::sync::Mutex, + child: Mutex>, + next_id: AtomicI64, + pending: Mutex>>, + /// uri -> (publish counter, diagnostics) + diags: Mutex)>>, + published: Notify, + /// uri -> version of opened documents + open: Mutex>, + last_used: Mutex, + dead: std::sync::atomic::AtomicBool, +} + +impl Drop for Client { + fn drop(&mut self) { + if let Some(mut c) = self.child.lock().unwrap().take() { + let _ = c.start_kill(); + } + } +} + +#[derive(Default)] +pub struct Lsp { + clients: Mutex>>, + /// Servers that failed to start (key -> time), not retried for a while. + failed: Mutex>, +} + +fn file_uri(p: &Path) -> String { + let mut s = String::from("file://"); + for b in p.display().to_string().bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' => s.push(b as char), + _ => s.push_str(&format!("%{b:02X}")), + } + } + s +} + +fn language_id(ext: &str) -> &'static str { + match ext { + "rs" => "rust", + "ts" | "mts" | "cts" => "typescript", + "tsx" => "typescriptreact", + "js" | "mjs" | "cjs" => "javascript", + "jsx" => "javascriptreact", + "py" | "pyi" => "python", + "go" => "go", + "c" | "h" => "c", + _ => "cpp", + } +} + +/// Find a command on the PATH the process will see (sandbox home or server home). +fn find_command(cmd: &str, sandbox: Option<&SandboxSpec>) -> Option { + if cmd.contains('/') { + return Path::new(cmd).exists().then(|| PathBuf::from(cmd)); + } + let mut dirs: Vec = Vec::new(); + match sandbox { + Some(s) => { + for d in [".local/bin", ".cargo/bin", ".npm-global/bin", "go/bin"] { + dirs.push(s.home.join(d)); + } + } + None => { + if let Ok(p) = std::env::var("PATH") { + dirs.extend(std::env::split_paths(&p)); + } + if let Ok(h) = std::env::var("HOME") { + dirs.push(Path::new(&h).join("go/bin")); + } + } + } + dirs.extend(["/usr/local/bin", "/usr/bin", "/bin"].map(PathBuf::from)); + dirs.into_iter().map(|d| d.join(cmd)).find(|p| p.is_file()) +} + +/// Nearest directory (from the file upwards, not above `limit`) containing a marker. +fn project_root(file: &Path, markers: &[String], limit: &Path) -> PathBuf { + let mut dir = file.parent(); + while let Some(d) = dir { + if markers.iter().any(|m| d.join(m).exists()) { + return d.to_path_buf(); + } + if d == limit || !d.starts_with(limit) { + break; + } + dir = d.parent(); + } + if file.starts_with(limit) { limit.to_path_buf() } else { file.parent().unwrap_or(limit).to_path_buf() } +} + +async fn write_msg(stdin: &tokio::sync::Mutex, msg: &Value) -> Result<()> { + let body = serde_json::to_vec(msg)?; + let mut w = stdin.lock().await; + w.write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()).await?; + w.write_all(&body).await?; + w.flush().await?; + Ok(()) +} + +impl Client { + async fn notify(&self, method: &str, params: Value) -> Result<()> { + write_msg(&self.stdin, &json!({"jsonrpc": "2.0", "method": method, "params": params})).await + } + + async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = oneshot::channel(); + self.pending.lock().unwrap().insert(id, tx); + write_msg(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).await?; + let v = tokio::time::timeout(timeout, rx).await.map_err(|_| anyhow!("{method}: no answer"))??; + if let Some(e) = v.get("error") { + bail!("{method}: {e}"); + } + Ok(v["result"].clone()) + } + + fn handle(self: &Arc, msg: Value) { + let method = msg["method"].as_str().unwrap_or(""); + if msg.get("id").is_some() && !method.is_empty() { + // Request from the server: answer the few that need it. + let result = match method { + "workspace/configuration" => Value::Array(vec![Value::Null; msg["params"]["items"].as_array().map_or(0, |a| a.len())]), + _ => Value::Null, + }; + let me = self.clone(); + let id = msg["id"].clone(); + tokio::spawn(async move { + let _ = write_msg(&me.stdin, &json!({"jsonrpc": "2.0", "id": id, "result": result})).await; + }); + return; + } + if let Some(id) = msg["id"].as_i64() { + if let Some(tx) = self.pending.lock().unwrap().remove(&id) { + let _ = tx.send(msg); + } + return; + } + if method == "textDocument/publishDiagnostics" { + let uri = msg["params"]["uri"].as_str().unwrap_or("").to_string(); + let list = msg["params"]["diagnostics"] + .as_array() + .map(|a| { + a.iter() + .map(|d| Diagnostic { + line: d["range"]["start"]["line"].as_u64().unwrap_or(0) + 1, + col: d["range"]["start"]["character"].as_u64().unwrap_or(0) + 1, + severity: d["severity"].as_u64().unwrap_or(1), + message: d["message"].as_str().unwrap_or("").to_string(), + source: d["source"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default(); + let mut diags = self.diags.lock().unwrap(); + let n = diags.get(&uri).map_or(0, |e| e.0) + 1; + diags.insert(uri, (n, list)); + drop(diags); + self.published.notify_waiters(); + } + } + + fn publish_count(&self, uri: &str) -> u64 { + self.diags.lock().unwrap().get(uri).map_or(0, |e| e.0) + } +} + +async fn start(app: &App, server: &LspServer, root: &Path, sandbox: Option<&SandboxSpec>) -> Result> { + let program = find_command(&server.command, sandbox).ok_or_else(|| anyhow!("{} is not installed", server.command))?; + let program = program.display().to_string(); + let args: Vec<&str> = server.args.iter().map(String::as_str).collect(); + let mut cmd = match sandbox { + Some(spec) => { + if !crate::sandbox::available() { + bail!("sandbox unavailable"); + } + // Inside the sandbox the home directory is mounted elsewhere. + let inside = program.replace(&spec.home.display().to_string(), &spec.home_inside()); + crate::sandbox::command(spec, root, &inside, &args) + } + None => { + let mut c = tokio::process::Command::new(&program); + c.args(&args).current_dir(root); + c + } + }; + let _ = app; + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true); + let mut child = cmd.spawn().with_context(|| format!("starting {}", server.command))?; + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let client = Arc::new(Client { + stdin: tokio::sync::Mutex::new(stdin), + child: Mutex::new(Some(child)), + next_id: AtomicI64::new(1), + pending: Mutex::default(), + diags: Mutex::default(), + published: Notify::new(), + open: Mutex::default(), + last_used: Mutex::new(Instant::now()), + dead: false.into(), + }); + + // Reader: Content-Length framed JSON messages. + let weak = Arc::downgrade(&client); + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + loop { + let mut len = 0usize; + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => { + if let Some(c) = weak.upgrade() { + c.dead.store(true, Ordering::SeqCst); + c.published.notify_waiters(); + } + return; + } + Ok(_) => {} + } + let line = line.trim(); + if line.is_empty() { + break; + } + if let Some(v) = line.strip_prefix("Content-Length:") { + len = v.trim().parse().unwrap_or(0); + } + } + if len == 0 || len > 64 * 1024 * 1024 { + continue; + } + let mut buf = vec![0u8; len]; + if reader.read_exact(&mut buf).await.is_err() { + return; + } + let Some(c) = weak.upgrade() else { return }; + if let Ok(msg) = serde_json::from_slice::(&buf) { + c.handle(msg); + } + } + }); + + let root_uri = file_uri(root); + client + .request( + "initialize", + json!({ + "processId": null, + "rootUri": root_uri, + "rootPath": root.display().to_string(), + "workspaceFolders": [{"uri": root_uri, "name": root.file_name().and_then(|n| n.to_str()).unwrap_or("project")}], + "capabilities": { + "textDocument": { + "synchronization": {"didSave": true, "dynamicRegistration": false}, + "publishDiagnostics": {"relatedInformation": false, "versionSupport": true} + }, + "workspace": {"configuration": true, "workspaceFolders": true} + }, + "initializationOptions": if server.init_options.is_null() { json!({}) } else { server.init_options.clone() } + }), + Duration::from_secs(60), + ) + .await?; + client.notify("initialized", json!({})).await?; + Ok(client) +} + +impl Lsp { + /// Stop servers that were not used for a while. + pub fn reap(&self) { + self.clients.lock().unwrap().retain(|_, c| c.last_used.lock().unwrap().elapsed() < IDLE_TIMEOUT && !c.dead.load(Ordering::SeqCst)); + } +} + +/// The configured servers (config, else built-in defaults). +fn servers(app: &App) -> Vec<(String, LspServer)> { + let cfg = app.config(); + if !cfg.lsp.enabled { + return vec![]; + } + let mut list = default_servers(); + for (name, s) in &cfg.lsp.servers { + list.retain(|(n, _)| n != name); + list.push((name.clone(), s.clone())); + } + list.retain(|(_, s)| s.enabled); + list +} + +/// Sync a file to its language server and return the diagnostics it reports. +/// `None` when no server handles the file (or it is not installed). +pub async fn diagnostics(app: &Arc, file: &Path, limit: &Path, sandbox: Option<&SandboxSpec>, wait: Duration) -> Option>> { + let ext = file.extension()?.to_str()?.to_ascii_lowercase(); + let (name, server) = servers(app).into_iter().find(|(_, s)| s.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext)))?; + let root = project_root(file, &server.root_markers, limit); + let user = sandbox.map(|s| s.username.as_str()).unwrap_or("-"); + let key = format!("{user}|{name}|{}", root.display()); + + if let Some(t) = app.lsp.failed.lock().unwrap().get(&key) { + if t.elapsed() < Duration::from_secs(300) { + return None; + } + } + let existing = app.lsp.clients.lock().unwrap().get(&key).cloned().filter(|c| !c.dead.load(Ordering::SeqCst)); + let (client, fresh) = match existing { + Some(c) => (c, false), + None => match start(app, &server, &root, sandbox).await { + Ok(c) => { + app.lsp.clients.lock().unwrap().insert(key.clone(), c.clone()); + (c, true) + } + Err(e) => { + tracing::debug!("lsp {name}: {e:#}"); + app.lsp.failed.lock().unwrap().insert(key, Instant::now()); + return None; + } + }, + }; + *client.last_used.lock().unwrap() = Instant::now(); + Some(sync_and_wait(&client, file, &ext, if fresh { wait.max(Duration::from_secs(20)) } else { wait }).await) +} + +async fn sync_and_wait(client: &Arc, file: &Path, ext: &str, wait: Duration) -> Result> { + let text = tokio::fs::read_to_string(file).await.unwrap_or_default(); + let uri = file_uri(file); + let before = client.publish_count(&uri); + let version = { + let mut open = client.open.lock().unwrap(); + let v = open.get(&uri).map_or(1, |v| v + 1); + open.insert(uri.clone(), v); + v + }; + if version == 1 { + client + .notify("textDocument/didOpen", json!({"textDocument": {"uri": uri, "languageId": language_id(ext), "version": 1, "text": text}})) + .await?; + } else { + client + .notify("textDocument/didChange", json!({"textDocument": {"uri": uri, "version": version}, "contentChanges": [{"text": text}]})) + .await?; + } + client.notify("textDocument/didSave", json!({"textDocument": {"uri": uri}, "text": text})).await?; + + // Wait for a fresh publish; servers often send an early empty one and the + // real result shortly after, so keep listening briefly after the first. + let deadline = Instant::now() + wait; + let mut settle: Option = None; + loop { + if client.dead.load(Ordering::SeqCst) { + bail!("language server exited"); + } + let now = Instant::now(); + let count = client.publish_count(&uri); + if count > before && settle.is_none() { + settle = Some(now + Duration::from_millis(700)); + } + let until = settle.map_or(deadline, |s| s.min(deadline)); + if now >= until { + break; + } + let _ = tokio::time::timeout(until - now, client.published.notified()).await; + if let Some(s) = settle { + if client.publish_count(&uri) > count { + settle = Some(Instant::now().max(s)); + } + } + } + if client.publish_count(&uri) == before { + bail!("no diagnostics within {}s (server still indexing?)", wait.as_secs()); + } + Ok(client.diags.lock().unwrap().get(&uri).map(|e| e.1.clone()).unwrap_or_default()) +} + +/// Format errors (and a few warnings) for a tool result. +pub fn format(file: &str, diags: &[Diagnostic], warnings: bool) -> String { + let mut list: Vec<&Diagnostic> = diags.iter().filter(|d| d.severity == 1 || (warnings && d.severity == 2)).collect(); + list.sort_by_key(|d| (d.severity, d.line, d.col)); + if list.is_empty() { + return String::new(); + } + let errors = diags.iter().filter(|d| d.severity == 1).count(); + let mut out = format!("\n\n\n"); + for d in list.iter().take(MAX_SHOWN) { + let sev = if d.severity == 1 { "error" } else { "warning" }; + let src = if d.source.is_empty() { String::new() } else { format!(" [{}]", d.source) }; + out.push_str(&format!("{}:{} {sev}{src}: {}\n", d.line, d.col, d.message.lines().next().unwrap_or(""))); + } + if list.len() > MAX_SHOWN { + out.push_str(&format!("… {} more\n", list.len() - MAX_SHOWN)); + } + out.push_str(""); + out +} + +/// Background task: stop idle servers. +pub fn spawn_reaper(app: Arc) { + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + app.lsp.reap(); + } + }); +} + +// --------------------------------------------------------------------------- + +/// Tool: diagnostics of a file on demand. +pub struct Diagnostics; + +#[async_trait::async_trait] +impl crate::tools::Tool for Diagnostics { + fn name(&self) -> &str { + "diagnostics" + } + fn description(&self) -> String { + "Get compiler/linter diagnostics (errors and warnings) for a source file from its language server \ + (rust-analyzer, typescript-language-server, pyright, gopls, clangd – if installed). Edits already \ + report new errors automatically; use this to check a file you did not edit." + .into() + } + fn schema(&self) -> Value { + json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}) + } + fn read_only(&self) -> bool { + true + } + fn title(&self, input: &Value) -> String { + format!("Diagnostics {}", input["path"].as_str().unwrap_or("")) + } + async fn run(&self, input: Value, ctx: &crate::tools::ToolCtx) -> Result { + let p = crate::tools::arg_str(&input, "path")?; + let path = ctx.resolve_read(p)?; + match diagnostics(&ctx.app, &path, &ctx.cwd, ctx.sandbox.as_ref(), Duration::from_secs(10)).await { + None => Ok(crate::tools::ToolOutput::text( + "No language server available for this file type. Install one (e.g. `npm i -g typescript-language-server typescript`, \ + `npm i -g pyright`, `rustup component add rust-analyzer`, `go install golang.org/x/tools/gopls@latest`).", + )), + Some(Err(e)) => Ok(crate::tools::ToolOutput::error(format!("Language server: {e:#}"))), + Some(Ok(d)) => { + let s = format(p, &d, true); + Ok(crate::tools::ToolOutput::text(if s.is_empty() { "No errors or warnings.".to_string() } else { s.trim().to_string() })) + } + } + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 8daaf8e..e37be1a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -9,6 +9,7 @@ mod docs; mod files; mod git; mod images; +mod lsp; mod mcp; mod permission; mod prompts; @@ -42,6 +43,7 @@ async fn main() -> Result<()> { let _ = sandbox::probe().await; let bind = cfg.server.bind.clone(); let app = app::App::new(path, cfg).await?; + lsp::spawn_reaper(app.clone()); let router = api::router(app); let listener = tokio::net::TcpListener::bind(&bind).await?; tracing::info!("listening on http://{bind}"); diff --git a/server/src/runner.rs b/server/src/runner.rs index 791531c..07bdc56 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -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, 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 `…` block some models put into the text. +fn strip_think(text: &str) -> String { + let t = text.trim_start(); + if t.starts_with("") { + if let Some(end) = t.find("") { + 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, session_id: &str, cancel: CancellationToken) -> Result<()> { let session = app.session(session_id)?; @@ -869,27 +931,33 @@ pub async fn compact(app: Arc, 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!("\n{transcript}\n\n\n{COMPACT_PROMPT}") + } else { + format!("\n{transcript}\n\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"); } diff --git a/server/src/tools/mod.rs b/server/src/tools/mod.rs index c607fa9..7aaf67b 100644 --- a/server/src/tools/mod.rs +++ b/server/src/tools/mod.rs @@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec> { Arc::new(crate::shells::KillShell), Arc::new(web::WebFetch), Arc::new(web::WebSearch), + Arc::new(crate::lsp::Diagnostics), Arc::new(agent::TodoWrite), Arc::new(agent::AskUser), Arc::new(agent::Task), diff --git a/web/src/components/Charts.tsx b/web/src/components/Charts.tsx index b248496..a3e58ef 100644 --- a/web/src/components/Charts.tsx +++ b/web/src/components/Charts.tsx @@ -201,7 +201,9 @@ export function Heatmap({
setTip(undefined)}> - {[0, 3, 6, 9, 12, 15, 18, 21].map((h) => ( + {Array.from({ length: 24 }, (_, h) => h) + .filter((h) => h % (cellW >= 22 ? 1 : cellW >= 12 ? 3 : 6) === 0) + .map((h) => ( {h} @@ -245,7 +247,9 @@ export function Heatmap({ )}
- {format(0)} + + {t("stats.heatEmpty")} + {format(0)} {SEQ.slice(1).map((c, i) => ( ))} diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index c5f0881..ac5010d 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { del, get, patch, post } from "../api"; import { useModels } from "../hooks"; @@ -12,7 +13,7 @@ import { ShareDialog } from "./ShareDialog"; import { QueueList } from "./QueueList"; import { BudgetChip } from "./Budget"; import { PermissionCard, QuestionCard } from "./Interaction"; -import { ImageRefs, Markdown } from "./Markdown"; +import { ImageRefs, Markdown, projectFileUrl } from "./Markdown"; import { UsageOverlay } from "./UsageOverlay"; import { RewindDialog } from "./RewindDialog"; import { ShellsChip } from "./ShellsPanel"; @@ -238,7 +239,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{ctxShare !== undefined && ( 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}> - {t("chat.context")} {fmtPercent(ctxShare)} + {t("chat.context")} {fmtK(ctxTokens)}/{fmtK(modelInfo!.context!)} · {fmtPercent(ctxShare)} )} {stats && ( @@ -325,11 +326,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
)} - {texts.map((p) => p.text).join("\n\n")} + p.text).join("\n\n")} /> {imgs.length > 0 && ( -
+
{imgs.map((p, i) => ( - + + + ))}
)} @@ -399,3 +402,41 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () ); } + +/** Attached-file lines ("- /path (12.3 KB)") of a user message become file chips. */ +const ATTACHED_RE = /^- (\/\S+) \(([\d.]+ (?:B|KB|MB))\)$/; + +function UserText({ text }: { text: string }) { + const lines = text.split("\n"); + const files: { path: string; size: string }[] = []; + const rest: string[] = []; + for (const l of lines) { + const m = l.match(ATTACHED_RE); + if (m) files.push({ path: m[1], size: m[2] }); + else rest.push(l); + } + // Drop the heading line ("Attached files …:") right before the list. + while (files.length && rest.length && (rest[rest.length - 1].trim() === "" || rest[rest.length - 1].trim().endsWith(":"))) { + const last = rest.pop()!; + if (last.trim().endsWith(":")) break; + } + return ( + <> + {rest.join("\n").trimEnd()} + {files.length > 0 && ( +
+ {files.map((f) => ( + + ))} +
+ )} + + ); +} + +/** Token counts like "221k" / "1.2M" (same in every language). */ +function fmtK(n: number) { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`; + if (n >= 1000) return `${Math.round(n / 1000)}k`; + return String(n); +} diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 0d8aa9a..a86e784 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -1,3 +1,4 @@ +import { formatSize } from "./FileFigure"; import { useEffect, useMemo, useRef, useState } from "react"; import { useAgents, useFetch } from "../hooks"; import { useT, type Key } from "../i18n"; @@ -17,11 +18,6 @@ const BUILTIN: { name: string; key: Key }[] = [ const MAX_SIDE = 2000; -function formatSize(n: number) { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / 1024 / 1024).toFixed(1)} MB`; -} /** Read an image and downscale it: models don't use more detail, and uploads stay small. */ async function readImage(file: File): Promise { diff --git a/web/src/components/FileFigure.tsx b/web/src/components/FileFigure.tsx new file mode 100644 index 0000000..37b1b42 --- /dev/null +++ b/web/src/components/FileFigure.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState, type ReactNode } from "react"; + +/** Human-readable file size. */ +export function formatSize(n: number) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +const MIME_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/svg+xml": "svg", + "application/pdf": "pdf", + "model/gltf-binary": "glb", +}; + +/** File type label (extension) from a name or MIME type. */ +export function fileType(name?: string, mime?: string): string { + const ext = name?.match(/\.([a-z0-9]{1,6})$/i)?.[1]; + if (ext) return ext.toUpperCase(); + if (mime) return (MIME_EXT[mime] ?? mime.split("/").pop() ?? "").toUpperCase(); + return ""; +} + +export function basename(p: string) { + return p.split(/[\\/]/).filter(Boolean).pop() ?? p; +} + +/** Bytes of a base64 payload. */ +export function base64Size(b64: string) { + const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; + return Math.floor((b64.length * 3) / 4) - pad; +} + +interface Meta { + size?: number; + mime?: string; + name?: string; +} + +const cache = new Map>(); + +/** Size/type/name of a file URL in the app (project files via stat, others via HEAD). */ +function loadMeta(url: string): Promise { + let p = cache.get(url); + if (!p) { + const project = url.match(/^\/api\/files\/raw\?path=([^&]+)/); + p = project + ? fetch(`/api/files/stat?path=${project[1]}`, { credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : {})) + .then((j: any) => ({ size: j.size, mime: j.mime })) + : fetch(url, { method: "HEAD", credentials: "same-origin" }).then((r) => { + if (!r.ok) return {}; + const len = r.headers.get("content-length"); + const name = r.headers.get("content-disposition")?.match(/filename="?([^";]+)"?/)?.[1]; + return { size: len ? Number(len) : undefined, mime: r.headers.get("content-type")?.split(";")[0], name }; + }); + p = p.catch(() => ({})); + cache.set(url, p); + } + return p; +} + +export function useFileMeta(url: string | undefined, known?: Meta): Meta { + const [meta, setMeta] = useState(known ?? {}); + useEffect(() => { + if (!url || url.startsWith("data:") || known?.size !== undefined) return; + let alive = true; + // Bust the cache key per version param so rewritten files show the new size. + loadMeta(url).then((m) => alive && setMeta((old) => ({ ...m, ...old, size: m.size ?? old.size }))); + return () => { + alive = false; + }; + }, [url, known?.size]); + return meta; +} + +/** + * A file shown in the chat: preview with type (top left) and size (top right) + * badges, file name below. + */ +export function FileFigure({ + url, + name, + size, + mime, + children, + className, +}: { + url?: string; + name?: string; + size?: number; + mime?: string; + children: ReactNode; + className?: string; +}) { + const meta = useFileMeta(url, size !== undefined ? { size, mime, name } : undefined); + const shownName = name ?? meta.name; + const type = fileType(shownName, mime ?? meta.mime); + const sz = size ?? meta.size; + return ( +
+
+ {url ? ( + + {children} + + ) : ( + children + )} + {type && {type}} + {sz !== undefined && {formatSize(sz)}} +
+ {shownName && ( +
+ {url ? ( + + {basename(shownName)} + + ) : ( + basename(shownName) + )} +
+ )} +
+ ); +} + +/** A non-previewable file: name with type and size as text. */ +export function FileChip({ url, name, size, sizeText }: { url?: string; name: string; size?: number; sizeText?: string }) { + const meta = useFileMeta(url, size !== undefined ? { size } : undefined); + const sz = size ?? meta.size; + const type = fileType(name, meta.mime); + return ( + + 📎 + {basename(name)} + {type && {type}} + {(sizeText || sz !== undefined) && {sizeText ?? formatSize(sz!)}} + + ); +} diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx index 01f17db..7850d12 100644 --- a/web/src/components/Markdown.tsx +++ b/web/src/components/Markdown.tsx @@ -2,6 +2,7 @@ import { memo, useState, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; +import { FileChip, FileFigure } from "./FileFigure"; export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i; @@ -31,7 +32,9 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`; return (
- SVG + + SVG + @@ -53,13 +56,14 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string if (!url) return {alt}; // Non-image files (3D models, documents) become links. if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) { - return ( - - 📎 {alt || src} - - ); + return ; } - return {alt; + const name = typeof src === "string" && !src.startsWith("data:") ? decodeURIComponent(src.split("?")[0]) : alt || undefined; + return ( + + {alt + + ); }, pre: ({ children }) => { const code = textOf(children); @@ -105,9 +109,9 @@ export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) {paths.map((p) => { const url = projectFileUrl(p, baseDir)!; return ( - - {p} ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} /> - + + {p} ((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} /> + ); })}
diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 47ca7fc..e56e191 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -818,6 +818,19 @@ function Raw({ reload }: { reload: () => void }) { const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const; const USER_TABS = ["general", "account"] as const; +const TAB_ICONS: Record = { + general: "⚙️", + providers: "🧠", + agents: "🤖", + mcp: "🔌", + permissions: "🛡️", + users: "👥", + folders: "🗂️", + tools: "🧰", + audit: "📜", + account: "👤", + raw: "📝", +}; export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) { const t = useT(); @@ -841,6 +854,9 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
{TABS.map((x) => ( ))} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 1354deb..17b1b3c 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -91,20 +91,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
diff --git a/web/src/components/ToolCard.tsx b/web/src/components/ToolCard.tsx index f91e891..0c035ae 100644 --- a/web/src/components/ToolCard.tsx +++ b/web/src/components/ToolCard.tsx @@ -1,3 +1,4 @@ +import { base64Size, FileFigure } from "./FileFigure"; import { useT } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; @@ -91,9 +92,9 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r {call.input.path} ✓
- + {call.input.path} - + ); } @@ -143,13 +144,17 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r {Array.isArray(meta?.image_ids) ? (
{meta.image_ids.map((id: string) => ( - + - + ))}
) : ( - result?.images?.map((img, i) => ) + result?.images?.map((img, i) => ( + + + + )) )} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 566487a..dbdacce 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -410,6 +410,7 @@ export const de: Messages = { "stats.prefillSpeed": "Prefill-Geschwindigkeit", "stats.costPerDay": "Kosten pro Tag nach Modell", "stats.tokensPerDay": "Tokens pro Tag", + "stats.heatEmpty": "keine Aktivität", "stats.heatmap": "Aktivität nach Wochentag und Uhrzeit", "stats.byModel": "Nach Modell", "stats.byProvider": "Nach Provider", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 0e582b3..8ccc2c7 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -409,6 +409,7 @@ export const en = { "stats.prefillSpeed": "Prefill speed", "stats.costPerDay": "Cost per day by model", "stats.tokensPerDay": "Tokens per day", + "stats.heatEmpty": "no activity", "stats.heatmap": "Activity by weekday and hour", "stats.byModel": "By model", "stats.byProvider": "By provider", diff --git a/web/src/styles.css b/web/src/styles.css index 743e60f..d370208 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2090,3 +2090,117 @@ button.danger-solid { gap: 4px; min-width: 0; } + +/* --- files in chat (preview + type/size badges + name) ------------------------- */ +.file-figure { + display: inline-flex; + flex-direction: column; + gap: 4px; + margin: 6px 8px 6px 0; + max-width: min(100%, 640px); + vertical-align: top; +} +.file-preview { + min-width: 128px; + position: relative; + display: inline-block; + max-width: 100%; +} +.file-preview img { + margin: 0 !important; + display: block; +} +.file-badge { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1; + padding: 3px 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--bg0) 82%, transparent); + color: var(--fg1); + border: 1px solid var(--bg3); + white-space: nowrap; +} +.file-preview .file-badge { + position: absolute; + top: 6px; + pointer-events: none; + backdrop-filter: blur(4px); +} +.file-preview .file-badge.left { + left: 6px; +} +.file-preview .file-badge.right { + right: 6px; +} +.file-figure figcaption { + font-size: 12px; + color: var(--fg3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} +.file-figure figcaption a { + color: inherit; +} +.file-figure.thumb img { + height: 140px; + max-width: 260px; + object-fit: contain; +} +.msg-user .file-figure img { + max-height: 160px; + border-radius: 6px; +} +.user-files { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} +.file-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--bg3); + background: var(--bg1); + color: var(--fg1); + font-size: 12px; + text-decoration: none; + white-space: nowrap; + margin: 2px 0; +} +.file-chip:hover { + border-color: var(--accent); +} +.file-chip-name { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.file-chip-size { + color: var(--muted); +} +.file-chip .file-badge { + background: var(--bg2); + border: none; +} +.gallery .file-figure { margin: 0; max-width: none; display: flex; } + +.nav-icon { + display: inline-block; + width: 1.3em; + margin-right: 6px; + text-align: center; + font-size: 0.95em; +} +.sidebar-foot button { + display: flex; + align-items: center; + justify-content: flex-start; +} diff --git a/web/src/themes/base.css b/web/src/themes/base.css index 6f0841e..602d5b6 100644 --- a/web/src/themes/base.css +++ b/web/src/themes/base.css @@ -9,7 +9,7 @@ --series-6: #008300; --series-7: #9085e9; --series-8: #e66767; - --seq-0: var(--bg1); + --seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1)); --seq-1: #104281; --seq-2: #184f95; --seq-3: #256abf; @@ -26,7 +26,7 @@ --series-6: #008300; --series-7: #6250d6; --series-8: #e34948; - --seq-0: var(--bg1); + --seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1)); --seq-1: #cde2fb; --seq-2: #9ec5f4; --seq-3: #6da7ec;