diff --git a/CHANGELOG.md b/CHANGELOG.md index 38596d6..4f97493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks + the cut. Also starts a fresh session with Claude Code. + ## [v202611-00029] – 2026-11-15 ### Added diff --git a/server/src/api.rs b/server/src/api.rs index 1562e04..1a71e3b 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -1128,6 +1128,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: tokio::spawn(do_compact(app.clone(), id.clone())); return Ok(json!({"ok": true, "command": "compact"})); } + if text == "/clear" { + if app.is_busy(&id) { + return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); + } + runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?; + return Ok(json!({"ok": true, "command": "clear"})); + } let cwd = PathBuf::from(&session.cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd); let mut prompt_text = text.clone(); diff --git a/server/src/runner.rs b/server/src/runner.rs index 5a89212..0931902 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -1723,10 +1723,43 @@ async fn compact_inner(app: Arc, session_id: &str, cancel: CancellationToke Ok(()) } +/// Text of the marker `/clear` puts into the history (the chat view recognises it). +pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]"; + +/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a +/// compaction without a summary: history before the marker is not sent any more. +pub fn clear_context(app: &App, session_id: &str) -> Result<()> { + save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?; + app.update_session(session_id, |s| { + s.last_usage = None; + s.todos = Value::Null; + })?; + app.with_runtime(session_id, |r| r.read_files.clear()); + // Claude Code continues its own conversation: start a new one. + if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) { + let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::::None); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn cleared_marker_starts_a_new_history() { + let mk = |role, parts| Message::new(role, parts); + let all = vec![ + mk(Role::User, vec![Part::Text { text: "old question".into() }]), + mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]), + mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]), + mk(Role::User, vec![Part::Text { text: "new question".into() }]), + ]; + let active = active_history(all); + assert_eq!(active.len(), 2); + assert!(!active.iter().any(|m| message_text(m).contains("old"))); + } + #[test] fn fit_keeps_head_and_tail_within_budget() { let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000)); diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 83f6713..0ab9582 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -459,13 +459,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight return (
setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}> - {summary.auto + {summary.text.startsWith("[Context cleared") + ? t("chat.cleared") + : summary.auto ? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" }) : summary.tokens ? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) }) : t("chat.compacted")} - {summaryOpen && } + {summaryOpen && !summary.text.startsWith("[Context cleared") && }
); } diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 4e57f46..ef3aeb5 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -20,6 +20,7 @@ interface Suggestion { const BUILTIN: { name: string; key: Key }[] = [ { name: "compact", key: "command.compact" }, + { name: "clear", key: "command.clear" }, { name: "new", key: "command.new" }, ]; diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index bd01510..bbcb108 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -1144,4 +1144,6 @@ export const de: Messages = { "stats.table.agent": "nach Modus", "stats.table.user": "nach Benutzer", "stats.table.session": "teuerste Chats", + "command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)", + "chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr", }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 113df3e..e5cb08e 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1143,6 +1143,8 @@ export const en = { "stats.table.agent": "by agent", "stats.table.user": "by user", "stats.table.session": "top chats", + "command.clear": "Forget the conversation so far (the chat stays visible)", + "chat.cleared": "Context cleared – the model no longer sees the messages above", }; export type Messages = Record; diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 03c81c1..3916771 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["/clear: the model forgets the conversation so far, the chat stays visible"], + de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"], + }, "202611-00029": { en: [ "Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",