Compare commits

...
Author SHA1 Message Date
Richard Zink 81ee084ff2 Release v202611-00003 2026-11-09 11:25:02 +01:00
Richard ZinkandClaude Opus 5.5 c850586a81 Chat tabs, chat mode, own modes, compaction notice, CLI binaries again
- Terminals and file managers as tabs of a chat; chat terminals keep running on
  the server (no idle timeout) and re-attach from any device
- "Just chat" in New session and a built-in chat mode with a lean prompt and only
  web tools; custom modes with kind (coding/conversation), read-only and tool picker
- Mode selector as a dropdown
- The chat shows running compactions (automatic: tokens and share of the limit)
  and labels summaries
- Docker image builds and serves the terminal client binaries again
- Fix: atomic session updates; background title/usage writes no longer undo each
  other (auto compaction triggered one turn late)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:24:58 +01:00
Richard ZinkandClaude Opus 5.5 242b80605c File manager: open a chat in a folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:39 +01:00
Richard ZinkandClaude Opus 5.5 c9f553dc60 Remove the one-time env-to-config migration from the settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:06 +01:00
31 changed files with 784 additions and 264 deletions

No files matched your search

+35
View File
@@ -3,6 +3,41 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00003] – 2026-11-09
### Added
- The Docker image ships the terminal client again (static Linux x86_64 and aarch64 binaries):
`curl -fsSL https://<your-server>/cli/install.sh | sh`, download links in the settings and
`codecraft update` work without building from source.
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
- **Tabs in a chat:** open terminals and file managers next to the chat (topbar buttons or
"+" in the tab strip), as many as needed. Terminals of a chat keep running on the server until
they are closed, so they are there again after a reload or on another device (output is
replayed); they end when the chat is deleted or the server restarts.
- **Chat mode:** *New session → Just chat* (and mode `chat`) for plain conversation – a lean
prompt with date and personal memory, web search and fetch only, no project or files.
- **Own modes:** *Settings → Agents & commands → New mode* with kind (coding or conversation),
read-only switch, tool checkboxes, prompt, model and review loop; custom modes show up in
*New session* and the mode selector.
- The chat shows when the conversation is being compacted – automatically because the context
is nearly full (with tokens and share of the limit) or on request – and labels the summary.
### Changed
- The mode selector in the composer is a dropdown.
### Removed
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
one-time migration); `${NAME}` references keep working.
### Fixed
- Session updates from background tasks (title, token usage, phase, todos) and from the UI
no longer overwrite each other. Often the first answer's token usage was lost, so automatic
compaction and the context display started one turn late.
## [v202611-00002] – 2026-11-09
### Added
+18 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
# Multi-arch: the web UI and the server are built once on the build
# Multi-arch: the web UI, the server and the terminal client are built once on the build
# machine (cross-compiled with cargo-zigbuild); only the runtime stage runs
# per target platform.
@@ -19,7 +19,7 @@ ARG ZIG_VERSION=0.17.1
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
&& cargo install --locked cargo-zigbuild \
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl
WORKDIR /src
# --- server (for the target platform, glibc of Debian bookworm) ----------------
@@ -52,6 +52,21 @@ RUN touch core/src/lib.rs server/src/main.rs \
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
&& cp "target/$(cat /target)/release/codecraft" /codecraft
# --- terminal client (static Linux binaries for x86_64 and aarch64) -----------
# Served by the server for download (/cli/install.sh, codecraft update).
FROM toolchain AS cli
COPY Cargo.toml Cargo.lock ./
COPY core ./core
COPY server/Cargo.toml ./server/
COPY server/resources ./server/resources
COPY cli ./cli
ARG CODECRAFT_VERSION=""
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
&& cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
# --- runtime ------------------------------------------------------------------
# Node is included so agents can work on JS projects and run npx-based MCP servers.
FROM node:22-bookworm-slim
@@ -69,6 +84,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
COPY --from=server /codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli
COPY server/ghidra /app/ghidra
COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space
+8 -10
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00002" src="https://img.shields.io/badge/version-v202611--00002-d69e2e?style=flat-square">
<img alt="Version v202611-00003" src="https://img.shields.io/badge/version-v202611--00003-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -82,8 +82,7 @@ docker compose -f docker-compose.release.yml logs codecraft # one-time setup c
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
tokens and everything else are set in the UI and stored in the configuration file
(`${NAME}` references to environment variables still work, and *Settings → Config file* moves
their values into the file).
(`${NAME}` references to environment variables still work).
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
@@ -174,15 +173,14 @@ Anthropic, OpenAI, Gemini, OpenRouter, opencode or a local model server (Ollama,
llama.cpp, vLLM – detected automatically); both can be mixed.
```sh
cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli
curl -fsSL https://<your-codecraft-server>/cli/install.sh | sh # Linux x86_64 / aarch64
codecraft login # server URL, user name, password → creates an API token
codecraft # start in the current folder; /help lists the commands
```
There are no prebuilt binaries yet, so the client is installed from source (needs a Rust
toolchain). A server that has binaries in `CODECRAFT_CLI_DIR` (`codecraft-linux-x86_64`,
`codecraft-linux-aarch64`) also offers `curl -fsSL https://<your-server>/cli/install.sh | sh`
and `codecraft update`.
The Docker image ships static binaries for Linux x86_64 and aarch64; the server offers them
for download (*Settings → Account*) and `codecraft update` fetches new versions from it. Other
platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli`.
## Automations
@@ -336,7 +334,7 @@ web/ React + Vite PWA
src/i18n English (reference) and German
```
The Docker build cross-compiles the server with
The Docker build cross-compiles the server and the terminal client with
[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds
quickly on either architecture:
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
@@ -354,7 +352,7 @@ Translations: copy `web/src/i18n/en.ts`, translate the values and register the l
Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next:
- Prebuilt terminal client binaries (Linux, macOS, Windows)
- Terminal client builds for macOS and Windows
- MCP OAuth, resources and prompts
- The OpenAI Responses API
+2 -2
View File
@@ -508,7 +508,7 @@ impl Agent {
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
for p in &m.parts {
let line = match p {
Part::Text { text } | Part::Summary { text } => text.clone(),
Part::Text { text } | Part::Summary { text, .. } => text.clone(),
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
_ => continue,
@@ -524,7 +524,7 @@ impl Agent {
if summary.trim().is_empty() {
return Err(anyhow!("compaction produced no summary"));
}
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }]));
self.session.context_tokens = 0;
self.read_files.lock().unwrap().clear();
let _ = self.session.save();
+1 -2
View File
@@ -10,8 +10,7 @@
#
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
# for admins and included in backups. Any string value may also reference an
# environment variable as ${NAME}; Settings → Config file moves such values
# into this file.
# environment variable as ${NAME}.
# =============================================================================
[server]
+3
View File
@@ -34,6 +34,9 @@ Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pa
pub const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
/// Plain conversation (mode "chat"): no project, no coding workflow.
pub const CHAT_PROMPT: &str = "You are a helpful, knowledgeable assistant having a conversation with the user. Answer directly and naturally in the user's language; keep answers as short as the question allows and go into depth when asked. Use Markdown only where it helps (lists, code, tables). You can search the web (web_search) and read pages (web_fetch) for current or specific facts – say where information comes from. You have no access to files or a shell in this mode; if the user wants something built or changed in a project, suggest switching to build mode.";
pub const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
pub const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
Part::Text { text } if !text.is_empty() => {
content.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => content.push(json!({
Part::Summary { text, .. } => content.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
Part::Text { text } if !text.is_empty() => {
parts.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => parts.push(json!({
Part::Summary { text, .. } => parts.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
+11 -1
View File
@@ -57,6 +57,12 @@ pub enum Part {
/// Marks the start of a compacted history; the text is the summary.
Summary {
text: String,
/// Started automatically because the context was nearly full.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
auto: bool,
/// Context size (tokens) when it was compacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens: Option<u64>,
},
}
@@ -213,6 +219,9 @@ pub enum Event {
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
/// Messages waiting for a busy session (full list).
QueueUpdated { session_id: String, items: Value },
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
/// a nearly full context (tokens of limit).
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
}
impl Event {
@@ -234,7 +243,8 @@ impl Event {
| Error { session_id, .. }
| Waiting { session_id, .. }
| QueueUpdated { session_id, .. }
| StepStarted { session_id, .. } => Some(session_id),
| StepStarted { session_id, .. }
| Compaction { session_id, .. } => Some(session_id),
McpStatus { .. } => None,
}
}
+43 -35
View File
@@ -66,10 +66,10 @@ pub fn router(app: AppState) -> Router {
.route("/folders/{id}/access", put(set_folder_access))
.route("/config", get(get_config).patch(patch_config))
.route("/config/raw", get(get_config_raw).put(put_config_raw))
.route("/config/inline-env", get(inline_env_preview).post(inline_env_apply))
.route("/providers/reload", post(reload_providers))
.route("/models", get(models))
.route("/agents", get(agents))
.route("/agents/tools", get(agent_tools))
.route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session))
@@ -290,24 +290,6 @@ struct RawBody {
text: String,
}
/// Which ${VAR} references the config still has (and whether they are set).
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let (_, set, unset) = config::inline_env(&app.config_text()?)?;
Ok(Json(json!({"set": set, "unset": unset})))
}
/// Move the values of set environment variables into the config file.
async fn inline_env_apply(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let (text, set, unset) = config::inline_env(&app.config_text()?)?;
if !set.is_empty() {
app.save_config(&text).await?;
app.audit(Some(&user), "config.inline_env", set.join(", "), "");
}
Ok(Json(json!({"set": set, "unset": unset})))
}
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
require_admin(&user)?;
app.save_config(&body.text).await?;
@@ -344,6 +326,16 @@ async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Q
}
/// Delete a user-defined agent: its config entry and/or its Markdown file.
/// Built-in tools for the mode editor (name, read-only, first line of the description).
async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
require_admin(&user)?;
let list: Vec<Value> = crate::tools::builtin_tools()
.iter()
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
.collect();
Ok(Json(json!(list)))
}
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
let (root, cwd) = scope(&app, &user, q.cwd);
let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?;
@@ -721,14 +713,21 @@ struct UpdateSession {
}
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = writable(&app, &user, &id)?;
writable(&app, &user, &id)?;
// Organising the list must not move the session to "today".
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none();
if let Some(m) = &body.model {
app.registry().resolve(m)?;
}
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
// Atomic: a running turn updates the same session (usage, title, todos) meanwhile.
let s = app.modify_session(&id, !organise_only, |s| {
if let Some(t) = body.title {
s.title = t;
}
if let Some(m) = body.model {
app.registry().resolve(&m)?;
s.model = m;
}
if let Some(a) = body.agent {
@@ -750,18 +749,11 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
let f = f.trim().chars().take(60).collect::<String>();
s.folder = (!f.is_empty()).then_some(f);
}
if organise_only {
app.store.save_session(&s)?;
app.emit(Event::SessionUpdated { session: s.clone() });
return Ok(Json(json!(s)));
}
if let Some(p) = body.permission_mode {
if !crate::permission::MODES.contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
s.permission_mode = p;
}
Ok(Json(json!(app.save_session(s)?)))
})?;
Ok(Json(json!(s)))
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
@@ -770,6 +762,7 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id);
crate::shells::kill_session(&app, &id);
app.terminals.kill_session(&id);
app.store.delete_session(&id)?;
Ok(Json(json!({"ok": true})))
}
@@ -860,7 +853,7 @@ async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
if let Err(e) = runner::compact(app.clone(), &id, cancel, None).await {
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
}
app.with_runtime(&id, |r| r.cancel = None);
@@ -1563,20 +1556,35 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
// --- web terminal ------------------------------------------------------------------
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(app.terminals.list(&user.id))))
#[derive(Deserialize)]
struct TerminalQuery {
session: Option<String>,
}
/// All terminals of the user, or the tabs of one chat (?session=…).
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<TerminalQuery>) -> ApiResult {
Ok(Json(json!(match q.session {
Some(s) => app.terminals.for_session(&s, &user.id),
None => app.terminals.list(&user.id),
})))
}
#[derive(Deserialize)]
struct OpenTerminal {
cwd: Option<String>,
/// Open as a tab of this chat (in its folder unless `cwd` is given).
session_id: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
}
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
let cwd = validate_cwd(&app, &user, b.cwd)?;
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let session = match &b.session_id {
Some(id) => Some(writable(&app, &user, id)?),
None => None,
};
let cwd = validate_cwd(&app, &user, b.cwd.or_else(|| session.as_ref().map(|s| s.cwd.clone())))?;
let term = crate::terminal::spawn(&app, &user, &cwd, session.map(|s| s.id), b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
Ok(Json(term.info()))
}
+23 -3
View File
@@ -46,6 +46,8 @@ pub struct Runtime {
}
pub struct App {
/// Serializes read-modify-write of sessions (see `update_session`).
session_write: Mutex<()>,
config_path: PathBuf,
config: RwLock<Arc<Config>>,
registry: RwLock<Arc<Registry>>,
@@ -101,6 +103,7 @@ impl App {
let registry = Registry::build(&cfg).await;
let (events, _) = broadcast::channel(4096);
let app = Arc::new(Self {
session_write: Mutex::new(()),
config_path,
config: RwLock::new(Arc::new(cfg)),
registry: RwLock::new(Arc::new(registry)),
@@ -326,10 +329,27 @@ impl App {
Ok(s)
}
/// Load, change and save a session atomically: background tasks (title, usage, phase,
/// todos) change different fields at the same time and must not undo each other.
pub fn update_session(&self, id: &str, f: impl FnOnce(&mut Session)) -> Result<Session> {
self.modify_session(id, true, f)
}
/// Like `update_session`; `touch = false` keeps `updated_at` (organising the list).
pub fn modify_session(&self, id: &str, touch: bool, f: impl FnOnce(&mut Session)) -> Result<Session> {
let _guard = self.session_write.lock().unwrap();
let mut s = self.session(id)?;
f(&mut s);
if touch {
return self.save_session(s);
}
self.store.save_session(&s)?;
self.emit(Event::SessionUpdated { session: s.clone() });
Ok(s)
}
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
let mut s = self.session(session_id)?;
s.todos = todos;
self.save_session(s)?;
self.update_session(session_id, |s| s.todos = todos)?;
Ok(())
}
+7 -71
View File
@@ -385,6 +385,13 @@ pub struct AgentConfig {
pub tools: Option<Vec<String>>,
#[serde(default)]
pub prompt: Option<String>,
/// Only read-only tools (like plan mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<bool>,
/// "coding" (default: project context and coding instructions) or "chat" (plain
/// conversation with only this prompt, like the built-in chat mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
/// Max. instances of this subagent running at the same time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel: Option<usize>,
@@ -470,57 +477,6 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
Ok((cfg, text))
}
/// Replace `${NAME}` references in all string values with the environment's values,
/// so the settings live in the config only. Comments and formatting stay; references
/// to unset variables are kept. Returns (new text, replaced names, unset names).
pub fn inline_env(text: &str) -> Result<(String, Vec<String>, Vec<String>)> {
use std::collections::BTreeSet;
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new());
let mut fix = |s: &str| -> Option<String> {
if !re.is_match(s) {
return None;
}
let out = re.replace_all(s, |c: &regex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) {
Some(v) => {
done.insert(c[1].to_string());
v
}
None => {
missing.insert(c[1].to_string());
c[0].to_string()
}
});
(out != s).then(|| out.into_owned())
};
fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option<String>) {
match v {
toml_edit::Value::String(f) => {
if let Some(new) = fix(f.value()) {
let decor = f.decor().clone();
let mut nv = toml_edit::Formatted::new(new);
*nv.decor_mut() = decor;
*f = nv;
}
}
toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)),
toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)),
_ => {}
}
}
fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option<String>) {
match i {
toml_edit::Item::Value(v) => walk_value(v, fix),
toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)),
toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))),
toml_edit::Item::None => {}
}
}
walk_item(doc.as_item_mut(), &mut fix);
Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect()))
}
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
#[derive(Debug, Deserialize)]
pub struct ConfigPatch {
@@ -667,26 +623,6 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
v
}
#[cfg(test)]
mod inline_env_tests {
#[test]
fn replaces_set_variables_and_keeps_comments() {
// SAFETY: test-only, the variable names are unique to this test.
unsafe {
std::env::set_var("CC_TEST_INLINE_KEY", "sk-123");
std::env::remove_var("CC_TEST_INLINE_UNSET");
}
let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n";
let (out, done, missing) = super::inline_env(text).unwrap();
assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}");
assert!(out.contains("Bearer sk-123"));
assert!(out.contains("# keys: use ${NAME}"), "comments untouched");
assert!(out.contains("${CC_TEST_INLINE_UNSET}"));
assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]);
assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]);
}
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn markdown(s: &Session, messages: &[Message]) -> String {
for p in &m.parts {
match p {
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
Part::Summary { text, .. } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
}
+29 -7
View File
@@ -23,6 +23,8 @@ pub struct Agent {
/// Allowed tools; None = all.
pub tools: Option<Vec<String>>,
pub read_only: bool,
/// "coding" or "chat" (plain conversation prompt).
pub style: String,
pub model: Option<String>,
/// "builtin", "config" or "file".
pub source: String,
@@ -41,11 +43,12 @@ pub use codecraft_core::prompts::*;
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room);
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
]
}
@@ -113,7 +116,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
prompt: body,
tools,
read_only: false,
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
@@ -131,7 +135,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
read_only: base.as_ref().is_some_and(|b| b.read_only),
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
@@ -177,7 +182,24 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
out
}
/// The plain chat mode: no coding instructions, project files or memory – only the
/// date, the model and what the user keeps in their personal memory.
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
let mut s = agent.prompt.clone();
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
if let Some(u) = owner {
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
if !memory.trim().is_empty() {
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
}
}
s
}
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
if agent.style == "chat" {
return chat_prompt(app, agent, model, owner);
}
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
+29 -16
View File
@@ -284,7 +284,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
let threshold = app.config().server.auto_compact_threshold;
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
compact(app.clone(), session_id, cancel.clone()).await?;
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
continue;
}
}
@@ -746,7 +746,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
for m in &req.messages {
for p in &m.parts {
match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(),
Part::ToolCall { input, .. } => chars += input.to_string().len(),
Part::ToolResult { content, images: imgs, .. } => {
chars += content.len();
@@ -913,9 +913,7 @@ async fn stream_step(
app.store.add_message(&session.id, &message)?;
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
}
let mut s = app.session(&session.id)?;
s.last_usage = Some(usage);
app.save_session(s)?;
app.update_session(&session.id, |s| s.last_usage = Some(usage))?;
return Ok(Step { message, stop_reason });
}
}
@@ -995,11 +993,12 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
Ok((title, usage)) => {
record_side_usage(&app, &session, &spec, &model, &usage, "title");
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
if !title.is_empty() {
let _ = app.update_session(&session.id, |s| {
if s.title.is_empty() {
s.title = title.chars().take(80).collect();
let _ = app.save_session(s);
}
});
}
}
Err(e) => tracing::debug!("title generation failed: {e:#}"),
@@ -1096,7 +1095,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
let root = app.root_session_id(session_id);
let Ok(mut s) = app.session(&root) else { return };
let Ok(s) = app.session(&root) else { return };
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
if let Some(from) = only_from {
if s.phase.as_deref() != Some(from) {
@@ -1113,8 +1112,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
if s.phase.as_deref() == Some(phase) {
return;
}
s.phase = Some(phase.to_string());
let _ = app.save_session(s);
let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string()));
}
/// Review the last turn against the user's request. Returns feedback for the
@@ -1217,7 +1215,7 @@ pub fn transcript_of(history: &[Message]) -> String {
for p in &m.parts {
match p {
Part::Text { text } => body.push_str(&format!("{text}\n")),
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
Part::Summary { text, .. } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
Part::ToolResult { name, content, is_error, .. } => {
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
@@ -1234,7 +1232,23 @@ pub fn transcript_of(history: &[Message]) -> String {
}
/// Replace the active history with a summary (the old messages stay visible in the UI).
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a
/// nearly full context; the chat shows the progress and the reason.
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let event = |state: &str| Event::Compaction {
session_id: session_id.to_string(),
state: state.into(),
auto: auto.is_some(),
tokens: auto.map(|a| a.0),
limit: auto.map(|a| a.1),
};
app.emit(event("started"));
let res = compact_inner(app.clone(), session_id, cancel, auto).await;
app.emit(event(if res.is_ok() { "done" } else { "failed" }));
res
}
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let session = app.session(session_id)?;
let history = active_history(app.store.messages(session_id)?);
if history.is_empty() {
@@ -1273,12 +1287,11 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
if summary.trim().is_empty() {
bail!("compaction produced an empty summary");
}
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
let mut s = app.session(session_id)?;
s.last_usage = None;
let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens()));
save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?;
app.update_session(session_id, |s| s.last_usage = None)?;
// Files must be re-read after compaction before editing.
app.with_runtime(session_id, |r| r.read_files.clear());
app.save_session(s)?;
Ok(())
}
+1 -1
View File
@@ -239,7 +239,7 @@ impl Store {
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
for p in &m.parts {
let text = match p {
Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(),
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
_ => continue,
};
+33 -4
View File
@@ -18,13 +18,16 @@ use tokio::sync::broadcast;
/// Output kept for re-attaching clients.
const SCROLLBACK: usize = 256 * 1024;
/// Detached terminals are stopped after this time.
/// Detached terminals outside a chat are stopped after this time; terminals that
/// belong to a chat stay open until they are closed, exit or the chat is deleted.
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
const MAX_PER_USER: usize = 8;
const MAX_PER_USER: usize = 16;
pub struct Term {
pub id: String,
pub owner_id: String,
/// Chat the terminal is a tab of.
pub session_id: Option<String>,
pub cwd: PathBuf,
pub created_at: i64,
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
@@ -57,6 +60,7 @@ impl Term {
json!({
"id": self.id,
"cwd": self.cwd,
"session_id": self.session_id,
"created_at": self.created_at,
"alive": self.alive(),
"attached": *self.attached.lock().unwrap() > 0,
@@ -76,6 +80,30 @@ impl Terminals {
v.into_iter().map(|(_, i)| i).collect()
}
/// Terminals of one chat (oldest first).
pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec<Value> {
let mut v: Vec<_> = self
.map
.lock()
.unwrap()
.values()
.filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id))
.map(|t| (t.created_at, t.info()))
.collect();
v.sort_by_key(|(c, _)| *c);
v.into_iter().map(|(_, i)| i).collect()
}
/// Close all terminals of a deleted chat.
pub fn kill_session(&self, session_id: &str) {
let gone: Vec<Arc<Term>> = {
let mut map = self.map.lock().unwrap();
let ids: Vec<String> = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect();
ids.iter().filter_map(|id| map.remove(id)).collect()
};
gone.iter().for_each(|t| t.kill());
}
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
}
@@ -100,7 +128,7 @@ impl Terminals {
pub fn reap(&self) {
let mut map = self.map.lock().unwrap();
map.retain(|_, t| {
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
if stale || gone {
t.kill();
@@ -113,7 +141,7 @@ impl Terminals {
}
/// Start a shell for `user` in `cwd` (already access-checked).
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>, cols: u16, rows: u16) -> Result<Arc<Term>> {
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
}
@@ -142,6 +170,7 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
let term = Arc::new(Term {
id: crate::types::new_id(),
owner_id: user.id.clone(),
session_id,
cwd: cwd.to_path_buf(),
created_at: crate::types::now_ms(),
writer: tokio::sync::Mutex::new(writer),
+179
View File
@@ -0,0 +1,179 @@
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { del, get, post } from "../api";
import { confirmDialog, toast } from "../dialogs";
import { useT } from "../i18n";
import { Icon } from "./Icon";
import type { TermInfo } from "./Terminal";
// xterm and the editor are large: load them only when such a tab is shown.
const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView })));
const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
const POLL_MS = 15_000;
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
const filesKey = (sessionId: string) => `codecraft.chatFiles.${sessionId}`;
/** A file manager tab: lives in this browser (it has no server state). */
interface FileTab {
id: string;
dir: string;
}
const loadFileTabs = (sessionId: string): FileTab[] => {
try {
return JSON.parse(localStorage.getItem(filesKey(sessionId)) ?? "[]");
} catch {
return [];
}
};
/**
* Tabs of a chat: the chat itself, its terminals and file managers. Terminals live on
* the server until closed, so they show up again after a reload or on another device
* (the list is refreshed periodically); file manager tabs are kept per browser.
*/
export function useChatTabs(sessionId: string, cwd: string) {
const t = useT();
const [terms, setTerms] = useState<TermInfo[]>([]);
const [files, setFilesState] = useState<FileTab[]>(() => loadFileTabs(sessionId));
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
const setTab = useCallback(
(v: string) => {
setTabState(v);
sessionStorage.setItem(tabKey(sessionId), v);
},
[sessionId],
);
const setFiles = useCallback(
(update: (f: FileTab[]) => FileTab[]) =>
setFilesState((cur) => {
const next = update(cur);
localStorage.setItem(filesKey(sessionId), JSON.stringify(next));
return next;
}),
[sessionId],
);
const reload = useCallback(
() =>
get<TermInfo[]>(`/terminals?session=${encodeURIComponent(sessionId)}`)
.then(setTerms)
.catch(() => undefined),
[sessionId],
);
useEffect(() => {
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
setFilesState(loadFileTabs(sessionId));
setTerms([]);
void reload();
const id = setInterval(reload, POLL_MS);
const onFocus = () => void reload();
window.addEventListener("focus", onFocus);
return () => {
clearInterval(id);
window.removeEventListener("focus", onFocus);
};
}, [sessionId, reload]);
// A tab closed elsewhere (or a stale stored tab) falls back to the chat.
const activeTerm = terms.find((x) => x.id === tab);
const activeFiles = files.find((f) => f.id === tab);
const current = activeTerm || activeFiles ? tab : "chat";
const openTerminal = async () => {
try {
const info = await post<TermInfo>("/terminals", { session_id: sessionId, cols: 100, rows: 30 });
await reload();
setTab(info.id);
} catch (e) {
toast(e);
}
};
const closeTerminal = async (x: TermInfo, n: number) => {
if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return;
await del(`/terminals/${x.id}`).catch(() => undefined);
if (tab === x.id) setTab("chat");
await reload();
};
const openFiles = () => {
const id = `files:${Date.now().toString(36)}`;
setFiles((f) => [...f, { id, dir: cwd }]);
setTab(id);
};
const closeFiles = (id: string) => {
setFiles((f) => f.filter((x) => x.id !== id));
if (tab === id) setTab("chat");
};
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x))), [setFiles]);
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
}
type Tabs = ReturnType<typeof useChatTabs>;
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */
export function ChatTabBar({ state }: { state: Tabs }) {
const t = useT();
const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state;
if (terms.length === 0 && files.length === 0) return null;
return (
<div className="chat-tabs" role="tablist">
<button role="tab" aria-selected={tab === "chat"} className={`chat-tab ${tab === "chat" ? "active" : ""}`} onClick={() => setTab("chat")}>
<Icon name="sparkle" /> {t("terminal.chatTab")}
</button>
{terms.map((x, i) => (
<span key={x.id} className={`chat-tab ${tab === x.id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
<button role="tab" aria-selected={tab === x.id} className="ghost" onClick={() => setTab(x.id)} title={x.cwd}>
<Icon name="terminal" /> {t("terminal.tab", { n: i + 1 })}
</button>
<button className="ghost close" onClick={() => void closeTerminal(x, i + 1)} aria-label={t("terminal.close")} title={t("terminal.close")}>
<Icon name="close" size={12} />
</button>
</span>
))}
{files.map((f) => (
<span key={f.id} className={`chat-tab ${tab === f.id ? "active" : ""}`}>
<button role="tab" aria-selected={tab === f.id} className="ghost" onClick={() => setTab(f.id)} title={f.dir}>
<Icon name="folder" /> {basename(f.dir)}
</button>
<button className="ghost close" onClick={() => closeFiles(f.id)} aria-label={t("files.closeTab")} title={t("files.closeTab")}>
<Icon name="close" size={12} />
</button>
</span>
))}
<button className="ghost chat-tab-add" onClick={() => void openTerminal()} title={t("terminal.new")} aria-label={t("terminal.new")}>
<Icon name="plus" /> <Icon name="terminal" />
</button>
<button className="ghost chat-tab-add" onClick={openFiles} title={t("files.newTab")} aria-label={t("files.newTab")}>
<Icon name="plus" /> <Icon name="folder" />
</button>
</div>
);
}
/**
* Tab contents besides the chat. The active terminal is mounted (it re-attaches and
* replays its output); file managers stay mounted while hidden, so unsaved edits survive.
*/
export function ChatTabPanes({ state }: { state: Tabs }) {
const t = useT();
const { tab, activeTerm, files, fileDirChanged } = state;
return (
<Suspense fallback={<div className="empty">{t("common.loading")}</div>}>
{activeTerm && <TermView key={activeTerm.id} info={activeTerm} />}
{files.map((f) => (
<div key={f.id} className="chat-pane chat-files" hidden={tab !== f.id}>
<Files embed={{ startDir: f.dir, onDirChange: (d) => fileDirChanged(f.id, d) }} />
</div>
))}
</Suspense>
);
}
+32 -6
View File
@@ -8,7 +8,7 @@ import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i
import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
@@ -24,6 +24,7 @@ import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -145,8 +146,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const allPermissions = useStore((s) => s.permissions);
const allQuestions = useStore((s) => s.questions);
const waiting = useStore((s) => s.waiting[sessionId]);
const compacting = useStore((s) => s.compacting[sessionId]);
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
@@ -314,14 +317,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
)}
<button
className="small ghost hide-mobile"
onClick={() => {
sessionStorage.setItem("codecraft.termCwd", session.cwd);
navigate("/terminal/new");
}}
onClick={() => void tabs.openTerminal()}
title={t("terminal.here")}
>
<Icon name="terminal" />
</button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" />
</button>
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" />
@@ -366,6 +369,9 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</>
)}
</div>
<ChatTabBar state={tabs} />
<ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
@@ -402,7 +408,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
if (summary && summary.type === "summary") {
return (
<details key={m.id} className="summary-divider">
<summary>{t("chat.compacted")}</summary>
<summary>
{summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
<Markdown text={summary.text} />
</details>
);
@@ -452,6 +464,19 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
{compacting && (
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
<Spinner small />
<div>
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
{compacting.auto && compacting.tokens && compacting.limit && (
<div className="hint">
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
</div>
)}
</div>
</div>
)}
{(busy || waiting) && (
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
@@ -514,6 +539,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
onUpdate={update}
/>
)}
</div>
</>
);
}
+11 -11
View File
@@ -288,20 +288,20 @@ export function Composer({
/>
<div className="composer-bar">
<div className="composer-options">
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
{agents.map((a) => (
<button
key={a.name}
role="tab"
aria-selected={session.agent === a.name}
className={session.agent === a.name ? "active" : ""}
title={a.description}
onClick={() => onUpdate({ agent: a.name })}
<select
className="mode-select"
value={session.agent}
onChange={(e) => onUpdate({ agent: e.target.value })}
aria-label={t("composer.mode")}
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
>
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
{agents.map((a) => (
<option key={a.name} value={a.name} title={a.description}>
{a.name}
</button>
</option>
))}
</div>
</select>
{session.agent === "craft" && (
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
+29 -6
View File
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { navigate } from "../router";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -55,10 +56,16 @@ function icon(e: Entry) {
return <Icon name="file" className="ficon" />;
}
export function Files({ onMenu }: { onMenu: () => void }) {
/** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */
export interface FilesEmbed {
startDir: string;
onDirChange?: (dir: string) => void;
}
export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) {
const t = useT();
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
const [dir, setDir] = useState<string>(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? "");
const [listing, setListing] = useState<Listing>();
const [hidden, setHidden] = useState(false);
const [error, setError] = useState<string>();
@@ -78,7 +85,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
setListing(l);
setDir(l.path);
localStorage.setItem("codecraft.filesDir", l.path);
if (embed) embed.onDirChange?.(l.path);
else localStorage.setItem("codecraft.filesDir", l.path);
setError(undefined);
} catch (e: any) {
setError(e.message);
@@ -142,7 +150,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
// "Edit file" links from other pages (e.g. automations) open the file directly.
useEffect(() => {
const target = sessionStorage.getItem("codecraft.openFile");
const target = embed ? null : sessionStorage.getItem("codecraft.openFile");
if (!target) return;
sessionStorage.removeItem("codecraft.openFile");
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
@@ -186,6 +194,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
});
};
/** New chat with the folder as working directory (default model and mode). */
const chatHere = async (e: Entry) => {
try {
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
navigate(`/s/${s.id}`);
} catch (err) {
toast(err);
}
};
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
@@ -324,6 +342,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<details className="entry-menu">
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu">
{e.is_dir && (
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
)}
<button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
{!e.is_dir && (
@@ -418,12 +439,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
return (
<>
<div className="topbar">
<div className={embed ? "files-embed-bar" : "topbar"}>
{!embed && (
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
)}
<div className="grow">
<h1>{t("nav.files")}</h1>
{!embed && <h1>{t("nav.files")}</h1>}
<div className="path">{listing?.path}</div>
</div>
{listing?.repo && (
+16 -7
View File
@@ -103,6 +103,7 @@ const ICONS: Record<string, ReactNode> = {
),
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
};
@@ -185,7 +186,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const [creating, setCreating] = useState<string>();
const fileInput = useRef<HTMLInputElement>(null);
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model);
@@ -244,7 +245,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const canStart =
!creating &&
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
/** The first message(s) for the agent. */
const prompts = (uploaded: string[]): string[] => {
@@ -275,7 +276,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
try {
// 1. Folder
let cwd = picked;
if (folderMode === "git") {
if (task === "chat") {
// A plain conversation needs no project: it runs in the user's own folder.
cwd = rootPath;
} else if (folderMode === "git") {
setCreating(t("wizard.cloning"));
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
@@ -295,7 +299,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
}
if (!cwd) throw new Error("could not create the folder");
}
localStorage.setItem(LAST_DIR, cwd);
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
const uploaded: string[] = [];
const mkdirs = async (dir: string) => {
@@ -327,7 +331,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
if (isolated && folderMode !== "new") {
if (isolated && folderMode !== "new" && task !== "chat") {
setCreating(t("worktree.creating"));
await post(`/sessions/${s.id}/worktree`, { action: "create" });
}
@@ -359,6 +363,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="panel wiz-panel">
<Step n={n++} title={t("wizard.whatToDo")}>
<div className="wiz-cards">
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
@@ -454,7 +459,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
>
<textarea
value={text}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
onChange={(e) => setText(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
@@ -512,9 +517,11 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="wiz-foot">
<div className="wiz-foot-row">
<label>{t("wizard.builtBy")}</label>
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
<ModelSelect value={model} onChange={setModel} />
</div>
{task !== "chat" && (
<>
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
{(["new", "existing", "git"] as const).map((m) => (
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
@@ -564,6 +571,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
</span>
</label>
)}
</>
)}
{error && <div className="notice err">{error}</div>}
<div className="wiz-start-row">
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
-51
View File
@@ -1014,56 +1014,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
// --- raw ----------------------------------------------------------------------
/** ${VAR} references left in the config: move their values from the environment into it. */
function EnvRefs({ onDone }: { onDone: () => void }) {
const t = useT();
const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env");
const [busy, setBusy] = useState(false);
if (!data || (data.set.length === 0 && data.unset.length === 0)) return null;
const apply = async () => {
if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return;
setBusy(true);
try {
const r = await post<{ set: string[] }>("/config/inline-env");
toast(t("settings.envDone", { n: r.set.length }), "success");
reload();
onDone();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="panel" style={{ marginBottom: 12 }}>
<h3>
<Icon name="file-text" /> {t("settings.envTitle")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("settings.envHint")}
</div>
{data.set.length > 0 && (
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
{data.set.map((n) => (
<span key={n} className="chip mono">
{n}
</span>
))}
<span className="spacer" />
<button className="primary small" onClick={apply} disabled={busy}>
{t("settings.envApply")}
</button>
</div>
)}
{data.unset.length > 0 && (
<div className="hint" style={{ marginTop: 8 }}>
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
</div>
)}
</div>
);
}
function Raw({ reload }: { reload: () => void }) {
const t = useT();
const [text, setText] = useState<string>();
@@ -1074,7 +1024,6 @@ function Raw({ reload }: { reload: () => void }) {
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
return (
<>
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
<div className="notice">{t("settings.rawHint")}</div>
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
{view}
+3 -2
View File
@@ -10,9 +10,10 @@ import { onThemeChange } from "../theme";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
interface TermInfo {
export interface TermInfo {
id: string;
cwd: string;
session_id?: string | null;
created_at: number;
alive: boolean;
attached: boolean;
@@ -132,7 +133,7 @@ export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }
);
}
function TermView({ info }: { info: TermInfo }) {
export function TermView({ info }: { info: TermInfo }) {
const t = useT();
const host = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
+67 -9
View File
@@ -9,25 +9,60 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
type Patch = { path: string[]; value: unknown };
interface ToolInfo {
name: string;
read_only: boolean;
description: string;
}
/** Tool checkboxes: nothing checked = all tools. */
function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
const t = useT();
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
const extra = value.filter((v) => !tools.some((x) => x.name === v));
return (
<div className="tool-picker">
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
<div className="tool-grid">
{tools.map((x) => (
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
<span className="mono">{x.name}</span>
</label>
))}
{extra.map((n) => (
<label key={n} className="tool-item">
<input type="checkbox" checked onChange={() => toggle(n)} />
<span className="mono">{n}</span>
</label>
))}
</div>
</div>
);
}
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
documentation, summaries, step-by-step guides and translations.
Return only the finished text.`;
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
const t = useT();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
const [form, setForm] = useState({
model: a.model ?? "",
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
description: raw?.description ?? "",
tools: (raw?.tools ?? []).join(", "),
tools: (raw?.tools ?? []) as string[],
read_only: !!(raw?.read_only ?? a.read_only),
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
prompt: raw?.prompt ?? "",
mode: raw?.mode ?? a.mode,
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
const save = async () => {
const base = ["agents", a.name];
@@ -45,7 +80,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode });
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
}
try {
@@ -133,8 +170,23 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<option value="subagent">{t("agents.modeSubagent")}</option>
<option value="primary">{t("agents.modePrimary")}</option>
</select>
{form.mode === "primary" && (
<>
<label>{t("agents.style")}</label>
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
<option value="coding">{t("agents.styleCoding")}</option>
<option value="chat">{t("agents.styleChat")}</option>
</select>
<div className="hint">{t("agents.styleHint")}</div>
</>
)}
<label>{t("agents.readOnlyLabel")}</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
<span className="hint">{t("agents.readOnlyHint")}</span>
</label>
<label>{t("agents.tools")}</label>
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
<label>{t("agents.prompt")}</label>
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
</>
@@ -160,6 +212,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
const t = useT();
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
const [name, setName] = useState("");
@@ -172,12 +225,14 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
}
};
const add = async (template: "writer" | "blank") => {
const add = async (template: "writer" | "blank" | "mode") => {
const n = name.trim() || (template === "writer" ? "writer" : "");
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const value =
template === "writer"
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
: template === "mode"
? { mode: "primary" }
: { mode: "subagent", description: "", prompt: "" };
try {
await patch("/config", { patches: [{ path: ["agents", n], value }] });
@@ -194,13 +249,16 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
<div className="notice">{t("agents.intro")}</div>
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
<div className="panel">
<h3>{t("agents.add")}</h3>
<div className="row">
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
+ {t("agents.addMode")}
</button>
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
<button onClick={() => add("blank")} disabled={!name.trim()}>
+ {t("agents.addBlank")}
+23 -7
View File
@@ -65,12 +65,6 @@ export const de: Messages = {
"settings.budgetWarning": "Warnen ab",
"settings.adminKey": "Admin-Key (optional)",
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
"settings.envApply": "In die Konfiguration übernehmen",
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
"settings.fetchPrices": "Preise abrufen",
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
"stats.perMillion": "pro 1 Mio. Tok.",
@@ -91,6 +85,9 @@ export const de: Messages = {
"files.newFolderName": "Ordnername:",
"files.renameTo": "Neuer Name:",
"files.copyTo": "Name der Kopie:",
"files.chatHere": "Chat hier öffnen",
"files.newTab": "Dateimanager öffnen",
"files.closeTab": "Dateimanager schließen",
"files.rename": "Umbenennen",
"files.duplicate": "Duplizieren",
"files.download": "Herunterladen",
@@ -241,6 +238,9 @@ export const de: Messages = {
"wizard.title": "Was möchtest du machen?",
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
"wizard.whatToDo": "Was möchtest du machen?",
"wizard.task.chat": "Einfach chatten",
"wizard.task.chatDesc": "Fragen, besprechen, nachschlagen – ohne Projekt und Dateien",
"wizard.placeholder.chat": "Worüber möchtest du sprechen?",
"wizard.task.craft": "Etwas craften",
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
"wizard.task.build": "Am Code arbeiten",
@@ -308,6 +308,9 @@ export const de: Messages = {
"rename.generate": "Generieren",
"nav.terminal": "Terminal",
"terminal.new": "Neues Terminal",
"terminal.chatTab": "Chat",
"terminal.tab": "Terminal {n}",
"terminal.closeConfirm": "{name} schließen? Die Shell und alles, was darin läuft, wird beendet.",
"terminal.close": "Terminal schließen",
"terminal.here": "Terminal in diesem Ordner öffnen",
"terminal.connecting": "Verbinde…",
@@ -562,6 +565,11 @@ export const de: Messages = {
"chat.exportMd": "Markdown (.md)",
"chat.exportJson": "JSON (.json)",
"chat.empty": "Schreib eine Nachricht, um zu starten.",
"chat.compactedAuto": "Kontext fast voll ({tokens} Tokens) – Unterhaltung automatisch komprimiert · Zusammenfassung",
"chat.compactedManual": "Unterhaltung bei {tokens} Tokens komprimiert · Zusammenfassung",
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
"chat.context": "Kontext",
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
@@ -848,8 +856,16 @@ export const de: Messages = {
"agents.description": "Beschreibung",
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
"agents.mode": "Art",
"agents.addMode": "Neuer Modus",
"agents.style": "Art",
"agents.styleCoding": "Programmieren – Projekt-Kontext und Coding-Ablauf",
"agents.styleChat": "Unterhaltung – nur dieser Prompt, kein Projekt",
"agents.styleHint": "Unterhaltungs-Modi bekommen nur deinen Prompt plus Datum und persönliches Gedächtnis, wie der eingebaute Chat-Modus.",
"agents.readOnlyLabel": "Nur lesen",
"agents.readOnlyHint": "Nur Werkzeuge, die nichts verändern (wie der Plan-Modus)",
"agents.toolsSome": "{n} Werkzeuge ausgewählt – nur diese sind verfügbar",
"agents.tools": "Tools",
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
"agents.toolsAll": "Nichts ausgewählt = alle Werkzeuge",
"agents.prompt": "Anweisungen",
"agents.reset": "Zurücksetzen",
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
+23 -7
View File
@@ -64,12 +64,6 @@ export const en = {
"settings.budgetWarning": "Warn at",
"settings.adminKey": "Admin key (optional)",
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
"settings.envTitle": "Settings from environment variables",
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
"settings.envApply": "Move into the configuration",
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
"settings.envUnset": "Referenced but not set (left as they are):",
"settings.fetchPrices": "Fetch prices",
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
"stats.perMillion": "per 1M tok",
@@ -90,6 +84,9 @@ export const en = {
"files.newFolderName": "Folder name:",
"files.renameTo": "New name:",
"files.copyTo": "Name of the copy:",
"files.chatHere": "Open chat here",
"files.newTab": "Open file manager",
"files.closeTab": "Close file manager",
"files.rename": "Rename",
"files.duplicate": "Duplicate",
"files.download": "Download",
@@ -240,6 +237,9 @@ export const en = {
"wizard.title": "What do you want to do?",
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
"wizard.whatToDo": "What do you want to do?",
"wizard.task.chat": "Just chat",
"wizard.task.chatDesc": "Ask, discuss, look things up – no project, no files",
"wizard.placeholder.chat": "What would you like to talk about?",
"wizard.task.craft": "Craft something",
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
"wizard.task.build": "Work on code",
@@ -307,6 +307,9 @@ export const en = {
"rename.generate": "Generate",
"nav.terminal": "Terminal",
"terminal.new": "New terminal",
"terminal.chatTab": "Chat",
"terminal.tab": "Terminal {n}",
"terminal.closeConfirm": "Close {name}? The shell and everything running in it are stopped.",
"terminal.close": "Close terminal",
"terminal.here": "Open a terminal in this folder",
"terminal.connecting": "Connecting…",
@@ -561,6 +564,11 @@ export const en = {
"chat.exportMd": "Markdown (.md)",
"chat.exportJson": "JSON (.json)",
"chat.empty": "Send a message to start.",
"chat.compactedAuto": "Context nearly full ({tokens} tokens) – conversation compacted automatically · summary",
"chat.compactedManual": "Conversation compacted at {tokens} tokens · summary",
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
"chat.compactingManual": "Compacting the conversation",
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
"chat.compacted": "Conversation compacted – summary",
"chat.context": "Context",
"chat.contextHint": "{tokens} tokens in the last request",
@@ -847,8 +855,16 @@ export const en = {
"agents.description": "Description",
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
"agents.mode": "Type",
"agents.addMode": "New mode",
"agents.style": "Kind",
"agents.styleCoding": "Coding – project context and coding workflow",
"agents.styleChat": "Conversation – only this prompt, no project",
"agents.styleHint": "Conversation modes get just your prompt plus date and personal memory, like the built-in chat mode.",
"agents.readOnlyLabel": "Read-only",
"agents.readOnlyHint": "Only tools that change nothing (like plan mode)",
"agents.toolsSome": "{n} tools selected – only these are available",
"agents.tools": "Tools",
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
"agents.toolsAll": "Nothing selected = all tools",
"agents.prompt": "Instructions",
"agents.reset": "Reset",
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
+11
View File
@@ -47,6 +47,8 @@ export interface State {
queue: Record<string, QueueItem[]>;
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
waiting: Record<string, string | undefined>;
/** Compaction running in a session (auto = context nearly full). */
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
}
let state: State = {
@@ -67,6 +69,7 @@ let state: State = {
queue: {},
steps: {},
waiting: {},
compacting: {},
};
const listeners = new Set<() => void>();
@@ -188,6 +191,14 @@ export function applyEvent(ev: ServerEvent) {
case "waiting":
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
break;
case "compaction":
setState((s) => ({
compacting: {
...s.compacting,
[ev.session_id]: ev.state === "started" ? { auto: ev.auto, tokens: ev.tokens, limit: ev.limit, since: Date.now() } : undefined,
},
}));
break;
case "mcp_status":
setState(() => ({ mcp: ev.servers }));
break;
+122
View File
@@ -3603,3 +3603,125 @@ button.danger-solid {
.plan-pct {
font-variant-numeric: tabular-nums;
}
/* --- chat tabs (chat + terminals) ---------------------------------------------- */
.chat-tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 10px 0;
border-bottom: 1px solid var(--bg3);
overflow-x: auto;
flex: none;
}
.chat-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-bottom: 2px solid transparent;
border-radius: 6px 6px 0 0;
background: none;
color: var(--fg2);
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
span.chat-tab {
padding: 0 4px 0 0;
}
span.chat-tab > button {
padding: 6px 4px 6px 12px;
color: inherit;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-tab .close {
padding: 2px 4px;
opacity: 0.6;
}
.chat-tab .close:hover {
opacity: 1;
}
.chat-tab:hover {
background: var(--bg1);
color: var(--fg0);
}
.chat-tab.active {
color: var(--fg0);
border-bottom-color: var(--accent);
background: var(--bg1);
}
.chat-tab.dead {
opacity: 0.55;
}
.chat-tab-add {
padding: 4px 8px;
}
.chat-pane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-pane[hidden] {
display: none;
}
.files-embed-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--bg3);
flex: none;
}
.files-embed-bar .path {
font-size: 12px;
color: var(--fg3);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-picker .hint {
margin-bottom: 6px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 4px 12px;
}
.tool-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
cursor: pointer;
}
.tool-item.disabled {
opacity: 0.45;
cursor: default;
}
.mode-select {
font-weight: 600;
color: var(--accent);
}
.compacting-card {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
padding: 10px 14px;
border: 1px dashed var(--bg3);
border-radius: var(--radius);
background: var(--bg1);
font-size: 13px;
}
.compacting-card.auto {
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
}
+4 -1
View File
@@ -19,7 +19,7 @@ export type Part =
is_error: boolean;
meta?: any;
}
| { type: "summary"; text: string };
| { type: "summary"; text: string; auto?: boolean; tokens?: number };
export interface Usage {
input_tokens: number;
@@ -118,6 +118,7 @@ export type ServerEvent =
| { type: "waiting"; session_id: string; reason: string | null }
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
| { type: "resync" };
@@ -150,6 +151,8 @@ export interface AgentInfo {
description: string;
mode: "primary" | "subagent";
read_only: boolean;
/** "coding" or "chat" (plain conversation prompt). */
style?: "coding" | "chat";
model?: string | null;
tools?: string[] | null;
source: "builtin" | "config" | "file";
+18
View File
@@ -3,6 +3,24 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00003": {
en: [
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
"New session → “Just chat” for plain conversation",
"Own modes: Settings → Agents & commands → New mode",
"The chat shows when the conversation is compacted (automatically or on request)",
"File manager: “Open chat here” in a folder's menu",
"Terminal client downloadable from the server again",
],
de: [
"Terminals und Dateimanager als Tabs im Chat – Terminals laufen weiter, auch für ein anderes Gerät",
"Neue Sitzung → „Einfach chatten“ für reine Unterhaltung",
"Eigene Modi: Einstellungen → Agents & Befehle → Neuer Modus",
"Der Chat zeigt an, wenn die Unterhaltung komprimiert wird (automatisch oder auf Wunsch)",
"Dateimanager: „Chat hier öffnen“ im Ordner-Menü",
"Terminal-Client wieder direkt vom Server herunterladbar",
],
},
"202611-00002": {
en: [
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",