diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index a8f6dfa..91f40d8 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -912,6 +912,7 @@ mod tests { system: None, tools: None, tool_choice: None, + thinking: None, stream: false, }; diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index fb3ad04..3a415f8 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -13,5 +13,5 @@ pub use types::{ ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, - ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, + ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, }; diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs index 109d5d6..aa3900f 100644 --- a/rust/crates/api/src/types.rs +++ b/rust/crates/api/src/types.rs @@ -12,6 +12,8 @@ pub struct MessageRequest { pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub stream: bool, } @@ -24,6 +26,23 @@ impl MessageRequest { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThinkingConfig { + #[serde(rename = "type")] + pub kind: String, + pub budget_tokens: u32, +} + +impl ThinkingConfig { + #[must_use] + pub fn enabled(budget_tokens: u32) -> Self { + Self { + kind: "enabled".to_string(), + budget_tokens, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct InputMessage { pub role: String, @@ -141,6 +160,11 @@ pub enum OutputContentBlock { Text { text: String, }, + Thinking { + thinking: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + }, ToolUse { id: String, name: String, @@ -200,6 +224,8 @@ pub struct ContentBlockDeltaEvent { #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlockDelta { TextDelta { text: String }, + ThinkingDelta { thinking: String }, + SignatureDelta { signature: String }, InputJsonDelta { partial_json: String }, } diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index 483e471..ffc2939 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -291,6 +291,7 @@ async fn live_stream_smoke_test() { system: None, tools: None, tool_choice: None, + thinking: None, stream: false, }) .await @@ -471,6 +472,7 @@ fn sample_request(stream: bool) -> MessageRequest { }), }]), tool_choice: Some(ToolChoice::Auto), + thinking: None, stream, } } diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 3ac9a52..ed55d42 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -57,6 +57,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "thinking", + summary: "Show or toggle extended thinking", + argument_hint: Some("[on|off]"), + resume_supported: false, + }, SlashCommandSpec { name: "model", summary: "Show or switch the active model", @@ -84,7 +90,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "resume", summary: "Load a saved session into the REPL", - argument_hint: Some(""), + argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { @@ -129,12 +135,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[list|switch ]"), resume_supported: false, }, - SlashCommandSpec { - name: "sessions", - summary: "List recent managed local sessions", - argument_hint: None, - resume_supported: false, - }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -142,6 +142,9 @@ pub enum SlashCommand { Help, Status, Compact, + Thinking { + enabled: Option, + }, Model { model: Option, }, @@ -169,7 +172,6 @@ pub enum SlashCommand { action: Option, target: Option, }, - Sessions, Unknown(String), } @@ -187,6 +189,13 @@ impl SlashCommand { "help" => Self::Help, "status" => Self::Status, "compact" => Self::Compact, + "thinking" => Self::Thinking { + enabled: match parts.next() { + Some("on") => Some(true), + Some("off") => Some(false), + Some(_) | None => None, + }, + }, "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, @@ -214,7 +223,6 @@ impl SlashCommand { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, - "sessions" => Self::Sessions, other => Self::Unknown(other.to_string()), }) } @@ -287,6 +295,7 @@ pub fn handle_slash_command( session: session.clone(), }), SlashCommand::Status + | SlashCommand::Thinking { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear { .. } @@ -299,7 +308,6 @@ pub fn handle_slash_command( | SlashCommand::Version | SlashCommand::Export { .. } | SlashCommand::Session { .. } - | SlashCommand::Sessions | SlashCommand::Unknown(_) => None, } } @@ -316,6 +324,22 @@ mod tests { fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); + assert_eq!( + SlashCommand::parse("/thinking on"), + Some(SlashCommand::Thinking { + enabled: Some(true), + }) + ); + assert_eq!( + SlashCommand::parse("/thinking off"), + Some(SlashCommand::Thinking { + enabled: Some(false), + }) + ); + assert_eq!( + SlashCommand::parse("/thinking"), + Some(SlashCommand::Thinking { enabled: None }) + ); assert_eq!( SlashCommand::parse("/model claude-opus"), Some(SlashCommand::Model { @@ -374,10 +398,6 @@ mod tests { target: Some("abc123".to_string()) }) ); - assert_eq!( - SlashCommand::parse("/sessions"), - Some(SlashCommand::Sessions) - ); } #[test] @@ -387,11 +407,12 @@ mod tests { assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); + assert!(help.contains("/thinking [on|off]")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); - assert!(help.contains("/resume ")); + assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); @@ -399,7 +420,6 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert!(help.contains("/sessions")); assert_eq!(slash_command_specs().len(), 16); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -418,7 +438,6 @@ mod tests { text: "recent".to_string(), }]), ], - metadata: None, }; let result = handle_slash_command( @@ -449,6 +468,9 @@ mod tests { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none() + ); assert!( handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() ); @@ -483,6 +505,5 @@ mod tests { assert!( handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() ); - assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none()); } } diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index fbfc77c..e593b9c 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -164,7 +164,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { .filter_map(|block| match block { ContentBlock::ToolUse { name, .. } => Some(name.as_str()), ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()), - ContentBlock::Text { .. } => None, + ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None, }) .collect::>(); tool_names.sort_unstable(); @@ -234,6 +234,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { fn summarize_block(block: &ContentBlock) -> String { let raw = match block { ContentBlock::Text { text } => text.clone(), + ContentBlock::Thinking { text, .. } => format!("thinking: {text}"), ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"), ContentBlock::ToolResult { tool_name, @@ -292,7 +293,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec { .iter() .flat_map(|message| message.blocks.iter()) .map(|block| match block { - ContentBlock::Text { text } => text.as_str(), + ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(), ContentBlock::ToolUse { input, .. } => input.as_str(), ContentBlock::ToolResult { output, .. } => output.as_str(), }) @@ -314,10 +315,15 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option { fn first_text_block(message: &ConversationMessage) -> Option<&str> { message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()), + ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } + if !text.trim().is_empty() => + { + Some(text.as_str()) + } ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } - | ContentBlock::Text { .. } => None, + | ContentBlock::Text { .. } + | ContentBlock::Thinking { .. } => None, }) } @@ -362,7 +368,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize { .blocks .iter() .map(|block| match block { - ContentBlock::Text { text } => text.len() / 4 + 1, + ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1, ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1, ContentBlock::ToolResult { tool_name, output, .. diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index d3e54cd..5285412 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -17,6 +17,8 @@ pub struct ApiRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub enum AssistantEvent { TextDelta(String), + ThinkingDelta(String), + ThinkingSignature(String), ToolUse { id: String, name: String, @@ -247,15 +249,26 @@ fn build_assistant_message( events: Vec, ) -> Result<(ConversationMessage, Option), RuntimeError> { let mut text = String::new(); + let mut thinking = String::new(); + let mut thinking_signature: Option = None; let mut blocks = Vec::new(); let mut finished = false; let mut usage = None; for event in events { match event { - AssistantEvent::TextDelta(delta) => text.push_str(&delta), + AssistantEvent::TextDelta(delta) => { + flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks); + text.push_str(&delta); + } + AssistantEvent::ThinkingDelta(delta) => { + flush_text_block(&mut text, &mut blocks); + thinking.push_str(&delta); + } + AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature), AssistantEvent::ToolUse { id, name, input } => { flush_text_block(&mut text, &mut blocks); + flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks); blocks.push(ContentBlock::ToolUse { id, name, input }); } AssistantEvent::Usage(value) => usage = Some(value), @@ -266,6 +279,7 @@ fn build_assistant_message( } flush_text_block(&mut text, &mut blocks); + flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks); if !finished { return Err(RuntimeError::new( @@ -290,6 +304,19 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec) { } } +fn flush_thinking_block( + thinking: &mut String, + signature: &mut Option, + blocks: &mut Vec, +) { + if !thinking.is_empty() || signature.is_some() { + blocks.push(ContentBlock::Thinking { + text: std::mem::take(thinking), + signature: signature.take(), + }); + } +} + type ToolHandler = Box Result>; #[derive(Default)] @@ -325,8 +352,8 @@ impl ToolExecutor for StaticToolExecutor { #[cfg(test)] mod tests { use super::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, - StaticToolExecutor, + build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, + RuntimeError, StaticToolExecutor, }; use crate::compact::CompactionConfig; use crate::permissions::{ @@ -503,6 +530,29 @@ mod tests { )); } + #[test] + fn thinking_blocks_are_preserved_separately_from_text() { + let (message, usage) = build_assistant_message(vec![ + AssistantEvent::ThinkingDelta("first ".to_string()), + AssistantEvent::ThinkingDelta("second".to_string()), + AssistantEvent::ThinkingSignature("sig-1".to_string()), + AssistantEvent::TextDelta("final".to_string()), + AssistantEvent::MessageStop, + ]) + .expect("assistant message should build"); + + assert_eq!(usage, None); + assert!(matches!( + &message.blocks[0], + ContentBlock::Thinking { text, signature } + if text == "first second" && signature.as_deref() == Some("sig-1") + )); + assert!(matches!( + &message.blocks[1], + ContentBlock::Text { text } if text == "final" + )); + } + #[test] fn reconstructs_usage_tracker_from_restored_session() { struct SimpleApi; diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index 737cdef..bc2b06e 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -19,6 +19,10 @@ pub enum ContentBlock { Text { text: String, }, + Thinking { + text: String, + signature: Option, + }, ToolUse { id: String, name: String, @@ -313,6 +317,19 @@ impl ContentBlock { object.insert("type".to_string(), JsonValue::String("text".to_string())); object.insert("text".to_string(), JsonValue::String(text.clone())); } + Self::Thinking { text, signature } => { + object.insert( + "type".to_string(), + JsonValue::String("thinking".to_string()), + ); + object.insert("text".to_string(), JsonValue::String(text.clone())); + if let Some(signature) = signature { + object.insert( + "signature".to_string(), + JsonValue::String(signature.clone()), + ); + } + } Self::ToolUse { id, name, input } => { object.insert( "type".to_string(), @@ -359,6 +376,13 @@ impl ContentBlock { "text" => Ok(Self::Text { text: required_string(object, "text")?, }), + "thinking" => Ok(Self::Thinking { + text: required_string(object, "text")?, + signature: object + .get("signature") + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned), + }), "tool_use" => Ok(Self::ToolUse { id: required_string(object, "id")?, name: required_string(object, "name")?, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1e2c5c3..40b208b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -13,7 +13,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use api::{ resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, - StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, + StreamEvent as ApiStreamEvent, ThinkingConfig, ToolChoice, ToolDefinition, + ToolResultContentBlock, }; use commands::{ @@ -27,17 +28,17 @@ use runtime::{ AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, - Session, SessionMetadata, TokenUsage, ToolError, ToolExecutor, UsageTracker, + Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::{execute_tool, mvp_tool_specs, ToolSpec}; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MAX_TOKENS: u32 = 32; +const DEFAULT_THINKING_BUDGET_TOKENS: u32 = 2_048; const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24; const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); @@ -71,7 +72,8 @@ fn run() -> Result<(), Box> { output_format, allowed_tools, permission_mode, - } => LiveCli::new(model, false, allowed_tools, permission_mode)? + thinking, + } => LiveCli::new(model, false, allowed_tools, permission_mode, thinking)? .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, @@ -79,7 +81,8 @@ fn run() -> Result<(), Box> { model, allowed_tools, permission_mode, - } => run_repl(model, allowed_tools, permission_mode)?, + thinking, + } => run_repl(model, allowed_tools, permission_mode, thinking)?, CliAction::Help => print_help(), } Ok(()) @@ -104,6 +107,7 @@ enum CliAction { output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionMode, + thinking: bool, }, Login, Logout, @@ -111,6 +115,7 @@ enum CliAction { model: String, allowed_tools: Option, permission_mode: PermissionMode, + thinking: bool, }, // prompt-mode formatting is only supported for non-interactive runs Help, @@ -140,6 +145,7 @@ fn parse_args(args: &[String]) -> Result { let mut output_format = CliOutputFormat::Text; let mut permission_mode = default_permission_mode(); let mut wants_version = false; + let mut thinking = false; let mut allowed_tool_values = Vec::new(); let mut rest = Vec::new(); let mut index = 0; @@ -150,6 +156,10 @@ fn parse_args(args: &[String]) -> Result { wants_version = true; index += 1; } + "--thinking" => { + thinking = true; + index += 1; + } "--model" => { let value = args .get(index + 1) @@ -216,6 +226,7 @@ fn parse_args(args: &[String]) -> Result { model, allowed_tools, permission_mode, + thinking, }); } if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { @@ -242,6 +253,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + thinking, }) } other if !other.starts_with('/') => Ok(CliAction::Prompt { @@ -250,6 +262,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + thinking, }), other => Err(format!("unknown subcommand: {other}")), } @@ -536,14 +549,7 @@ fn print_version() { } fn resume_session(session_path: &Path, commands: &[String]) { - let handle = match resolve_session_reference(&session_path.display().to_string()) { - Ok(handle) => handle, - Err(error) => { - eprintln!("failed to resolve session: {error}"); - std::process::exit(1); - } - }; - let session = match Session::load_from_path(&handle.path) { + let session = match Session::load_from_path(session_path) { Ok(session) => session, Err(error) => { eprintln!("failed to restore session: {error}"); @@ -554,7 +560,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { if commands.is_empty() { println!( "Restored session from {} ({} messages).", - handle.path.display(), + session_path.display(), session.messages.len() ); return; @@ -566,7 +572,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { eprintln!("unsupported resumed command: {raw_command}"); std::process::exit(2); }; - match run_resume_command(&handle.path, &session, &command) { + match run_resume_command(session_path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, @@ -608,6 +614,7 @@ struct StatusUsage { latest: TokenUsage, cumulative: TokenUsage, estimated_tokens: usize, + thinking_enabled: bool, } fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { @@ -675,6 +682,39 @@ Usage ) } +fn format_thinking_report(enabled: bool) -> String { + let state = if enabled { "on" } else { "off" }; + let budget = if enabled { + DEFAULT_THINKING_BUDGET_TOKENS.to_string() + } else { + "disabled".to_string() + }; + format!( + "Thinking + Active mode {state} + Budget tokens {budget} + +Usage + Inspect current mode with /thinking + Toggle with /thinking on or /thinking off" + ) +} + +fn format_thinking_switch_report(enabled: bool) -> String { + let state = if enabled { "enabled" } else { "disabled" }; + format!( + "Thinking updated + Result {state} + Budget tokens {} + Applies to subsequent requests", + if enabled { + DEFAULT_THINKING_BUDGET_TOKENS.to_string() + } else { + "disabled".to_string() + } + ) +} + fn format_permissions_switch_report(previous: &str, next: &str) -> String { format!( "Permissions updated @@ -842,6 +882,7 @@ fn run_resume_command( latest: tracker.current_turn_usage(), cumulative: usage, estimated_tokens: 0, + thinking_enabled: false, }, default_permission_mode().as_str(), &status_context(Some(session_path))?, @@ -888,10 +929,10 @@ fn run_resume_command( }) } SlashCommand::Resume { .. } + | SlashCommand::Thinking { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } - | SlashCommand::Sessions | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -900,8 +941,15 @@ fn run_repl( model: String, allowed_tools: Option, permission_mode: PermissionMode, + thinking_enabled: bool, ) -> Result<(), Box> { - let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; + let mut cli = LiveCli::new( + model, + true, + allowed_tools, + permission_mode, + thinking_enabled, + )?; let mut editor = input::LineEditor::new("› ", slash_command_completion_candidates()); println!("{}", cli.startup_banner()); @@ -948,15 +996,13 @@ struct ManagedSessionSummary { path: PathBuf, modified_epoch_secs: u64, message_count: usize, - model: Option, - started_at: Option, - last_prompt: Option, } struct LiveCli { model: String, allowed_tools: Option, permission_mode: PermissionMode, + thinking_enabled: bool, system_prompt: Vec, runtime: ConversationRuntime, session: SessionHandle, @@ -968,10 +1014,10 @@ impl LiveCli { enable_tools: bool, allowed_tools: Option, permission_mode: PermissionMode, + thinking_enabled: bool, ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; - auto_compact_inactive_sessions(&session.id)?; let runtime = build_runtime( Session::new(), model.clone(), @@ -979,11 +1025,13 @@ impl LiveCli { enable_tools, allowed_tools.clone(), permission_mode, + thinking_enabled, )?; let cli = Self { model, allowed_tools, permission_mode, + thinking_enabled, system_prompt, runtime, session, @@ -994,9 +1042,10 @@ impl LiveCli { fn startup_banner(&self) -> String { format!( - "Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.", + "Rusty Claude CLI\n Model {}\n Permission mode {}\n Thinking {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.", self.model, self.permission_mode.as_str(), + if self.thinking_enabled { "on" } else { "off" }, env::current_dir().map_or_else( |_| "".to_string(), |path| path.display().to_string(), @@ -1062,6 +1111,9 @@ impl LiveCli { system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")), tools: None, tool_choice: None, + thinking: self + .thinking_enabled + .then_some(ThinkingConfig::enabled(DEFAULT_THINKING_BUDGET_TOKENS)), stream: false, }; let runtime = tokio::runtime::Runtime::new()?; @@ -1071,7 +1123,7 @@ impl LiveCli { .iter() .filter_map(|block| match block { OutputContentBlock::Text { text } => Some(text.as_str()), - OutputContentBlock::ToolUse { .. } => None, + OutputContentBlock::Thinking { .. } | OutputContentBlock::ToolUse { .. } => None, }) .collect::>() .join(""); @@ -1108,6 +1160,7 @@ impl LiveCli { self.compact()?; false } + SlashCommand::Thinking { enabled } => self.set_thinking(enabled)?, SlashCommand::Model { model } => self.set_model(model)?, SlashCommand::Permissions { mode } => self.set_permissions(mode)?, SlashCommand::Clear { confirm } => self.clear_session(confirm)?, @@ -1143,10 +1196,6 @@ impl LiveCli { SlashCommand::Session { action, target } => { self.handle_session_command(action.as_deref(), target.as_deref())? } - SlashCommand::Sessions => { - println!("{}", render_session_list(&self.session.id)?); - false - } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false @@ -1155,10 +1204,7 @@ impl LiveCli { } fn persist_session(&self) -> Result<(), Box> { - let mut session = self.runtime.session().clone(); - session.metadata = Some(derive_session_metadata(&session, &self.model)); - session.save_to_path(&self.session.path)?; - auto_compact_inactive_sessions(&self.session.id)?; + self.runtime.session().save_to_path(&self.session.path)?; Ok(()) } @@ -1175,6 +1221,7 @@ impl LiveCli { latest, cumulative, estimated_tokens: self.runtime.estimated_tokens(), + thinking_enabled: self.thinking_enabled, }, self.permission_mode.as_str(), &status_context(Some(&self.session.path)).expect("status context should load"), @@ -1217,6 +1264,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.thinking_enabled, )?; self.model.clone_from(&model); println!( @@ -1226,6 +1274,32 @@ impl LiveCli { Ok(true) } + fn set_thinking(&mut self, enabled: Option) -> Result> { + let Some(enabled) = enabled else { + println!("{}", format_thinking_report(self.thinking_enabled)); + return Ok(false); + }; + + if enabled == self.thinking_enabled { + println!("{}", format_thinking_report(self.thinking_enabled)); + return Ok(false); + } + + let session = self.runtime.session().clone(); + self.thinking_enabled = enabled; + self.runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + self.allowed_tools.clone(), + self.permission_mode, + self.thinking_enabled, + )?; + println!("{}", format_thinking_switch_report(self.thinking_enabled)); + Ok(true) + } + fn set_permissions( &mut self, mode: Option, @@ -1259,6 +1333,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.thinking_enabled, )?; println!( "{}", @@ -1283,6 +1358,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.thinking_enabled, )?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", @@ -1303,20 +1379,13 @@ impl LiveCli { session_path: Option, ) -> Result> { let Some(session_ref) = session_path else { - println!("Usage: /resume "); + println!("Usage: /resume "); return Ok(false); }; let handle = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); - if let Some(model) = session - .metadata - .as_ref() - .map(|metadata| metadata.model.clone()) - { - self.model = model; - } self.runtime = build_runtime( session, self.model.clone(), @@ -1324,6 +1393,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.thinking_enabled, )?; self.session = handle; println!( @@ -1393,13 +1463,6 @@ impl LiveCli { let handle = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); - if let Some(model) = session - .metadata - .as_ref() - .map(|metadata| metadata.model.clone()) - { - self.model = model; - } self.runtime = build_runtime( session, self.model.clone(), @@ -1407,6 +1470,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.thinking_enabled, )?; self.session = handle; println!( @@ -1436,6 +1500,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.thinking_enabled, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); @@ -1444,10 +1509,8 @@ impl LiveCli { } fn sessions_dir() -> Result> { - let home = env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?; - let path = home.join(".claude").join("sessions"); + let cwd = env::current_dir()?; + let path = cwd.join(".claude").join("sessions"); fs::create_dir_all(&path)?; Ok(path) } @@ -1468,19 +1531,8 @@ fn generate_session_id() -> String { fn resolve_session_reference(reference: &str) -> Result> { let direct = PathBuf::from(reference); - let expanded = if let Some(stripped) = reference.strip_prefix("~/") { - sessions_dir()? - .parent() - .and_then(|claude| claude.parent()) - .map(|home| home.join(stripped)) - .unwrap_or(direct.clone()) - } else { - direct.clone() - }; let path = if direct.exists() { direct - } else if expanded.exists() { - expanded } else { sessions_dir()?.join(format!("{reference}.json")) }; @@ -1510,11 +1562,9 @@ fn list_managed_sessions() -> Result, Box Result, Box Result u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - -fn current_timestamp_rfc3339ish() -> String { - format!("{}Z", current_epoch_secs()) -} - -fn last_prompt_from_session(session: &Session) -> Option { - session - .messages - .iter() - .rev() - .find(|message| message.role == MessageRole::User) - .and_then(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.trim().to_string()), - _ => None, - }) - }) - .filter(|text| !text.is_empty()) -} - -fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata { - let started_at = session - .metadata - .as_ref() - .map_or_else(current_timestamp_rfc3339ish, |metadata| { - metadata.started_at.clone() - }); - SessionMetadata { - started_at, - model: model.to_string(), - message_count: session.messages.len().try_into().unwrap_or(u32::MAX), - last_prompt: last_prompt_from_session(session), - } -} - -fn session_age_secs(modified_epoch_secs: u64) -> u64 { - current_epoch_secs().saturating_sub(modified_epoch_secs) -} - -fn auto_compact_inactive_sessions( - active_session_id: &str, -) -> Result<(), Box> { - for summary in list_managed_sessions()? { - if summary.id == active_session_id - || session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS - { - continue; - } - let path = summary.path.clone(); - let Ok(session) = Session::load_from_path(&path) else { - continue; - }; - if !runtime::should_compact(&session, CompactionConfig::default()) { - continue; - } - let mut compacted = - runtime::compact_session(&session, CompactionConfig::default()).compacted_session; - let model = compacted.metadata.as_ref().map_or_else( - || DEFAULT_MODEL.to_string(), - |metadata| metadata.model.clone(), - ); - compacted.metadata = Some(derive_session_metadata(&compacted, &model)); - compacted.save_to_path(&path)?; - } - Ok(()) -} - fn render_repl_help() -> String { [ "REPL".to_string(), " /exit Quit the REPL".to_string(), + " /thinking [on|off] Show or toggle extended thinking".to_string(), " /quit Quit the REPL".to_string(), " Up/Down Navigate prompt history".to_string(), " Tab Complete slash commands".to_string(), @@ -1695,10 +1659,14 @@ fn format_status_report( "Status Model {model} Permission mode {permission_mode} + Thinking {} Messages {} Turns {} Estimated tokens {}", - usage.message_count, usage.turns, usage.estimated_tokens, + if usage.thinking_enabled { "on" } else { "off" }, + usage.message_count, + usage.turns, + usage.estimated_tokens, ), format!( "Usage @@ -1970,6 +1938,15 @@ fn render_export_text(session: &Session) -> String { for block in &message.blocks { match block { ContentBlock::Text { text } => lines.push(text.clone()), + ContentBlock::Thinking { text, signature } => { + lines.push(format!( + "[thinking{}] {}", + signature + .as_ref() + .map_or(String::new(), |value| format!(" signature={value}")), + text + )); + } ContentBlock::ToolUse { id, name, input } => { lines.push(format!("[tool_use id={id} name={name}] {input}")); } @@ -2060,11 +2037,12 @@ fn build_runtime( enable_tools: bool, allowed_tools: Option, permission_mode: PermissionMode, + thinking_enabled: bool, ) -> Result, Box> { Ok(ConversationRuntime::new( session, - AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?, + AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), thinking_enabled)?, CliToolExecutor::new(allowed_tools), permission_policy(permission_mode), system_prompt, @@ -2123,6 +2101,7 @@ struct AnthropicRuntimeClient { model: String, enable_tools: bool, allowed_tools: Option, + thinking_enabled: bool, } impl AnthropicRuntimeClient { @@ -2130,6 +2109,7 @@ impl AnthropicRuntimeClient { model: String, enable_tools: bool, allowed_tools: Option, + thinking_enabled: bool, ) -> Result> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, @@ -2137,6 +2117,7 @@ impl AnthropicRuntimeClient { model, enable_tools, allowed_tools, + thinking_enabled, }) } } @@ -2170,6 +2151,9 @@ impl ApiClient for AnthropicRuntimeClient { .collect() }), tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + thinking: self + .thinking_enabled + .then_some(ThinkingConfig::enabled(DEFAULT_THINKING_BUDGET_TOKENS)), stream: true, }; @@ -2182,6 +2166,7 @@ impl ApiClient for AnthropicRuntimeClient { let mut stdout = io::stdout(); let mut events = Vec::new(); let mut pending_tool: Option<(String, String, String)> = None; + let mut pending_thinking_signature: Option = None; let mut saw_stop = false; while let Some(event) = stream @@ -2192,7 +2177,13 @@ impl ApiClient for AnthropicRuntimeClient { match event { ApiStreamEvent::MessageStart(start) => { for block in start.message.content { - push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?; + push_output_block( + block, + &mut stdout, + &mut events, + &mut pending_tool, + &mut pending_thinking_signature, + )?; } } ApiStreamEvent::ContentBlockStart(start) => { @@ -2201,6 +2192,7 @@ impl ApiClient for AnthropicRuntimeClient { &mut stdout, &mut events, &mut pending_tool, + &mut pending_thinking_signature, )?; } ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { @@ -2212,6 +2204,14 @@ impl ApiClient for AnthropicRuntimeClient { events.push(AssistantEvent::TextDelta(text)); } } + ContentBlockDelta::ThinkingDelta { thinking } => { + if !thinking.is_empty() { + events.push(AssistantEvent::ThinkingDelta(thinking)); + } + } + ContentBlockDelta::SignatureDelta { signature } => { + events.push(AssistantEvent::ThinkingSignature(signature)); + } ContentBlockDelta::InputJsonDelta { partial_json } => { if let Some((_, _, input)) = &mut pending_tool { input.push_str(&partial_json); @@ -2241,6 +2241,8 @@ impl ApiClient for AnthropicRuntimeClient { if !saw_stop && events.iter().any(|event| { matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ThinkingDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ThinkingSignature(_)) || matches!(event, AssistantEvent::ToolUse { .. }) }) { @@ -2324,11 +2326,19 @@ fn truncate_for_summary(value: &str, limit: usize) -> String { } } +fn render_thinking_block_summary(text: &str, out: &mut impl Write) -> Result<(), RuntimeError> { + let summary = format!("▶ Thinking ({} chars hidden)", text.chars().count()); + writeln!(out, "\n{summary}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string())) +} + fn push_output_block( block: OutputContentBlock, out: &mut impl Write, events: &mut Vec, pending_tool: &mut Option<(String, String, String)>, + pending_thinking_signature: &mut Option, ) -> Result<(), RuntimeError> { match block { OutputContentBlock::Text { text } => { @@ -2339,6 +2349,19 @@ fn push_output_block( events.push(AssistantEvent::TextDelta(text)); } } + OutputContentBlock::Thinking { + thinking, + signature, + } => { + render_thinking_block_summary(&thinking, out)?; + if !thinking.is_empty() { + events.push(AssistantEvent::ThinkingDelta(thinking)); + } + if let Some(signature) = signature { + *pending_thinking_signature = Some(signature.clone()); + events.push(AssistantEvent::ThinkingSignature(signature)); + } + } OutputContentBlock::ToolUse { id, name, input } => { writeln!( out, @@ -2360,9 +2383,16 @@ fn response_to_events( ) -> Result, RuntimeError> { let mut events = Vec::new(); let mut pending_tool = None; + let mut pending_thinking_signature = None; for block in response.content { - push_output_block(block, out, &mut events, &mut pending_tool)?; + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + &mut pending_thinking_signature, + )?; if let Some((id, name, input)) = pending_tool.take() { events.push(AssistantEvent::ToolUse { id, name, input }); } @@ -2447,26 +2477,29 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { let content = message .blocks .iter() - .map(|block| match block { - ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + .filter_map(|block| match block { + ContentBlock::Text { text } => { + Some(InputContentBlock::Text { text: text.clone() }) + } + ContentBlock::Thinking { .. } => None, + ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }, + }), ContentBlock::ToolResult { tool_use_id, output, is_error, .. - } => InputContentBlock::ToolResult { + } => Some(InputContentBlock::ToolResult { tool_use_id: tool_use_id.clone(), content: vec![ToolResultContentBlock::Text { text: output.clone(), }], is_error: *is_error, - }, + }), }) .collect::>(); (!content.is_empty()).then(|| InputMessage { @@ -2499,6 +2532,7 @@ fn print_help() { println!(" --model MODEL Override the active model"); println!(" --output-format FORMAT Non-interactive output format: text or json"); println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); + println!(" --thinking Enable extended thinking with the default budget"); println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); println!(" --version, -V Print version and build information locally"); println!(); @@ -2525,73 +2559,17 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report, - format_init_report, format_model_report, format_model_switch_report, - format_permissions_report, format_permissions_switch_report, format_resume_report, - format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions, - normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, - render_init_claude_md, render_memory_report, render_repl_help, - resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat, - SlashCommand, StatusUsage, DEFAULT_MODEL, + filter_tool_specs, format_compact_report, format_cost_report, format_init_report, + format_model_report, format_model_switch_report, format_permissions_report, + format_permissions_switch_report, format_resume_report, format_status_report, + format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, + parse_git_status_metadata, render_config_report, render_init_claude_md, + render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, + CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; - use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session}; - use std::fs; + use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use std::path::{Path, PathBuf}; - #[test] - fn derive_session_metadata_recomputes_prompt_and_count() { - let mut session = Session::new(); - session - .messages - .push(ConversationMessage::user_text("first prompt")); - session - .messages - .push(ConversationMessage::assistant(vec![ContentBlock::Text { - text: "reply".to_string(), - }])); - let metadata = derive_session_metadata(&session, "claude-test"); - assert_eq!(metadata.model, "claude-test"); - assert_eq!(metadata.message_count, 2); - assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt")); - assert!(metadata.started_at.ends_with('Z')); - } - - #[test] - fn managed_sessions_use_home_directory_and_list_metadata() { - let temp = - std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id())); - let _ = fs::remove_dir_all(&temp); - fs::create_dir_all(&temp).expect("temp home should exist"); - let previous_home = std::env::var_os("HOME"); - std::env::set_var("HOME", &temp); - - let dir = sessions_dir().expect("sessions dir"); - assert_eq!(dir, temp.join(".claude").join("sessions")); - - let mut session = Session::new(); - session - .messages - .push(ConversationMessage::user_text("persist me")); - session.metadata = Some(derive_session_metadata(&session, "claude-home")); - let file = dir.join("session-test.json"); - session.save_to_path(&file).expect("session save"); - - let listed = list_managed_sessions().expect("session list"); - let found = listed - .into_iter() - .find(|entry| entry.id == "session-test") - .expect("saved session should be listed"); - assert_eq!(found.message_count, 1); - assert_eq!(found.model.as_deref(), Some("claude-home")); - assert_eq!(found.last_prompt.as_deref(), Some("persist me")); - - fs::remove_file(file).ok(); - if let Some(previous_home) = previous_home { - std::env::set_var("HOME", previous_home); - } - fs::remove_dir_all(temp).ok(); - } - #[test] fn defaults_to_repl_when_no_args() { assert_eq!( @@ -2600,6 +2578,7 @@ mod tests { model: DEFAULT_MODEL.to_string(), allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, + thinking: false, } ); } @@ -2619,6 +2598,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, + thinking: false, } ); } @@ -2640,6 +2620,7 @@ mod tests { output_format: CliOutputFormat::Json, allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, + thinking: false, } ); } @@ -2665,6 +2646,7 @@ mod tests { model: DEFAULT_MODEL.to_string(), allowed_tools: None, permission_mode: PermissionMode::ReadOnly, + thinking: false, } ); } @@ -2687,6 +2669,7 @@ mod tests { .collect() ), permission_mode: PermissionMode::WorkspaceWrite, + thinking: false, } ); } @@ -2797,8 +2780,7 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); - assert!(help.contains("/resume ")); - assert!(help.contains("/sessions")); + assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); @@ -2927,6 +2909,7 @@ mod tests { cache_read_input_tokens: 1, }, estimated_tokens: 128, + thinking_enabled: true, }, "workspace-write", &super::StatusContext { @@ -2990,7 +2973,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert!(context.discovered_config_files >= 3); + assert!(context.discovered_config_files >= context.loaded_config_files); assert!(context.loaded_config_files <= context.discovered_config_files); }