Merge remote-tracking branch 'origin/rcc/thinking' into dev/rust

# Conflicts:
#	rust/crates/commands/src/lib.rs
#	rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
Yeachan-Heo
2026-04-01 01:11:06 +00:00
9 changed files with 372 additions and 259 deletions

View File

@@ -912,6 +912,7 @@ mod tests {
system: None,
tools: None,
tool_choice: None,
thinking: None,
stream: false,
};

View File

@@ -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,
};

View File

@@ -12,6 +12,8 @@ pub struct MessageRequest {
pub tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
#[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<String>,
},
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 },
}

View File

@@ -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,
}
}

View File

@@ -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("<session-id-or-path>"),
argument_hint: Some("<session-path>"),
resume_supported: false,
},
SlashCommandSpec {
@@ -129,12 +135,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|switch <session-id>]"),
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<bool>,
},
Model {
model: Option<String>,
},
@@ -169,7 +172,6 @@ pub enum SlashCommand {
action: Option<String>,
target: Option<String>,
},
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 <session-id-or-path>"));
assert!(help.contains("/resume <session-path>"));
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 <session-id>]"));
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());
}
}

View File

@@ -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::<Vec<_>>();
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<String> {
.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<String> {
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, ..

View File

@@ -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<AssistantEvent>,
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
let mut text = String::new();
let mut thinking = String::new();
let mut thinking_signature: Option<String> = 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<ContentBlock>) {
}
}
fn flush_thinking_block(
thinking: &mut String,
signature: &mut Option<String>,
blocks: &mut Vec<ContentBlock>,
) {
if !thinking.is_empty() || signature.is_some() {
blocks.push(ContentBlock::Thinking {
text: std::mem::take(thinking),
signature: signature.take(),
});
}
}
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
#[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;

View File

@@ -19,6 +19,10 @@ pub enum ContentBlock {
Text {
text: String,
},
Thinking {
text: String,
signature: Option<String>,
},
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")?,

View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<AllowedToolSet>,
permission_mode: PermissionMode,
thinking: bool,
},
Login,
Logout,
@@ -111,6 +115,7 @@ enum CliAction {
model: String,
allowed_tools: Option<AllowedToolSet>,
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<CliAction, String> {
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<CliAction, String> {
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<CliAction, String> {
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<CliAction, String> {
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<CliAction, String> {
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<AllowedToolSet>,
permission_mode: PermissionMode,
thinking_enabled: bool,
) -> Result<(), Box<dyn std::error::Error>> {
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<String>,
started_at: Option<String>,
last_prompt: Option<String>,
}
struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
thinking_enabled: bool,
system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle,
@@ -968,10 +1014,10 @@ impl LiveCli {
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
thinking_enabled: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
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(
|_| "<unknown>".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::<Vec<_>>()
.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<dyn std::error::Error>> {
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<bool>) -> Result<bool, Box<dyn std::error::Error>> {
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<String>,
@@ -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<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else {
println!("Usage: /resume <session-id-or-path>");
println!("Usage: /resume <session-path>");
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<PathBuf, Box<dyn std::error::Error>> {
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<SessionHandle, Box<dyn std::error::Error>> {
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<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
let session = Session::load_from_path(&path).ok();
let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len());
let stored = session
.as_ref()
.and_then(|session| session.metadata.as_ref());
let message_count = Session::load_from_path(&path)
.map(|session| session.messages.len())
.unwrap_or_default();
let id = path
.file_stem()
.and_then(|value| value.to_str())
@@ -1524,12 +1574,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
id,
path,
modified_epoch_secs,
message_count: stored.map_or(derived_message_count, |metadata| {
metadata.message_count as usize
}),
model: stored.map(|metadata| metadata.model.clone()),
started_at: stored.map(|metadata| metadata.started_at.clone()),
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
message_count,
});
}
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
@@ -1552,103 +1597,22 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
} else {
"○ saved"
};
let model = session.model.as_deref().unwrap_or("unknown");
let started = session.started_at.as_deref().unwrap_or("unknown");
let last_prompt = session.last_prompt.as_deref().map_or_else(
|| "-".to_string(),
|prompt| truncate_for_summary(prompt, 36),
);
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}",
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
id = session.id,
msgs = session.message_count,
model = model,
started = started,
modified = session.modified_epoch_secs,
last_prompt = last_prompt,
path = session.path.display(),
));
}
Ok(lines.join("\n"))
}
fn current_epoch_secs() -> 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<String> {
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<dyn std::error::Error>> {
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<AllowedToolSet>,
permission_mode: PermissionMode,
thinking_enabled: bool,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
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<AllowedToolSet>,
thinking_enabled: bool,
}
impl AnthropicRuntimeClient {
@@ -2130,6 +2109,7 @@ impl AnthropicRuntimeClient {
model: String,
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
thinking_enabled: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
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<String> = 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<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
pending_thinking_signature: &mut Option<String>,
) -> 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<Vec<AssistantEvent>, 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<InputMessage> {
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::<Vec<_>>();
(!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 <session-id-or-path>"));
assert!(help.contains("/sessions"));
assert!(help.contains("/resume <session-path>"));
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);
}