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

+3
View File
@@ -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
+18
View File
@@ -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
# -----------------------------------------------------------------------------
+1
View File
@@ -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))
+3
View File
@@ -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();
+35
View File
@@ -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<String, LspServer>,
}
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<String>,
/// File extensions without dot.
#[serde(default)]
pub extensions: Vec<String>,
/// Files that mark the project root (e.g. Cargo.toml).
#[serde(default)]
pub root_markers: Vec<String>,
/// 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)]
+13
View File
@@ -259,6 +259,19 @@ pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>,
.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<User>, State(app): State<AppState>, Query(q): Query<StatQuery>) -> 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,
+511
View File
@@ -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<ChildStdin>,
child: Mutex<Option<Child>>,
next_id: AtomicI64,
pending: Mutex<HashMap<i64, oneshot::Sender<Value>>>,
/// uri -> (publish counter, diagnostics)
diags: Mutex<HashMap<String, (u64, Vec<Diagnostic>)>>,
published: Notify,
/// uri -> version of opened documents
open: Mutex<HashMap<String, i64>>,
last_used: Mutex<Instant>,
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<HashMap<String, Arc<Client>>>,
/// Servers that failed to start (key -> time), not retried for a while.
failed: Mutex<HashMap<String, Instant>>,
}
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<PathBuf> {
if cmd.contains('/') {
return Path::new(cmd).exists().then(|| PathBuf::from(cmd));
}
let mut dirs: Vec<PathBuf> = 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<ChildStdin>, 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<Value> {
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<Self>, 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<Arc<Client>> {
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::<Value>(&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<App>, file: &Path, limit: &Path, sandbox: Option<&SandboxSpec>, wait: Duration) -> Option<Result<Vec<Diagnostic>>> {
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<Client>, file: &Path, ext: &str, wait: Duration) -> Result<Vec<Diagnostic>> {
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<Instant> = 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<diagnostics file=\"{file}\" errors=\"{errors}\">\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("</diagnostics>");
out
}
/// Background task: stop idle servers.
pub fn spawn_reaper(app: Arc<App>) {
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<crate::tools::ToolOutput> {
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() }))
}
}
}
}
+2
View File
@@ -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}");
+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");
}
+1
View File
@@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
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),
+6 -2
View File
@@ -201,7 +201,9 @@ export function Heatmap({
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
<svg height={top + cell * 7 + 4} role="img" aria-label={t("stats.heatmap")}>
<g className="axis">
{[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) => (
<text key={h} x={left + h * cellW + cellW / 2} y={12} textAnchor="middle">
{h}
</text>
@@ -245,7 +247,9 @@ export function Heatmap({
)}
</svg>
<div className="legend" style={{ marginTop: 6 }}>
<span>{format(0)}</span>
<i style={{ background: SEQ[0], width: 18 }} />
<span>{t("stats.heatEmpty")}</span>
<span style={{ marginLeft: 12 }}>{format(0)}</span>
{SEQ.slice(1).map((c, i) => (
<i key={i} style={{ background: c, width: 18 }} />
))}
+46 -5
View File
@@ -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: ()
</div>
{ctxShare !== undefined && (
<span className={`chip hide-mobile ${ctxShare > 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)}
</span>
)}
{stats && (
@@ -325,11 +326,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</button>
</div>
)}
{texts.map((p) => p.text).join("\n\n")}
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div>
<div className="user-files">
{imgs.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
</FileFigure>
))}
</div>
)}
@@ -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 && (
<div className="user-files">
{files.map((f) => (
<FileChip key={f.path} url={projectFileUrl(f.path)} name={f.path} sizeText={f.size} />
))}
</div>
)}
</>
);
}
/** 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);
}
+1 -5
View File
@@ -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<ImageData> {
+146
View File
@@ -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<string, string> = {
"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<string, Promise<Meta>>();
/** Size/type/name of a file URL in the app (project files via stat, others via HEAD). */
function loadMeta(url: string): Promise<Meta> {
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<Meta>(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 (
<figure className={`file-figure ${className ?? ""}`}>
<div className="file-preview">
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{children}
</a>
) : (
children
)}
{type && <span className="file-badge left">{type}</span>}
{sz !== undefined && <span className="file-badge right">{formatSize(sz)}</span>}
</div>
{shownName && (
<figcaption title={shownName}>
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{basename(shownName)}
</a>
) : (
basename(shownName)
)}
</figcaption>
)}
</figure>
);
}
/** 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 (
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
<span className="file-chip-icon">📎</span>
<span className="file-chip-name">{basename(name)}</span>
{type && <span className="file-badge">{type}</span>}
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
</a>
);
}
+14 -10
View File
@@ -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 (
<div className="svg-block">
<img src={url} alt="SVG" />
<FileFigure name="inline.svg" size={new Blob([code]).size} mime="image/svg+xml">
<img src={url} alt="SVG" />
</FileFigure>
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
{showCode ? "▾ SVG" : "▸ SVG"}
</button>
@@ -53,13 +56,14 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
if (!url) return <span>{alt}</span>;
// Non-image files (3D models, documents) become links.
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
return (
<a href={url} target="_blank" rel="noreferrer">
📎 {alt || src}
</a>
);
return <FileChip url={url} name={src} />;
}
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
const name = typeof src === "string" && !src.startsWith("data:") ? decodeURIComponent(src.split("?")[0]) : alt || undefined;
return (
<FileFigure url={url} name={name} mime={typeof src === "string" ? src.match(/^data:([^;,]+)/)?.[1] : undefined}>
<img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />
</FileFigure>
);
},
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 (
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
</a>
<FileFigure key={p} url={url} name={p} className="thumb">
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} />
</FileFigure>
);
})}
</div>
+16
View File
@@ -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<string, string> = {
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 })
<div className="tabs" role="tablist">
{TABS.map((x) => (
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
<span className="nav-icon" aria-hidden>
{TAB_ICONS[x]}
</span>
{t(`settings.tab.${x}` as Key)}
</button>
))}
+5
View File
@@ -91,20 +91,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</span>
<ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
<span className="nav-icon" aria-hidden>🚪</span>
{t("nav.logout")}
</button>
</div>
<div className="sidebar-foot">
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
<span className="nav-icon" aria-hidden>📁</span>
{t("nav.files")}
</button>
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
<span className="nav-icon" aria-hidden>🖼️</span>
{t("nav.images")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<span className="nav-icon" aria-hidden>📊</span>
{t("nav.stats")}
</button>
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
<span className="nav-icon" aria-hidden>🔧</span>
{t("nav.settings")}
</button>
</div>
+10 -5
View File
@@ -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
<span className="tool-title">{call.input.path}</span>
<span className="status ok">✓</span>
</div>
<a href={writtenImage} target="_blank" rel="noreferrer">
<FileFigure url={writtenImage} name={call.input.path}>
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
</a>
</FileFigure>
</div>
);
}
@@ -143,13 +144,17 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
{Array.isArray(meta?.image_ids) ? (
<div className="gallery small">
{meta.image_ids.map((id: string) => (
<a key={id} href={`/api/images/${id}/file`} target="_blank" rel="noreferrer" className="gallery-item">
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img src={`/api/images/${id}/file`} alt="" />
</a>
</FileFigure>
))}
</div>
) : (
result?.images?.map((img, i) => <img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />)
result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
</FileFigure>
))
)}
</div>
</details>
+1
View File
@@ -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",
+1
View File
@@ -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",
+114
View File
@@ -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;
}
+2 -2
View File
@@ -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;