Claude Code engine, image viewer, colour tags, checks per turn and more

- Claude Code as an engine: chats on a "claude-code/<alias>" model run through
  the claude CLI (stream-json); tool approvals go through our permission
  system. Sign in from the web UI (URL + pasted code), CLI version and update
  from Settings; the Docker image contains the CLI.
- Small models (8k context) for side tasks: input is cut to the model's
  context window; one-line chat status, "What does this do?" on approvals,
  "Plan as automation" (job draft from a chat).
- Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark
  up, use in message), jump to previous own message, message menu, time under
  every message.
- Approvals: push actions (allow/reject), "always allow in this project"
  (never for dangerous commands) with revoke.
- Per turn: changed files with diffs and rollback; project check command with
  a green/red badge and "let the agent fix it".
- Colour tags for chats in the colours of the active theme.
- Project defaults for new chats, project page (timeline), offline queue.

Tested in a throwaway container with a mock model (8k small model): 26 API
checks passed (permissions, diffs, checks, defaults, job draft, side-task
budget, Claude Code status/login URL, unsigned-in chat).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-14 22:30:12 +01:00
1 parent 084452abf6
commit 62fe5bf467
42 files changed
+3734 -55

No files matched your search

+171 -8
View File
@@ -2,7 +2,7 @@
//! checks), feed results back, repeat until the model stops.
use crate::app::{App, PermissionReply};
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
use crate::prompts::{self, Agent};
use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord;
@@ -73,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string();
let turn_started = now_ms();
tokio::spawn(async move {
let res = async {
@@ -114,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
});
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
if leftover.is_empty() && !cancel.is_cancelled() {
spawn_summary(app.clone(), session_id.clone());
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
}
if !leftover.is_empty() {
let _ = start_prompt(app, session_id, leftover);
}
@@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
/// Messages since the last compaction summary. Tool calls left without a
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
/// providers reject histories with unanswered tool calls.
fn active_history(all: Vec<Message>) -> Vec<Message> {
pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
let start = all
.iter()
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
@@ -263,6 +268,10 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text.
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
return crate::claude_code::run(app, session_id, cancel).await;
}
let mut final_text = String::new();
let mut stop_hooks = 0;
let mut refine_done = 0u32;
@@ -639,9 +648,18 @@ async fn execute_call(
// Permission check. Approvals are tracked on the top-level session.
let root = app.root_session_id(&session.id);
let cfg = app.config();
let decision = app.with_runtime(&root, |r| {
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
let (mut decision, session_approved) = app.with_runtime(&root, |r| {
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
});
// What the user allowed "for this project" earlier counts as well.
if decision == Decision::Ask {
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
if !project.is_empty() {
let mut all = session_approved;
all.extend(project);
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
}
}
match decision {
Decision::Deny => {
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
@@ -659,16 +677,27 @@ async fn execute_call(
}
return tool_result(call_id, call_name, text, true, None);
}
PermissionReply::Always => app.with_runtime(&root, |r| {
PermissionReply::Always | PermissionReply::Project => {
// (key, may be remembered for the whole project)
let mut keys: Vec<(String, bool)> = Vec::new();
if name == "bash" {
let cmd = input["command"].as_str().unwrap_or("");
for part in split_command(cmd).unwrap_or_default() {
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
// Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
}
} else {
r.approved.insert(name.to_string());
keys.push((name.to_string(), true));
}
}),
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
if reply == PermissionReply::Project {
for (k, ok) in &keys {
if *ok {
let _ = app.store.add_project_approval(&session.cwd, k);
}
}
}
}
PermissionReply::Once => {}
}
if let Some(sel) = hunks {
@@ -868,6 +897,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
folder: None,
instructions: String::new(),
telegram_quiet: false,
summary: String::new(),
color: None,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -1149,6 +1180,9 @@ fn small_model(app: &App, session: &Session) -> String {
let cfg = app.config();
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone()
} else if crate::claude_code::is_claude_code(&session.model) {
// Claude Code runs itself; side tasks need an ordinary model.
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
} else {
session.model.clone()
}
@@ -1176,6 +1210,122 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
});
}
/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went).
pub(crate) fn fit(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let head = clip(s, max * 2 / 3);
let mut start = s.len() - max / 3;
while !s.is_char_boundary(start) {
start += 1;
}
format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..])
}
/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the
/// model's context window, so models with 8k tokens work as well as large ones: tokens left for
/// the input = context − answer − prompt, at a conservative 2.5 bytes per token.
async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result<String> {
let spec = small_model(app, session);
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let ctx = model.context.unwrap_or(32_000);
let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600);
let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
record_side_usage(app, session, &spec, &model, &usage, kind);
Ok(strip_think(&text))
}
fn message_text(m: &Message) -> String {
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
}
/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input).
fn spawn_summary(app: Arc<App>, session_id: String) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(history) = app.store.messages(&session_id) else { return };
let history = active_history(history);
let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<'));
let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty());
let (Some(u), Some(a)) = (last_user, last_answer) else { return };
let input = format!(
"<previous-line>{}</previous-line>\n<user-request>\n{}\n</user-request>\n<agent-answer>\n{}\n</agent-answer>",
session.summary,
fit(&message_text(u), 1500),
fit(&message_text(a), 2500)
);
let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \
Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only.";
match side_task(&app, &session, "summary", system, &input, 120).await {
Ok(text) => {
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::<String>();
if !line.is_empty() {
let _ = app.modify_session(&session_id, false, |s| s.summary = line);
}
}
Err(e) => tracing::debug!("summary failed: {e:#}"),
}
});
}
/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code).
pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result<String> {
let language = if lang.starts_with("de") { "German" } else { "English" };
let system = format!(
"You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \
what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \
If it is harmless, say so. No markdown headings, no code blocks."
);
let mut body = format!("<tool>{tool}</tool>\n<title>{title}</title>\n<input>\n{}\n</input>", clip(&input.to_string(), 3000));
if let Some(d) = diff {
body.push_str(&format!("\n<diff>\n{}\n</diff>", clip(d, 3000)));
}
side_task(app, session, "explain", &system, &body, 300).await
}
/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown).
/// Works with a small model: the transcript is cut to its context window.
pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> {
let history = active_history(app.store.messages(&session.id)?);
let transcript = transcript_of(&history);
if transcript.trim().is_empty() {
bail!("the session has no messages yet");
}
let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \
Reply in exactly this format. First line: NAME: <at most 5 words>. Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \
Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \
Use the language of the user's messages. Keep it under 300 words.";
let text = side_task(app, session, "job-draft", system, &format!("<conversation>\n{transcript}\n</conversation>"), 1200).await?;
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
let first = lines.next().unwrap_or("");
let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) {
Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::<Vec<_>>().join("\n")),
None => (String::new(), text.clone()),
};
let body = body.trim().to_string();
if body.is_empty() {
bail!("the model returned no instructions");
}
let name = if name.is_empty() { session.title.clone() } else { name };
let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() };
Ok((name.clone(), format!("# {name}\n\n{body}\n")))
}
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
let Ok(session) = app.session(session_id) else { return };
if session.parent_id.is_some() || !session.title.is_empty() {
@@ -1524,6 +1674,19 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
mod tests {
use super::*;
#[test]
fn fit_keeps_head_and_tail_within_budget() {
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
let out = fit(&text, 3000);
assert!(out.len() <= 3100, "{}", out.len());
assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…'));
// Multi-byte text is cut on character boundaries.
let de = "äöüß".repeat(2000);
assert!(fit(&de, 1000).is_char_boundary(0));
assert_eq!(fit("short", 100), "short");
}
#[test]
fn todo_completion_and_reopening() {
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);