minor README tweaks + rust progress

This commit is contained in:
kuberwastaken 2026-04-01 03:31:03 +05:30
parent c99507ca1e
commit 45f7ac9071
9 changed files with 628 additions and 25 deletions

View file

@ -7,7 +7,7 @@ The process was explicitly two-phase:
Specification [`spec/`](https://github.com/kuberwastaken/claude-code/tree/main/spec) - An AI agent analyzed the source and produced exhaustive behavioral specifications and improvements, deviated from the original: architecture, data flows, tool contracts, system designs. No source code was carried forward. Specification [`spec/`](https://github.com/kuberwastaken/claude-code/tree/main/spec) - An AI agent analyzed the source and produced exhaustive behavioral specifications and improvements, deviated from the original: architecture, data flows, tool contracts, system designs. No source code was carried forward.
Implementation [`src/`](https://github.com/kuberwastaken/claude-code/tree/main/src-rust)- A separate AI agent implemented from the spec alone, never referencing the original TypeScript. The output is idiomatic Rust that reproduces the behavior, not the expression. Implementation [`src-rust/`](https://github.com/kuberwastaken/claude-code/tree/main/src-rust)- A separate AI agent implemented from the spec alone, never referencing the original TypeScript. The output is idiomatic Rust that reproduces the behavior, not the expression.
This mirrors the legal precedent established by Phoenix Technologies v. IBM (1984) — clean-room engineering of the BIOS — and the principle from Baker v. Selden (1879) that copyright protects expression, not ideas or behavior. This mirrors the legal precedent established by Phoenix Technologies v. IBM (1984) — clean-room engineering of the BIOS — and the principle from Baker v. Selden (1879) that copyright protects expression, not ideas or behavior.
@ -353,6 +353,14 @@ Claude Code's tool system lives in [`tools/`](https://github.com/kuberwastaken/c
| **WorkflowTool** | Execute workflow scripts | | **WorkflowTool** | Execute workflow scripts |
| **ConfigTool** | Modify settings (**internal only**) | | **ConfigTool** | Modify settings (**internal only**) |
| **TungstenTool** | Advanced features (**internal only**) | | **TungstenTool** | Advanced features (**internal only**) |
| **MCPTool** | Generic MCP tool execution |
| **McpAuthTool** | MCP server authentication |
| **SyntheticOutputTool** | Structured output via dynamic JSON schemas |
| **SuggestBackgroundPRTool** | Suggest background PRs (**internal only**) |
| **VerifyPlanExecutionTool** | Verify plan execution (gated by `CLAUDE_CODE_VERIFY_PLAN`) |
| **CtxInspectTool** | Context window inspection (gated by `CONTEXT_COLLAPSE`) |
| **TerminalCaptureTool** | Terminal panel capture (gated by `TERMINAL_PANEL`) |
| **CronCreateTool** / **CronDeleteTool** / **CronListTool** | Granular cron job management (under `ScheduleCronTool/`) |
| **SendUserFile** / **PushNotification** / **SubscribePR** | KAIROS-exclusive tools | | **SendUserFile** / **PushNotification** / **SubscribePR** | KAIROS-exclusive tools |
Tools are registered via `getAllBaseTools()` and filtered by feature gates, user type, environment flags, and permission deny rules. There's a **tool schema cache** ([`toolSchemaCache.ts`](https://github.com/kuberwastaken/claude-code/blob/main/src-rust/crates/tools/src/lib.rs)) that caches JSON schemas for prompt efficiency. Tools are registered via `getAllBaseTools()` and filtered by feature gates, user type, environment flags, and permission deny rules. There's a **tool schema cache** ([`toolSchemaCache.ts`](https://github.com/kuberwastaken/claude-code/blob/main/src-rust/crates/tools/src/lib.rs)) that caches JSON schemas for prompt efficiency.
@ -401,6 +409,33 @@ The [`constants/betas.ts`](https://github.com/kuberwastaken/claude-code/blob/mai
--- ---
## Upcoming Models - Capybara, Opus 4.7, and Sonnet 4.8
The codebase contains references to unreleased Anthropic models that haven't been publicly announced:
- **Claude "Capybara"** - A new model family already in version 2, with a variant called `capybara-v2-fast` being prepared with a **1M context window**
- **Capybara comes in "fast" and regular thinking** tiers
- **Opus 4.7** and **Sonnet 4.8** are already referenced within the code
### Production Engineering Around Capybara
The code reveals that Anthropic observed a **real production failure mode**: Capybara can prematurely stop generating when the prompt shape resembles a turn boundary after tool results. Rather than waiting for a model fix, they mitigated it with **prompt-shape surgery**:
1. **Force a safe boundary marker** (`Tool loaded.`) to prevent ambiguous turn boundaries
2. **Relocate risky sibling blocks** that could trigger premature stops
3. **Smoosh reminder text into tool results** to maintain generation flow
4. **Add non-empty markers for empty tool outputs** to avoid confusing the model
All of this is wrapped with **kill-switchable gates** (`tengu_*` prefixed flags) so rollout can be staged and reverted quickly.
- Comments include **concrete A/B test evidence** (not hand-wavy), which typically means this area was **launch-critical** and closely monitored
- Comments like *"un-gate once validated on external via A/B"* confirm that **ant/internal users are canary lanes** before broader rollout
- The strongest interpretation: Anthropic is working toward a **Capybara model family** with a fast-tier variant (`capybara-v2-fast`), supporting up to **1M context**
Nothing confirms a launch date or official SKU naming, but the implementation signatures fit a model family that is actively being prepared for release ;)
---
## Feature Gating - Internal vs. External Builds ## Feature Gating - Internal vs. External Builds
This is one of the most architecturally interesting parts of the codebase. This is one of the most architecturally interesting parts of the codebase.

View file

@ -151,10 +151,6 @@ struct Cli {
#[arg(long = "dangerously-skip-permissions", action = ArgAction::SetTrue)] #[arg(long = "dangerously-skip-permissions", action = ArgAction::SetTrue)]
dangerously_skip_permissions: bool, dangerously_skip_permissions: bool,
/// Show version and exit
#[arg(long = "version", short = 'V', action = ArgAction::SetTrue)]
version_flag: bool,
/// Dump the system prompt to stdout and exit /// Dump the system prompt to stdout and exit
#[arg(long = "dump-system-prompt", action = ArgAction::SetTrue, hide = true)] #[arg(long = "dump-system-prompt", action = ArgAction::SetTrue, hide = true)]
dump_system_prompt: bool, dump_system_prompt: bool,
@ -449,6 +445,8 @@ async fn main() -> anyhow::Result<()> {
max_turns: cli.max_turns, max_turns: cli.max_turns,
system_prompt: Some(system_prompt), system_prompt: Some(system_prompt),
append_system_prompt: None, append_system_prompt: None,
output_style: config.effective_output_style(),
working_directory: Some(cwd.display().to_string()),
thinking_budget: None, thinking_budget: None,
temperature: None, temperature: None,
}; };
@ -805,6 +803,11 @@ async fn run_interactive(
app.status_message = app.status_message =
Some("Configuration updated.".to_string()); Some("Configuration updated.".to_string());
} }
Some(CommandResult::ConfigChangeMessage(new_cfg, msg)) => {
cmd_ctx.config = new_cfg.clone();
app.config = new_cfg;
app.status_message = Some(msg);
}
Some(CommandResult::UserMessage(msg)) => { Some(CommandResult::UserMessage(msg)) => {
// Inject as user turn // Inject as user turn
messages.push(cc_core::types::Message::user(msg.clone())); messages.push(cc_core::types::Message::user(msg.clone()));

View file

@ -5,9 +5,10 @@
// Each command is a struct implementing the `SlashCommand` trait. // Each command is a struct implementing the `SlashCommand` trait.
use async_trait::async_trait; use async_trait::async_trait;
use cc_core::config::Config; use cc_core::config::{Config, Settings, Theme};
use cc_core::cost::CostTracker; use cc_core::cost::CostTracker;
use cc_core::types::Message; use cc_core::types::Message;
use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
#[allow(unused_imports)] #[allow(unused_imports)]
use std::path::PathBuf; use std::path::PathBuf;
@ -34,6 +35,8 @@ pub enum CommandResult {
UserMessage(String), UserMessage(String),
/// Modify the configuration. /// Modify the configuration.
ConfigChange(Config), ConfigChange(Config),
/// Modify the configuration and show a specific status message.
ConfigChangeMessage(Config, String),
/// Clear the conversation. /// Clear the conversation.
ClearConversation, ClearConversation,
/// Replace the conversation with a specific message list (used by /rewind). /// Replace the conversation with a specific message list (used by /rewind).
@ -111,6 +114,141 @@ pub struct RenameCommand;
pub struct EffortCommand; pub struct EffortCommand;
pub struct SummaryCommand; pub struct SummaryCommand;
pub struct CommitCommand; pub struct CommitCommand;
pub struct ThemeCommand;
pub struct OutputStyleCommand;
pub struct KeybindingsCommand;
pub struct PrivacySettingsCommand;
#[derive(serde::Serialize)]
struct KeybindingTemplateFile {
#[serde(rename = "$schema")]
schema: &'static str,
#[serde(rename = "$docs")]
docs: &'static str,
bindings: Vec<KeybindingTemplateBlock>,
}
#[derive(serde::Serialize)]
struct KeybindingTemplateBlock {
context: String,
bindings: BTreeMap<String, Option<String>>,
}
fn save_settings_mutation<F>(mutate: F) -> anyhow::Result<()>
where
F: FnOnce(&mut Settings),
{
let mut settings = Settings::load_sync()?;
mutate(&mut settings);
settings.save_sync()
}
fn open_with_system(target: &str) -> std::io::Result<()> {
#[cfg(target_os = "windows")]
{
let ps_cmd = format!("Start-Process '{}'", target.replace('\'', "''"));
std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
return Ok(());
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(target)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
return Ok(());
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
std::process::Command::new("xdg-open")
.arg(target)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
Ok(())
}
}
fn format_keystroke(keystroke: &cc_core::keybindings::ParsedKeystroke) -> String {
let mut parts = Vec::new();
if keystroke.ctrl {
parts.push("ctrl".to_string());
}
if keystroke.alt {
parts.push("alt".to_string());
}
if keystroke.shift {
parts.push("shift".to_string());
}
if keystroke.meta {
parts.push("meta".to_string());
}
parts.push(match keystroke.key.as_str() {
"space" => "space".to_string(),
other => other.to_string(),
});
parts.join("+")
}
fn format_chord(chord: &[cc_core::keybindings::ParsedKeystroke]) -> String {
chord
.iter()
.map(format_keystroke)
.collect::<Vec<_>>()
.join(" ")
}
fn generate_keybindings_template() -> anyhow::Result<String> {
let mut grouped: BTreeMap<String, BTreeMap<String, Option<String>>> = BTreeMap::new();
for binding in cc_core::keybindings::default_bindings() {
let chord = format_chord(&binding.chord);
if cc_core::keybindings::NON_REBINDABLE.contains(&chord.as_str()) {
continue;
}
grouped
.entry(format!("{:?}", binding.context))
.or_default()
.insert(chord, binding.action.clone());
}
let template = KeybindingTemplateFile {
schema: "https://www.schemastore.org/claude-code-keybindings.json",
docs: "https://code.claude.com/docs/en/keybindings",
bindings: grouped
.into_iter()
.map(|(context, bindings)| KeybindingTemplateBlock { context, bindings })
.collect(),
};
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&template)?
))
}
fn parse_theme(name: &str) -> Option<Theme> {
match name.trim().to_lowercase().as_str() {
"default" | "system" => Some(Theme::Default),
"dark" => Some(Theme::Dark),
"light" => Some(Theme::Light),
custom if !custom.is_empty() => Some(Theme::Custom(custom.to_string())),
_ => None,
}
}
fn current_output_style_name(config: &Config) -> &str {
config.output_style.as_deref().unwrap_or("default")
}
// ---- /help --------------------------------------------------------------- // ---- /help ---------------------------------------------------------------
@ -262,9 +400,311 @@ impl SlashCommand for ConfigCommand {
fn name(&self) -> &str { "config" } fn name(&self) -> &str { "config" }
fn description(&self) -> &str { "Show or modify configuration settings" } fn description(&self) -> &str { "Show or modify configuration settings" }
async fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult { async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
let args = args.trim();
if args.is_empty() || matches!(args, "show" | "get") {
let json = serde_json::to_string_pretty(&ctx.config).unwrap_or_default(); let json = serde_json::to_string_pretty(&ctx.config).unwrap_or_default();
CommandResult::Message(format!("Current configuration:\n{}", json)) return CommandResult::Message(format!(
"Current configuration:\n{}\n\nUsage:\n /config\n /config set theme <default|dark|light>\n /config set output-style <default|concise|explanatory|learning|formal|casual>\n /config set model <model>\n /config set permission-mode <default|accept-edits|bypass-permissions|plan>\n /config unset <model|output-style>",
json
));
}
if let Some(key) = args.strip_prefix("get ").map(str::trim) {
return match key {
"theme" => CommandResult::Message(format!("theme = {:?}", ctx.config.theme)),
"output-style" | "output_style" => CommandResult::Message(format!(
"output-style = {}",
current_output_style_name(&ctx.config)
)),
"model" => CommandResult::Message(format!(
"model = {}",
ctx.config.effective_model()
)),
"permission-mode" | "permission_mode" => CommandResult::Message(format!(
"permission-mode = {:?}",
ctx.config.permission_mode
)),
other => CommandResult::Error(format!("Unknown config key '{}'", other)),
};
}
if let Some(key) = args.strip_prefix("unset ").map(str::trim) {
return match key {
"model" => {
let mut new_config = ctx.config.clone();
new_config.model = None;
if let Err(err) = save_settings_mutation(|settings| settings.config.model = None)
{
return CommandResult::Error(format!(
"Failed to save configuration: {}",
err
));
}
CommandResult::ConfigChangeMessage(
new_config,
"Model reset to the default for new sessions.".to_string(),
)
}
"output-style" | "output_style" => {
let mut new_config = ctx.config.clone();
new_config.output_style = None;
if let Err(err) =
save_settings_mutation(|settings| settings.config.output_style = None)
{
return CommandResult::Error(format!(
"Failed to save configuration: {}",
err
));
}
CommandResult::ConfigChangeMessage(
new_config,
"Output style reset to default.".to_string(),
)
}
other => CommandResult::Error(format!("Unknown config key '{}'", other)),
};
}
let mut parts = args.splitn(3, ' ');
let command = parts.next().unwrap_or_default();
let key = parts.next().unwrap_or_default().trim();
let value = parts.next().unwrap_or_default().trim();
if command != "set" || key.is_empty() || value.is_empty() {
return CommandResult::Error("Usage: /config set <key> <value>".to_string());
}
match key {
"theme" => {
let Some(theme) = parse_theme(value) else {
return CommandResult::Error(
"Theme must be one of: default, dark, light".to_string(),
);
};
let mut new_config = ctx.config.clone();
new_config.theme = theme.clone();
if let Err(err) =
save_settings_mutation(|settings| settings.config.theme = theme.clone())
{
return CommandResult::Error(format!("Failed to save configuration: {}", err));
}
CommandResult::ConfigChangeMessage(
new_config,
format!("Theme set to {}.", value.trim().to_lowercase()),
)
}
"output-style" | "output_style" => {
let normalized = value.trim().to_lowercase();
let valid = ["default", "concise", "explanatory", "learning", "formal", "casual"];
if !valid.contains(&normalized.as_str()) {
return CommandResult::Error(format!(
"Unsupported output style '{}'. Use one of: {}",
value,
valid.join(", ")
));
}
let mut new_config = ctx.config.clone();
new_config.output_style =
(normalized != "default").then(|| normalized.clone());
if let Err(err) = save_settings_mutation(|settings| {
settings.config.output_style =
(normalized != "default").then(|| normalized.clone());
}) {
return CommandResult::Error(format!("Failed to save configuration: {}", err));
}
CommandResult::ConfigChangeMessage(
new_config,
format!(
"Output style set to {}. Changes take effect on the next request.",
normalized
),
)
}
"model" => {
let mut new_config = ctx.config.clone();
new_config.model = Some(value.to_string());
if let Err(err) = save_settings_mutation(|settings| {
settings.config.model = Some(value.to_string());
}) {
return CommandResult::Error(format!("Failed to save configuration: {}", err));
}
CommandResult::ConfigChangeMessage(
new_config,
format!("Model set to {}.", value),
)
}
"permission-mode" | "permission_mode" => {
let mode = match value.trim().to_lowercase().as_str() {
"default" => cc_core::config::PermissionMode::Default,
"accept-edits" | "accept_edits" => {
cc_core::config::PermissionMode::AcceptEdits
}
"bypass-permissions" | "bypass_permissions" => {
cc_core::config::PermissionMode::BypassPermissions
}
"plan" => cc_core::config::PermissionMode::Plan,
_ => {
return CommandResult::Error(
"Permission mode must be one of: default, accept-edits, bypass-permissions, plan"
.to_string(),
)
}
};
let mut new_config = ctx.config.clone();
new_config.permission_mode = mode.clone();
if let Err(err) = save_settings_mutation(|settings| {
settings.config.permission_mode = mode.clone();
}) {
return CommandResult::Error(format!("Failed to save configuration: {}", err));
}
CommandResult::ConfigChangeMessage(
new_config,
format!("Permission mode set to {}.", value.trim().to_lowercase()),
)
}
other => CommandResult::Error(format!("Unknown config key '{}'", other)),
}
}
}
// ---- /theme --------------------------------------------------------------
#[async_trait]
impl SlashCommand for ThemeCommand {
fn name(&self) -> &str { "theme" }
fn aliases(&self) -> Vec<&str> { vec!["color"] }
fn description(&self) -> &str { "Show or change the current theme" }
fn help(&self) -> &str {
"Usage: /theme [default|dark|light]\n\
Without arguments, shows the active theme. With an argument, updates the theme for this and future sessions."
}
async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
let args = args.trim();
if args.is_empty() {
return CommandResult::Message(format!(
"Current theme: {:?}\nUse /theme <default|dark|light> to change it.",
ctx.config.theme
));
}
let Some(theme) = parse_theme(args) else {
return CommandResult::Error(
"Theme must be one of: default, dark, light".to_string(),
);
};
let mut new_config = ctx.config.clone();
new_config.theme = theme.clone();
if let Err(err) = save_settings_mutation(|settings| settings.config.theme = theme.clone())
{
return CommandResult::Error(format!("Failed to save theme: {}", err));
}
CommandResult::ConfigChangeMessage(
new_config,
format!("Theme set to {}.", args.to_lowercase()),
)
}
}
// ---- /output-style -------------------------------------------------------
#[async_trait]
impl SlashCommand for OutputStyleCommand {
fn name(&self) -> &str { "output-style" }
fn description(&self) -> &str { "Show the output-style migration guidance" }
async fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
CommandResult::Message(format!(
"/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.\nCurrent output style: {}",
current_output_style_name(&ctx.config)
))
}
}
// ---- /keybindings --------------------------------------------------------
#[async_trait]
impl SlashCommand for KeybindingsCommand {
fn name(&self) -> &str { "keybindings" }
fn description(&self) -> &str { "Create or open ~/.claude/keybindings.json" }
async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult {
let config_dir = Settings::config_dir();
let path = config_dir.join("keybindings.json");
let existed = path.exists();
if !existed {
if let Err(err) = std::fs::create_dir_all(&config_dir) {
return CommandResult::Error(format!(
"Failed to create {}: {}",
config_dir.display(),
err
));
}
let template = match generate_keybindings_template() {
Ok(template) => template,
Err(err) => {
return CommandResult::Error(format!(
"Failed to generate keybindings template: {}",
err
))
}
};
if let Err(err) = std::fs::write(&path, template) {
return CommandResult::Error(format!(
"Failed to write {}: {}",
path.display(),
err
));
}
}
match open_with_system(&path.display().to_string()) {
Ok(_) => CommandResult::Message(if existed {
format!("Opened {} in your editor.", path.display())
} else {
format!(
"Created {} with a template and opened it in your editor.",
path.display()
)
}),
Err(err) => CommandResult::Message(if existed {
format!(
"Opened {}. Could not launch an editor automatically: {}",
path.display(),
err
)
} else {
format!(
"Created {} with a template. Could not launch an editor automatically: {}",
path.display(),
err
)
}),
}
}
}
// ---- /privacy-settings ---------------------------------------------------
#[async_trait]
impl SlashCommand for PrivacySettingsCommand {
fn name(&self) -> &str { "privacy-settings" }
fn description(&self) -> &str { "Open Claude privacy settings" }
async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult {
let url = "https://claude.ai/settings/data-privacy-controls";
let fallback = format!("Review and manage your privacy settings at {}", url);
match open_with_system(url) {
Ok(_) => CommandResult::Message(format!("Opened privacy settings: {}", url)),
Err(_) => CommandResult::Message(fallback),
}
} }
} }
@ -1086,6 +1526,10 @@ pub fn all_commands() -> Vec<Box<dyn SlashCommand>> {
Box::new(TasksCommand), Box::new(TasksCommand),
Box::new(SessionCommand), Box::new(SessionCommand),
Box::new(ThinkingCommand), Box::new(ThinkingCommand),
Box::new(ThemeCommand),
Box::new(OutputStyleCommand),
Box::new(KeybindingsCommand),
Box::new(PrivacySettingsCommand),
// New commands // New commands
Box::new(ExportCommand), Box::new(ExportCommand),
Box::new(SkillsCommand), Box::new(SkillsCommand),

View file

@ -128,10 +128,28 @@ impl NamedCommand for AddDirCommand {
Err(e) => return CommandResult::Error(format!("Cannot resolve path: {e}")), Err(e) => return CommandResult::Error(format!("Cannot resolve path: {e}")),
}; };
// TODO: persist to settings.json `workspacePaths` array let mut settings = match cc_core::config::Settings::load_sync() {
Ok(s) => s,
Err(e) => {
return CommandResult::Error(format!(
"Failed to load settings before updating workspace paths: {e}"
))
}
};
if !settings.config.workspace_paths.iter().any(|p| p == &abs_path) {
settings.config.workspace_paths.push(abs_path.clone());
if let Err(e) = settings.save_sync() {
return CommandResult::Error(format!(
"Added {} for this session, but failed to save settings: {}",
abs_path.display(),
e
));
}
}
CommandResult::Message(format!( CommandResult::Message(format!(
"Added {} to allowed workspace paths.\n\ "Added {} to allowed workspace paths.",
Note: restart Claude Code for the change to take effect.",
abs_path.display() abs_path.display()
)) ))
} }

View file

@ -57,6 +57,9 @@ pub fn parse_keystroke(s: &str) -> Option<ParsedKeystroke> {
for part in s.split('+') { for part in s.split('+') {
let part = part.trim(); let part = part.trim();
if part.is_empty() {
continue;
}
match part { match part {
"ctrl" | "control" => ctrl = true, "ctrl" | "control" => ctrl = true,
"alt" | "opt" | "option" => alt = true, "alt" | "opt" | "option" => alt = true,
@ -164,6 +167,18 @@ pub struct UserKeybindings {
pub bindings: Vec<UserBinding>, pub bindings: Vec<UserBinding>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct JsonKeybindingConfig {
#[serde(default)]
bindings: Vec<JsonKeybindingBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct JsonKeybindingBlock {
context: String,
bindings: HashMap<String, Option<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserBinding { pub struct UserBinding {
pub chord: String, // e.g. "ctrl+k ctrl+d" pub chord: String, // e.g. "ctrl+k ctrl+d"
@ -172,10 +187,16 @@ pub struct UserBinding {
} }
impl UserKeybindings { impl UserKeybindings {
pub fn from_json_str(content: &str) -> Self {
serde_json::from_str(content)
.or_else(|_| Self::from_block_config(content))
.unwrap_or_default()
}
pub fn load(config_dir: &Path) -> Self { pub fn load(config_dir: &Path) -> Self {
let path = config_dir.join("keybindings.json"); let path = config_dir.join("keybindings.json");
if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(content) = std::fs::read_to_string(&path) {
serde_json::from_str(&content).unwrap_or_default() Self::from_json_str(&content)
} else { } else {
Self::default() Self::default()
} }
@ -187,6 +208,23 @@ impl UserKeybindings {
std::fs::write(path, json)?; std::fs::write(path, json)?;
Ok(()) Ok(())
} }
fn from_block_config(content: &str) -> Result<Self, serde_json::Error> {
let config: JsonKeybindingConfig = serde_json::from_str(content)?;
let bindings = config
.bindings
.into_iter()
.flat_map(|block| {
let context = block.context;
block.bindings.into_iter().map(move |(chord, action)| UserBinding {
chord,
action,
context: Some(context.clone()),
})
})
.collect();
Ok(Self { bindings })
}
} }
/// Resolved keybindings (defaults merged with user overrides) /// Resolved keybindings (defaults merged with user overrides)
@ -420,4 +458,28 @@ mod tests {
let user = UserKeybindings::default(); let user = UserKeybindings::default();
assert!(user.bindings.is_empty()); assert!(user.bindings.is_empty());
} }
#[test]
fn test_user_keybindings_supports_ts_block_format() {
let user = UserKeybindings::from_json_str(
r#"{
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+g": "chat:externalEditor",
"space": null
}
}
]
}"#,
);
assert_eq!(user.bindings.len(), 2);
assert_eq!(user.bindings[0].context.as_deref(), Some("Chat"));
assert_eq!(user.bindings[0].chord, "ctrl+g");
assert_eq!(user.bindings[0].action.as_deref(), Some("chat:externalEditor"));
assert_eq!(user.bindings[1].chord, "space");
assert_eq!(user.bindings[1].action, None);
}
} }

View file

@ -411,6 +411,8 @@ pub mod config {
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
pub permission_mode: PermissionMode, pub permission_mode: PermissionMode,
pub theme: Theme, pub theme: Theme,
#[serde(default)]
pub output_style: Option<String>,
pub auto_compact: bool, pub auto_compact: bool,
pub compact_threshold: f32, pub compact_threshold: f32,
pub verbose: bool, pub verbose: bool,
@ -424,6 +426,8 @@ pub mod config {
pub append_system_prompt: Option<String>, pub append_system_prompt: Option<String>,
pub disable_claude_mds: bool, pub disable_claude_mds: bool,
pub project_dir: Option<PathBuf>, pub project_dir: Option<PathBuf>,
#[serde(default)]
pub workspace_paths: Vec<PathBuf>,
/// Event hooks: map of event → list of hook commands. /// Event hooks: map of event → list of hook commands.
#[serde(default)] #[serde(default)]
pub hooks: HashMap<HookEvent, Vec<HookEntry>>, pub hooks: HashMap<HookEvent, Vec<HookEntry>>,
@ -518,6 +522,14 @@ pub mod config {
} }
} }
/// Resolve the effective output style for system-prompt assembly.
pub fn effective_output_style(&self) -> crate::system_prompt::OutputStyle {
self.output_style
.as_deref()
.map(crate::system_prompt::OutputStyle::from_str)
.unwrap_or_default()
}
/// Resolve the API key from the config, then from `ANTHROPIC_API_KEY`. /// Resolve the API key from the config, then from `ANTHROPIC_API_KEY`.
pub fn resolve_api_key(&self) -> Option<String> { pub fn resolve_api_key(&self) -> Option<String> {
self.api_key self.api_key
@ -633,6 +645,28 @@ pub mod config {
tokio::fs::write(&path, content).await?; tokio::fs::write(&path, content).await?;
Ok(()) Ok(())
} }
/// Synchronous variant used by pre-session commands.
pub fn load_sync() -> anyhow::Result<Self> {
let path = Self::global_settings_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content).unwrap_or_default())
} else {
Ok(Self::default())
}
}
/// Synchronous variant used by pre-session commands.
pub fn save_sync(&self) -> anyhow::Result<()> {
let path = Self::global_settings_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
} }
} }

View file

@ -218,7 +218,7 @@ pub fn format_memory_manifest(memories: &[MemoryFileMeta]) -> String {
match &m.description { match &m.description {
Some(desc) => format!("- {}{} ({}): {}", tag, m.filename, ts, desc), Some(desc) => format!("- {}{} ({}): {}", tag, m.filename, ts, desc),
None => format!("- {}{} ({})", tag, m.filename, ts), None => format!("- {}{}", tag, m.filename),
} }
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -361,8 +361,7 @@ pub fn auto_memory_path(project_root: &Path) -> PathBuf {
/// Sanitize an arbitrary string into a directory-name-safe component. /// Sanitize an arbitrary string into a directory-name-safe component.
/// Matches `sanitizePath` used inside `getAutoMemPath` in `paths.ts`. /// Matches `sanitizePath` used inside `getAutoMemPath` in `paths.ts`.
pub fn sanitize_path_component(s: &str) -> String { pub fn sanitize_path_component(s: &str) -> String {
let sanitized: String = s s.chars()
.chars()
.map(|c| { .map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c c
@ -370,8 +369,7 @@ pub fn sanitize_path_component(s: &str) -> String {
'_' '_'
} }
}) })
.collect(); .collect()
sanitized.trim_matches('_').to_string()
} }
/// Whether the auto-memory system is enabled for this session. /// Whether the auto-memory system is enabled for this session.

View file

@ -59,6 +59,8 @@ pub struct QueryConfig {
pub max_turns: u32, pub max_turns: u32,
pub system_prompt: Option<String>, pub system_prompt: Option<String>,
pub append_system_prompt: Option<String>, pub append_system_prompt: Option<String>,
pub output_style: cc_core::system_prompt::OutputStyle,
pub working_directory: Option<String>,
pub thinking_budget: Option<u32>, pub thinking_budget: Option<u32>,
pub temperature: Option<f32>, pub temperature: Option<f32>,
} }
@ -71,6 +73,8 @@ impl Default for QueryConfig {
max_turns: cc_core::constants::MAX_TURNS_DEFAULT, max_turns: cc_core::constants::MAX_TURNS_DEFAULT,
system_prompt: None, system_prompt: None,
append_system_prompt: None, append_system_prompt: None,
output_style: cc_core::system_prompt::OutputStyle::Default,
working_directory: None,
thinking_budget: None, thinking_budget: None,
temperature: None, temperature: None,
} }
@ -82,6 +86,11 @@ impl QueryConfig {
Self { Self {
model: cfg.effective_model().to_string(), model: cfg.effective_model().to_string(),
max_tokens: cfg.effective_max_tokens(), max_tokens: cfg.effective_max_tokens(),
output_style: cfg.effective_output_style(),
working_directory: cfg
.project_dir
.as_ref()
.map(|p| p.display().to_string()),
..Default::default() ..Default::default()
} }
} }
@ -441,12 +450,11 @@ fn build_system_prompt(config: &QueryConfig) -> SystemPrompt {
append_system_prompt: config.append_system_prompt.clone(), append_system_prompt: config.append_system_prompt.clone(),
// All other fields use sensible defaults: // All other fields use sensible defaults:
// - prefix: auto-detect from env // - prefix: auto-detect from env
// - output_style: Default (no suffix)
// - working_directory: None (callers inject via append if needed)
// - memory_content: empty (callers inject via append if needed) // - memory_content: empty (callers inject via append if needed)
// - replace_system_prompt: false (additive mode) // - replace_system_prompt: false (additive mode)
// - coordinator_mode: false // - coordinator_mode: false
output_style: OutputStyle::Default, output_style: config.output_style,
working_directory: config.working_directory.clone(),
..Default::default() ..Default::default()
}; };

View file

@ -549,8 +549,9 @@ mod tests {
fn expand_prompt_suffix_empty() { fn expand_prompt_suffix_empty() {
let skill = find_bundled_skill("stuck").unwrap(); let skill = find_bundled_skill("stuck").unwrap();
let expanded = expand_prompt(skill, ""); let expanded = expand_prompt(skill, "");
// $ARGUMENTS_SUFFIX should be "" (not ": ") // $ARGUMENTS_SUFFIX should expand to "" so "stuck" is not followed by ": "
assert!(!expanded.contains(": ")); assert!(!expanded.contains("stuck: "));
assert!(!expanded.contains("$ARGUMENTS_SUFFIX"));
} }
#[test] #[test]