mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-30 05:55:36 -04:00
Compare commits
8 Commits
cd01d0e387
...
rcc/thinki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c14196c730 | ||
|
|
d6341d54c1 | ||
|
|
863958b94c | ||
|
|
9455280f24 | ||
|
|
c92403994d | ||
|
|
8d4a739c05 | ||
|
|
e2f061fd08 | ||
|
|
6a7cea810e |
@@ -102,6 +102,13 @@ cd rust
|
|||||||
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
|
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Restrict enabled tools in an interactive session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rust
|
||||||
|
cargo run -p rusty-claude-cli -- --allowedTools read,glob
|
||||||
|
```
|
||||||
|
|
||||||
### 2) REPL mode
|
### 2) REPL mode
|
||||||
|
|
||||||
Start the interactive shell:
|
Start the interactive shell:
|
||||||
@@ -123,6 +130,10 @@ Inside the REPL, useful commands include:
|
|||||||
/memory
|
/memory
|
||||||
/config
|
/config
|
||||||
/init
|
/init
|
||||||
|
/diff
|
||||||
|
/version
|
||||||
|
/export notes.txt
|
||||||
|
/session list
|
||||||
/exit
|
/exit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,6 +180,10 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
|
|||||||
- `/config [env|hooks|model]` — inspect discovered Claude config
|
- `/config [env|hooks|model]` — inspect discovered Claude config
|
||||||
- `/memory` — inspect loaded instruction memory files
|
- `/memory` — inspect loaded instruction memory files
|
||||||
- `/init` — create a starter `CLAUDE.md`
|
- `/init` — create a starter `CLAUDE.md`
|
||||||
|
- `/diff` — show the current git diff for the workspace
|
||||||
|
- `/version` — print version and build metadata locally
|
||||||
|
- `/export [file]` — export the current conversation transcript
|
||||||
|
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
|
||||||
- `/exit` — leave the REPL
|
- `/exit` — leave the REPL
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|||||||
@@ -392,8 +392,52 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
|
|||||||
let Some(token_set) = load_saved_oauth_token()? else {
|
let Some(token_set) = load_saved_oauth_token()? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
resolve_saved_oauth_token_set(config, token_set).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
||||||
|
{
|
||||||
|
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||||
|
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
|
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
||||||
|
api_key,
|
||||||
|
bearer_token,
|
||||||
|
}),
|
||||||
|
None => Ok(AuthSource::ApiKey(api_key)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
|
return Ok(AuthSource::BearerToken(bearer_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(token_set) = load_saved_oauth_token()? else {
|
||||||
|
return Err(ApiError::MissingApiKey);
|
||||||
|
};
|
||||||
if !oauth_token_is_expired(&token_set) {
|
if !oauth_token_is_expired(&token_set) {
|
||||||
return Ok(Some(token_set));
|
return Ok(AuthSource::BearerToken(token_set.access_token));
|
||||||
|
}
|
||||||
|
if token_set.refresh_token.is_none() {
|
||||||
|
return Err(ApiError::ExpiredOAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(config) = load_oauth_config()? else {
|
||||||
|
return Err(ApiError::Auth(
|
||||||
|
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(AuthSource::from(resolve_saved_oauth_token_set(
|
||||||
|
&config, token_set,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_saved_oauth_token_set(
|
||||||
|
config: &OAuthConfig,
|
||||||
|
token_set: OAuthTokenSet,
|
||||||
|
) -> Result<OAuthTokenSet, ApiError> {
|
||||||
|
if !oauth_token_is_expired(&token_set) {
|
||||||
|
return Ok(token_set);
|
||||||
}
|
}
|
||||||
let Some(refresh_token) = token_set.refresh_token.clone() else {
|
let Some(refresh_token) = token_set.refresh_token.clone() else {
|
||||||
return Err(ApiError::ExpiredOAuthToken);
|
return Err(ApiError::ExpiredOAuthToken);
|
||||||
@@ -403,18 +447,28 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
|
|||||||
client
|
client
|
||||||
.refresh_oauth_token(
|
.refresh_oauth_token(
|
||||||
config,
|
config,
|
||||||
&OAuthRefreshRequest::from_config(config, refresh_token, Some(token_set.scopes)),
|
&OAuthRefreshRequest::from_config(
|
||||||
|
config,
|
||||||
|
refresh_token,
|
||||||
|
Some(token_set.scopes.clone()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})?;
|
})?;
|
||||||
save_oauth_credentials(&runtime::OAuthTokenSet {
|
let resolved = OAuthTokenSet {
|
||||||
access_token: refreshed.access_token.clone(),
|
access_token: refreshed.access_token,
|
||||||
refresh_token: refreshed.refresh_token.clone(),
|
refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
|
||||||
expires_at: refreshed.expires_at,
|
expires_at: refreshed.expires_at,
|
||||||
scopes: refreshed.scopes.clone(),
|
scopes: refreshed.scopes,
|
||||||
|
};
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: resolved.access_token.clone(),
|
||||||
|
refresh_token: resolved.refresh_token.clone(),
|
||||||
|
expires_at: resolved.expires_at,
|
||||||
|
scopes: resolved.scopes.clone(),
|
||||||
})
|
})
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
Ok(Some(refreshed))
|
Ok(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
|
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
|
||||||
@@ -571,8 +625,8 @@ mod tests {
|
|||||||
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
|
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
|
||||||
|
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient,
|
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
|
||||||
AuthSource, OAuthTokenSet,
|
resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
|
||||||
};
|
};
|
||||||
use crate::types::{ContentBlockDelta, MessageRequest};
|
use crate::types::{ContentBlockDelta, MessageRequest};
|
||||||
|
|
||||||
@@ -760,6 +814,95 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "saved-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh".to_string()),
|
||||||
|
expires_at: Some(now_unix_timestamp() + 300),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
|
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
|
||||||
|
.expect("startup auth");
|
||||||
|
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||||
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "expired-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh-token".to_string()),
|
||||||
|
expires_at: Some(1),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save expired oauth credentials");
|
||||||
|
|
||||||
|
let error =
|
||||||
|
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
|
||||||
|
assert!(
|
||||||
|
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let stored = runtime::load_oauth_credentials()
|
||||||
|
.expect("load stored credentials")
|
||||||
|
.expect("stored token set");
|
||||||
|
assert_eq!(stored.access_token, "expired-access-token");
|
||||||
|
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||||
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "expired-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh-token".to_string()),
|
||||||
|
expires_at: Some(1),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save expired oauth credentials");
|
||||||
|
|
||||||
|
let token_url = spawn_token_server(
|
||||||
|
"{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
|
||||||
|
);
|
||||||
|
let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
|
||||||
|
.expect("resolve refreshed token")
|
||||||
|
.expect("token set present");
|
||||||
|
assert_eq!(resolved.access_token, "refreshed-token");
|
||||||
|
assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
let stored = runtime::load_oauth_credentials()
|
||||||
|
.expect("load stored credentials")
|
||||||
|
.expect("stored token set");
|
||||||
|
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||||
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn message_request_stream_helper_sets_stream_true() {
|
fn message_request_stream_helper_sets_stream_true() {
|
||||||
let request = MessageRequest {
|
let request = MessageRequest {
|
||||||
@@ -769,6 +912,7 @@ mod tests {
|
|||||||
system: None,
|
system: None,
|
||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
|
thinking: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ mod sse;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use client::{
|
pub use client::{
|
||||||
oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient, AuthSource, MessageStream,
|
oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source,
|
||||||
OAuthTokenSet,
|
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
pub use error::ApiError;
|
||||||
pub use sse::{parse_frame, SseParser};
|
pub use sse::{parse_frame, SseParser};
|
||||||
@@ -13,5 +13,5 @@ pub use types::{
|
|||||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||||
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
|
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
|
||||||
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
|
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
|
||||||
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
|
ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub struct MessageRequest {
|
|||||||
pub tools: Option<Vec<ToolDefinition>>,
|
pub tools: Option<Vec<ToolDefinition>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tool_choice: Option<ToolChoice>,
|
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")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub stream: bool,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct InputMessage {
|
pub struct InputMessage {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
@@ -130,6 +149,11 @@ pub enum OutputContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Thinking {
|
||||||
|
thinking: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -189,6 +213,8 @@ pub struct ContentBlockDeltaEvent {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ContentBlockDelta {
|
pub enum ContentBlockDelta {
|
||||||
TextDelta { text: String },
|
TextDelta { text: String },
|
||||||
|
ThinkingDelta { thinking: String },
|
||||||
|
SignatureDelta { signature: String },
|
||||||
InputJsonDelta { partial_json: String },
|
InputJsonDelta { partial_json: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ async fn live_stream_smoke_test() {
|
|||||||
system: None,
|
system: None,
|
||||||
tools: None,
|
tools: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
|
thinking: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -438,6 +439,7 @@ fn sample_request(stream: bool) -> MessageRequest {
|
|||||||
}),
|
}),
|
||||||
}]),
|
}]),
|
||||||
tool_choice: Some(ToolChoice::Auto),
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
|
thinking: None,
|
||||||
stream,
|
stream,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "thinking",
|
||||||
|
summary: "Show or toggle extended thinking",
|
||||||
|
argument_hint: Some("[on|off]"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "model",
|
name: "model",
|
||||||
summary: "Show or switch the active model",
|
summary: "Show or switch the active model",
|
||||||
@@ -136,6 +142,9 @@ pub enum SlashCommand {
|
|||||||
Help,
|
Help,
|
||||||
Status,
|
Status,
|
||||||
Compact,
|
Compact,
|
||||||
|
Thinking {
|
||||||
|
enabled: Option<bool>,
|
||||||
|
},
|
||||||
Model {
|
Model {
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -180,6 +189,13 @@ impl SlashCommand {
|
|||||||
"help" => Self::Help,
|
"help" => Self::Help,
|
||||||
"status" => Self::Status,
|
"status" => Self::Status,
|
||||||
"compact" => Self::Compact,
|
"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" => Self::Model {
|
||||||
model: parts.next().map(ToOwned::to_owned),
|
model: parts.next().map(ToOwned::to_owned),
|
||||||
},
|
},
|
||||||
@@ -279,6 +295,7 @@ pub fn handle_slash_command(
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Status
|
SlashCommand::Status
|
||||||
|
| SlashCommand::Thinking { .. }
|
||||||
| SlashCommand::Model { .. }
|
| SlashCommand::Model { .. }
|
||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Clear { .. }
|
| SlashCommand::Clear { .. }
|
||||||
@@ -307,6 +324,22 @@ mod tests {
|
|||||||
fn parses_supported_slash_commands() {
|
fn parses_supported_slash_commands() {
|
||||||
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
||||||
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
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!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/model claude-opus"),
|
SlashCommand::parse("/model claude-opus"),
|
||||||
Some(SlashCommand::Model {
|
Some(SlashCommand::Model {
|
||||||
@@ -374,6 +407,7 @@ mod tests {
|
|||||||
assert!(help.contains("/help"));
|
assert!(help.contains("/help"));
|
||||||
assert!(help.contains("/status"));
|
assert!(help.contains("/status"));
|
||||||
assert!(help.contains("/compact"));
|
assert!(help.contains("/compact"));
|
||||||
|
assert!(help.contains("/thinking [on|off]"));
|
||||||
assert!(help.contains("/model [model]"));
|
assert!(help.contains("/model [model]"));
|
||||||
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||||
assert!(help.contains("/clear [--confirm]"));
|
assert!(help.contains("/clear [--confirm]"));
|
||||||
@@ -386,7 +420,7 @@ mod tests {
|
|||||||
assert!(help.contains("/version"));
|
assert!(help.contains("/version"));
|
||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||||
assert_eq!(slash_command_specs().len(), 15);
|
assert_eq!(slash_command_specs().len(), 16);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 11);
|
assert_eq!(resume_supported_slash_commands().len(), 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +468,9 @@ mod tests {
|
|||||||
let session = Session::new();
|
let session = Session::new();
|
||||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/status", &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!(
|
assert!(
|
||||||
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|||||||
.filter_map(|block| match block {
|
.filter_map(|block| match block {
|
||||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||||
ContentBlock::Text { .. } => None,
|
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
tool_names.sort_unstable();
|
tool_names.sort_unstable();
|
||||||
@@ -200,6 +200,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|||||||
fn summarize_block(block: &ContentBlock) -> String {
|
fn summarize_block(block: &ContentBlock) -> String {
|
||||||
let raw = match block {
|
let raw = match block {
|
||||||
ContentBlock::Text { text } => text.clone(),
|
ContentBlock::Text { text } => text.clone(),
|
||||||
|
ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
|
||||||
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_name,
|
tool_name,
|
||||||
@@ -258,7 +259,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|message| message.blocks.iter())
|
.flat_map(|message| message.blocks.iter())
|
||||||
.map(|block| match block {
|
.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::ToolUse { input, .. } => input.as_str(),
|
||||||
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||||
})
|
})
|
||||||
@@ -280,10 +281,15 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
|
|||||||
|
|
||||||
fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
||||||
message.blocks.iter().find_map(|block| match block {
|
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::ToolUse { .. }
|
||||||
| ContentBlock::ToolResult { .. }
|
| ContentBlock::ToolResult { .. }
|
||||||
| ContentBlock::Text { .. } => None,
|
| ContentBlock::Text { .. }
|
||||||
|
| ContentBlock::Thinking { .. } => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
|||||||
.blocks
|
.blocks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|block| match block {
|
.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::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
|
||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_name, output, ..
|
tool_name, output, ..
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ pub struct ApiRequest {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum AssistantEvent {
|
pub enum AssistantEvent {
|
||||||
TextDelta(String),
|
TextDelta(String),
|
||||||
|
ThinkingDelta(String),
|
||||||
|
ThinkingSignature(String),
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -247,15 +249,26 @@ fn build_assistant_message(
|
|||||||
events: Vec<AssistantEvent>,
|
events: Vec<AssistantEvent>,
|
||||||
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
|
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
let mut thinking = String::new();
|
||||||
|
let mut thinking_signature: Option<String> = None;
|
||||||
let mut blocks = Vec::new();
|
let mut blocks = Vec::new();
|
||||||
let mut finished = false;
|
let mut finished = false;
|
||||||
let mut usage = None;
|
let mut usage = None;
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match event {
|
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 } => {
|
AssistantEvent::ToolUse { id, name, input } => {
|
||||||
flush_text_block(&mut text, &mut blocks);
|
flush_text_block(&mut text, &mut blocks);
|
||||||
|
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
|
||||||
blocks.push(ContentBlock::ToolUse { id, name, input });
|
blocks.push(ContentBlock::ToolUse { id, name, input });
|
||||||
}
|
}
|
||||||
AssistantEvent::Usage(value) => usage = Some(value),
|
AssistantEvent::Usage(value) => usage = Some(value),
|
||||||
@@ -266,6 +279,7 @@ fn build_assistant_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
flush_text_block(&mut text, &mut blocks);
|
flush_text_block(&mut text, &mut blocks);
|
||||||
|
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
|
||||||
|
|
||||||
if !finished {
|
if !finished {
|
||||||
return Err(RuntimeError::new(
|
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>>;
|
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -325,8 +352,8 @@ impl ToolExecutor for StaticToolExecutor {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
|
build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime,
|
||||||
StaticToolExecutor,
|
RuntimeError, StaticToolExecutor,
|
||||||
};
|
};
|
||||||
use crate::compact::CompactionConfig;
|
use crate::compact::CompactionConfig;
|
||||||
use crate::permissions::{
|
use crate::permissions::{
|
||||||
@@ -408,7 +435,7 @@ mod tests {
|
|||||||
.sum::<i32>();
|
.sum::<i32>();
|
||||||
Ok(total.to_string())
|
Ok(total.to_string())
|
||||||
});
|
});
|
||||||
let permission_policy = PermissionPolicy::new(PermissionMode::Prompt);
|
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
|
||||||
let system_prompt = SystemPromptBuilder::new()
|
let system_prompt = SystemPromptBuilder::new()
|
||||||
.with_project_context(ProjectContext {
|
.with_project_context(ProjectContext {
|
||||||
cwd: PathBuf::from("/tmp/project"),
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
@@ -487,7 +514,7 @@ mod tests {
|
|||||||
Session::new(),
|
Session::new(),
|
||||||
SingleCallApiClient,
|
SingleCallApiClient,
|
||||||
StaticToolExecutor::new(),
|
StaticToolExecutor::new(),
|
||||||
PermissionPolicy::new(PermissionMode::Prompt),
|
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
||||||
vec!["system".to_string()],
|
vec!["system".to_string()],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -502,6 +529,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]
|
#[test]
|
||||||
fn reconstructs_usage_tracker_from_restored_session() {
|
fn reconstructs_usage_tracker_from_restored_session() {
|
||||||
struct SimpleApi;
|
struct SimpleApi;
|
||||||
@@ -536,7 +586,7 @@ mod tests {
|
|||||||
session,
|
session,
|
||||||
SimpleApi,
|
SimpleApi,
|
||||||
StaticToolExecutor::new(),
|
StaticToolExecutor::new(),
|
||||||
PermissionPolicy::new(PermissionMode::Allow),
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||||
vec!["system".to_string()],
|
vec!["system".to_string()],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -563,7 +613,7 @@ mod tests {
|
|||||||
Session::new(),
|
Session::new(),
|
||||||
SimpleApi,
|
SimpleApi,
|
||||||
StaticToolExecutor::new(),
|
StaticToolExecutor::new(),
|
||||||
PermissionPolicy::new(PermissionMode::Allow),
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||||
vec!["system".to_string()],
|
vec!["system".to_string()],
|
||||||
);
|
);
|
||||||
runtime.run_turn("a", None).expect("turn a");
|
runtime.run_turn("a", None).expect("turn a");
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum PermissionMode {
|
pub enum PermissionMode {
|
||||||
Allow,
|
ReadOnly,
|
||||||
Deny,
|
WorkspaceWrite,
|
||||||
Prompt,
|
DangerFullAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionMode {
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ReadOnly => "read-only",
|
||||||
|
Self::WorkspaceWrite => "workspace-write",
|
||||||
|
Self::DangerFullAccess => "danger-full-access",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PermissionRequest {
|
pub struct PermissionRequest {
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub input: String,
|
pub input: String,
|
||||||
|
pub current_mode: PermissionMode,
|
||||||
|
pub required_mode: PermissionMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -31,31 +44,41 @@ pub enum PermissionOutcome {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PermissionPolicy {
|
pub struct PermissionPolicy {
|
||||||
default_mode: PermissionMode,
|
active_mode: PermissionMode,
|
||||||
tool_modes: BTreeMap<String, PermissionMode>,
|
tool_requirements: BTreeMap<String, PermissionMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PermissionPolicy {
|
impl PermissionPolicy {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(default_mode: PermissionMode) -> Self {
|
pub fn new(active_mode: PermissionMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_mode,
|
active_mode,
|
||||||
tool_modes: BTreeMap::new(),
|
tool_requirements: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_tool_mode(mut self, tool_name: impl Into<String>, mode: PermissionMode) -> Self {
|
pub fn with_tool_requirement(
|
||||||
self.tool_modes.insert(tool_name.into(), mode);
|
mut self,
|
||||||
|
tool_name: impl Into<String>,
|
||||||
|
required_mode: PermissionMode,
|
||||||
|
) -> Self {
|
||||||
|
self.tool_requirements
|
||||||
|
.insert(tool_name.into(), required_mode);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn mode_for(&self, tool_name: &str) -> PermissionMode {
|
pub fn active_mode(&self) -> PermissionMode {
|
||||||
self.tool_modes
|
self.active_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
|
||||||
|
self.tool_requirements
|
||||||
.get(tool_name)
|
.get(tool_name)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(self.default_mode)
|
.unwrap_or(PermissionMode::DangerFullAccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -65,23 +88,43 @@ impl PermissionPolicy {
|
|||||||
input: &str,
|
input: &str,
|
||||||
mut prompter: Option<&mut dyn PermissionPrompter>,
|
mut prompter: Option<&mut dyn PermissionPrompter>,
|
||||||
) -> PermissionOutcome {
|
) -> PermissionOutcome {
|
||||||
match self.mode_for(tool_name) {
|
let current_mode = self.active_mode();
|
||||||
PermissionMode::Allow => PermissionOutcome::Allow,
|
let required_mode = self.required_mode_for(tool_name);
|
||||||
PermissionMode::Deny => PermissionOutcome::Deny {
|
if current_mode >= required_mode {
|
||||||
reason: format!("tool '{tool_name}' denied by permission policy"),
|
return PermissionOutcome::Allow;
|
||||||
},
|
}
|
||||||
PermissionMode::Prompt => match prompter.as_mut() {
|
|
||||||
Some(prompter) => match prompter.decide(&PermissionRequest {
|
let request = PermissionRequest {
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
}) {
|
current_mode,
|
||||||
|
required_mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_mode == PermissionMode::WorkspaceWrite
|
||||||
|
&& required_mode == PermissionMode::DangerFullAccess
|
||||||
|
{
|
||||||
|
return match prompter.as_mut() {
|
||||||
|
Some(prompter) => match prompter.decide(&request) {
|
||||||
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
|
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
|
||||||
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
|
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
|
||||||
},
|
},
|
||||||
None => PermissionOutcome::Deny {
|
None => PermissionOutcome::Deny {
|
||||||
reason: format!("tool '{tool_name}' requires interactive approval"),
|
reason: format!(
|
||||||
|
"tool '{tool_name}' requires approval to escalate from {} to {}",
|
||||||
|
current_mode.as_str(),
|
||||||
|
required_mode.as_str()
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PermissionOutcome::Deny {
|
||||||
|
reason: format!(
|
||||||
|
"tool '{tool_name}' requires {} permission; current mode is {}",
|
||||||
|
required_mode.as_str(),
|
||||||
|
current_mode.as_str()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,25 +136,92 @@ mod tests {
|
|||||||
PermissionPrompter, PermissionRequest,
|
PermissionPrompter, PermissionRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct AllowPrompter;
|
struct RecordingPrompter {
|
||||||
|
seen: Vec<PermissionRequest>,
|
||||||
|
allow: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl PermissionPrompter for AllowPrompter {
|
impl PermissionPrompter for RecordingPrompter {
|
||||||
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
|
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
|
||||||
assert_eq!(request.tool_name, "bash");
|
self.seen.push(request.clone());
|
||||||
PermissionPromptDecision::Allow
|
if self.allow {
|
||||||
|
PermissionPromptDecision::Allow
|
||||||
|
} else {
|
||||||
|
PermissionPromptDecision::Deny {
|
||||||
|
reason: "not now".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn uses_tool_specific_overrides() {
|
fn allows_tools_when_active_mode_meets_requirement() {
|
||||||
let policy = PermissionPolicy::new(PermissionMode::Deny)
|
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
|
||||||
.with_tool_mode("bash", PermissionMode::Prompt);
|
.with_tool_requirement("read_file", PermissionMode::ReadOnly)
|
||||||
|
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
policy.authorize("read_file", "{}", None),
|
||||||
|
PermissionOutcome::Allow
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy.authorize("write_file", "{}", None),
|
||||||
|
PermissionOutcome::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denies_read_only_escalations_without_prompt() {
|
||||||
|
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
|
||||||
|
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
|
||||||
|
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
|
||||||
|
|
||||||
let outcome = policy.authorize("bash", "echo hi", Some(&mut AllowPrompter));
|
|
||||||
assert_eq!(outcome, PermissionOutcome::Allow);
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
policy.authorize("edit", "x", None),
|
policy.authorize("write_file", "{}", None),
|
||||||
PermissionOutcome::Deny { .. }
|
PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
policy.authorize("bash", "{}", None),
|
||||||
|
PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompts_for_workspace_write_to_danger_full_access_escalation() {
|
||||||
|
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
|
||||||
|
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
|
||||||
|
let mut prompter = RecordingPrompter {
|
||||||
|
seen: Vec::new(),
|
||||||
|
allow: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
|
||||||
|
|
||||||
|
assert_eq!(outcome, PermissionOutcome::Allow);
|
||||||
|
assert_eq!(prompter.seen.len(), 1);
|
||||||
|
assert_eq!(prompter.seen[0].tool_name, "bash");
|
||||||
|
assert_eq!(
|
||||||
|
prompter.seen[0].current_mode,
|
||||||
|
PermissionMode::WorkspaceWrite
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompter.seen[0].required_mode,
|
||||||
|
PermissionMode::DangerFullAccess
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn honors_prompt_rejection_reason() {
|
||||||
|
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
|
||||||
|
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
|
||||||
|
let mut prompter = RecordingPrompter {
|
||||||
|
seen: Vec::new(),
|
||||||
|
allow: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
policy.authorize("bash", "echo hi", Some(&mut prompter)),
|
||||||
|
PermissionOutcome::Deny { reason } if reason == "not now"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ pub enum ContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Thinking {
|
||||||
|
text: String,
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -257,6 +261,19 @@ impl ContentBlock {
|
|||||||
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
||||||
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
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 } => {
|
Self::ToolUse { id, name, input } => {
|
||||||
object.insert(
|
object.insert(
|
||||||
"type".to_string(),
|
"type".to_string(),
|
||||||
@@ -303,6 +320,13 @@ impl ContentBlock {
|
|||||||
"text" => Ok(Self::Text {
|
"text" => Ok(Self::Text {
|
||||||
text: required_string(object, "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 {
|
"tool_use" => Ok(Self::ToolUse {
|
||||||
id: required_string(object, "id")?,
|
id: required_string(object, "id")?,
|
||||||
name: required_string(object, "name")?,
|
name: required_string(object, "name")?,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::io::{self, Write};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::args::{OutputFormat, PermissionMode};
|
use crate::args::{OutputFormat, PermissionMode};
|
||||||
use crate::input::LineEditor;
|
use crate::input::{LineEditor, ReadOutcome};
|
||||||
use crate::render::{Spinner, TerminalRenderer};
|
use crate::render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||||
|
|
||||||
@@ -111,16 +111,21 @@ impl CliApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||||
let editor = LineEditor::new("› ");
|
let mut editor = LineEditor::new("› ", Vec::new());
|
||||||
println!("Rusty Claude CLI interactive mode");
|
println!("Rusty Claude CLI interactive mode");
|
||||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||||
|
|
||||||
while let Some(input) = editor.read_line()? {
|
loop {
|
||||||
if input.trim().is_empty() {
|
match editor.read_line()? {
|
||||||
continue;
|
ReadOutcome::Submit(input) => {
|
||||||
|
if input.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.handle_submission(&input, &mut io::stdout())?;
|
||||||
|
}
|
||||||
|
ReadOutcome::Cancel => continue,
|
||||||
|
ReadOutcome::Exit => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.handle_submission(&input, &mut io::stdout())?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use std::io::{self, IsTerminal, Write};
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
use crossterm::cursor::MoveToColumn;
|
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
use crossterm::queue;
|
use crossterm::queue;
|
||||||
use crossterm::style::Print;
|
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -85,21 +84,124 @@ impl InputBuffer {
|
|||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.cursor = 0;
|
self.cursor = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn replace(&mut self, value: impl Into<String>) {
|
||||||
|
self.buffer = value.into();
|
||||||
|
self.cursor = self.buffer.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn current_command_prefix(&self) -> Option<&str> {
|
||||||
|
if self.cursor != self.buffer.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let prefix = &self.buffer[..self.cursor];
|
||||||
|
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool {
|
||||||
|
let Some(prefix) = self.current_command_prefix() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let matches = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|candidate| candidate.starts_with(prefix))
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if matches.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacement = longest_common_prefix(&matches);
|
||||||
|
if replacement == prefix {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.replace(replacement);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RenderedBuffer {
|
||||||
|
lines: Vec<String>,
|
||||||
|
cursor_row: u16,
|
||||||
|
cursor_col: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderedBuffer {
|
||||||
|
#[must_use]
|
||||||
|
pub fn line_count(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, out: &mut impl Write) -> io::Result<()> {
|
||||||
|
for (index, line) in self.lines.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
writeln!(out)?;
|
||||||
|
}
|
||||||
|
write!(out, "{line}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn lines(&self) -> &[String] {
|
||||||
|
&self.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn cursor_position(&self) -> (u16, u16) {
|
||||||
|
(self.cursor_row, self.cursor_col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ReadOutcome {
|
||||||
|
Submit(String),
|
||||||
|
Cancel,
|
||||||
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LineEditor {
|
pub struct LineEditor {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
|
continuation_prompt: String,
|
||||||
|
history: Vec<String>,
|
||||||
|
history_index: Option<usize>,
|
||||||
|
draft: Option<String>,
|
||||||
|
completions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LineEditor {
|
impl LineEditor {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(prompt: impl Into<String>) -> Self {
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
prompt: prompt.into(),
|
prompt: prompt.into(),
|
||||||
|
continuation_prompt: String::from("> "),
|
||||||
|
history: Vec::new(),
|
||||||
|
history_index: None,
|
||||||
|
draft: None,
|
||||||
|
completions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_line(&self) -> io::Result<Option<String>> {
|
pub fn push_history(&mut self, entry: impl Into<String>) {
|
||||||
|
let entry = entry.into();
|
||||||
|
if entry.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.history.push(entry);
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
||||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||||
return self.read_line_fallback();
|
return self.read_line_fallback();
|
||||||
}
|
}
|
||||||
@@ -107,29 +209,43 @@ impl LineEditor {
|
|||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut input = InputBuffer::new();
|
let mut input = InputBuffer::new();
|
||||||
self.redraw(&mut stdout, &input)?;
|
let mut rendered_lines = 1usize;
|
||||||
|
self.redraw(&mut stdout, &input, rendered_lines)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let event = event::read()?;
|
let event = event::read()?;
|
||||||
if let Event::Key(key) = event {
|
if let Event::Key(key) = event {
|
||||||
match Self::handle_key(key, &mut input) {
|
match self.handle_key(key, &mut input) {
|
||||||
EditorAction::Continue => self.redraw(&mut stdout, &input)?,
|
EditorAction::Continue => {
|
||||||
|
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
|
||||||
|
}
|
||||||
EditorAction::Submit => {
|
EditorAction::Submit => {
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
writeln!(stdout)?;
|
writeln!(stdout)?;
|
||||||
return Ok(Some(input.as_str().to_owned()));
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
|
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
|
||||||
}
|
}
|
||||||
EditorAction::Cancel => {
|
EditorAction::Cancel => {
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
writeln!(stdout)?;
|
writeln!(stdout)?;
|
||||||
return Ok(None);
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
|
return Ok(ReadOutcome::Cancel);
|
||||||
|
}
|
||||||
|
EditorAction::Exit => {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
writeln!(stdout)?;
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
|
return Ok(ReadOutcome::Exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_line_fallback(&self) -> io::Result<Option<String>> {
|
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
write!(stdout, "{}", self.prompt)?;
|
write!(stdout, "{}", self.prompt)?;
|
||||||
stdout.flush()?;
|
stdout.flush()?;
|
||||||
@@ -137,22 +253,32 @@ impl LineEditor {
|
|||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
return Ok(None);
|
return Ok(ReadOutcome::Exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
||||||
buffer.pop();
|
buffer.pop();
|
||||||
}
|
}
|
||||||
Ok(Some(buffer))
|
Ok(ReadOutcome::Submit(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
||||||
match key {
|
match key {
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Char('c'),
|
code: KeyCode::Char('c'),
|
||||||
modifiers,
|
modifiers,
|
||||||
..
|
..
|
||||||
} if modifiers.contains(KeyModifiers::CONTROL) => EditorAction::Cancel,
|
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
if input.as_str().is_empty() {
|
||||||
|
EditorAction::Exit
|
||||||
|
} else {
|
||||||
|
input.clear();
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
|
EditorAction::Cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Char('j'),
|
code: KeyCode::Char('j'),
|
||||||
modifiers,
|
modifiers,
|
||||||
@@ -194,6 +320,25 @@ impl LineEditor {
|
|||||||
input.move_right();
|
input.move_right();
|
||||||
EditorAction::Continue
|
EditorAction::Continue
|
||||||
}
|
}
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Up, ..
|
||||||
|
} => {
|
||||||
|
self.navigate_history_up(input);
|
||||||
|
EditorAction::Continue
|
||||||
|
}
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Down,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.navigate_history_down(input);
|
||||||
|
EditorAction::Continue
|
||||||
|
}
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Tab, ..
|
||||||
|
} => {
|
||||||
|
input.complete_slash_command(&self.completions);
|
||||||
|
EditorAction::Continue
|
||||||
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Home,
|
code: KeyCode::Home,
|
||||||
..
|
..
|
||||||
@@ -211,6 +356,8 @@ impl LineEditor {
|
|||||||
code: KeyCode::Esc, ..
|
code: KeyCode::Esc, ..
|
||||||
} => {
|
} => {
|
||||||
input.clear();
|
input.clear();
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
EditorAction::Cancel
|
EditorAction::Cancel
|
||||||
}
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
@@ -219,22 +366,74 @@ impl LineEditor {
|
|||||||
..
|
..
|
||||||
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
||||||
input.insert(ch);
|
input.insert(ch);
|
||||||
|
self.history_index = None;
|
||||||
|
self.draft = None;
|
||||||
EditorAction::Continue
|
EditorAction::Continue
|
||||||
}
|
}
|
||||||
_ => EditorAction::Continue,
|
_ => EditorAction::Continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw(&self, out: &mut impl Write, input: &InputBuffer) -> io::Result<()> {
|
fn navigate_history_up(&mut self, input: &mut InputBuffer) {
|
||||||
let display = input.as_str().replace('\n', "\\n\n> ");
|
if self.history.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.history_index {
|
||||||
|
Some(0) => {}
|
||||||
|
Some(index) => {
|
||||||
|
let next_index = index - 1;
|
||||||
|
input.replace(self.history[next_index].clone());
|
||||||
|
self.history_index = Some(next_index);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.draft = Some(input.as_str().to_owned());
|
||||||
|
let next_index = self.history.len() - 1;
|
||||||
|
input.replace(self.history[next_index].clone());
|
||||||
|
self.history_index = Some(next_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_history_down(&mut self, input: &mut InputBuffer) {
|
||||||
|
let Some(index) = self.history_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if index + 1 < self.history.len() {
|
||||||
|
let next_index = index + 1;
|
||||||
|
input.replace(self.history[next_index].clone());
|
||||||
|
self.history_index = Some(next_index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.replace(self.draft.take().unwrap_or_default());
|
||||||
|
self.history_index = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redraw(
|
||||||
|
&self,
|
||||||
|
out: &mut impl Write,
|
||||||
|
input: &InputBuffer,
|
||||||
|
previous_line_count: usize,
|
||||||
|
) -> io::Result<usize> {
|
||||||
|
let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
|
||||||
|
if previous_line_count > 1 {
|
||||||
|
queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
|
||||||
|
}
|
||||||
|
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
|
||||||
|
rendered.write(out)?;
|
||||||
queue!(
|
queue!(
|
||||||
out,
|
out,
|
||||||
|
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
|
||||||
MoveToColumn(0),
|
MoveToColumn(0),
|
||||||
Clear(ClearType::CurrentLine),
|
|
||||||
Print(&self.prompt),
|
|
||||||
Print(display),
|
|
||||||
)?;
|
)?;
|
||||||
out.flush()
|
if rendered.cursor_row > 0 {
|
||||||
|
queue!(out, MoveDown(rendered.cursor_row))?;
|
||||||
|
}
|
||||||
|
queue!(out, MoveToColumn(rendered.cursor_col))?;
|
||||||
|
out.flush()?;
|
||||||
|
Ok(rendered.line_count())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,11 +442,76 @@ enum EditorAction {
|
|||||||
Continue,
|
Continue,
|
||||||
Submit,
|
Submit,
|
||||||
Cancel,
|
Cancel,
|
||||||
|
Exit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_buffer(
|
||||||
|
prompt: &str,
|
||||||
|
continuation_prompt: &str,
|
||||||
|
input: &InputBuffer,
|
||||||
|
) -> RenderedBuffer {
|
||||||
|
let before_cursor = &input.as_str()[..input.cursor];
|
||||||
|
let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count());
|
||||||
|
let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default();
|
||||||
|
let cursor_prompt = if cursor_row == 0 {
|
||||||
|
prompt
|
||||||
|
} else {
|
||||||
|
continuation_prompt
|
||||||
|
};
|
||||||
|
let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count());
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (index, line) in input.as_str().split('\n').enumerate() {
|
||||||
|
let prefix = if index == 0 {
|
||||||
|
prompt
|
||||||
|
} else {
|
||||||
|
continuation_prompt
|
||||||
|
};
|
||||||
|
lines.push(format!("{prefix}{line}"));
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(prompt.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderedBuffer {
|
||||||
|
lines,
|
||||||
|
cursor_row,
|
||||||
|
cursor_col,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn longest_common_prefix(values: &[&str]) -> String {
|
||||||
|
let Some(first) = values.first() else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut prefix = (*first).to_string();
|
||||||
|
for value in values.iter().skip(1) {
|
||||||
|
while !value.starts_with(&prefix) {
|
||||||
|
prefix.pop();
|
||||||
|
if prefix.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn saturating_u16(value: usize) -> u16 {
|
||||||
|
u16::try_from(value).unwrap_or(u16::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::InputBuffer;
|
use super::{render_buffer, InputBuffer, LineEditor};
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supports_basic_line_editing() {
|
fn supports_basic_line_editing() {
|
||||||
@@ -266,4 +530,119 @@ mod tests {
|
|||||||
assert_eq!(input.as_str(), "hix");
|
assert_eq!(input.as_str(), "hix");
|
||||||
assert_eq!(input.cursor(), 2);
|
assert_eq!(input.cursor(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completes_unique_slash_command() {
|
||||||
|
let mut input = InputBuffer::new();
|
||||||
|
for ch in "/he".chars() {
|
||||||
|
input.insert(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(input.complete_slash_command(&[
|
||||||
|
"/help".to_string(),
|
||||||
|
"/hello".to_string(),
|
||||||
|
"/status".to_string(),
|
||||||
|
]));
|
||||||
|
assert_eq!(input.as_str(), "/hel");
|
||||||
|
|
||||||
|
assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
|
||||||
|
assert_eq!(input.as_str(), "/help");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_completion_when_prefix_is_not_a_slash_command() {
|
||||||
|
let mut input = InputBuffer::new();
|
||||||
|
for ch in "hello".chars() {
|
||||||
|
input.insert(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!input.complete_slash_command(&["/help".to_string()]));
|
||||||
|
assert_eq!(input.as_str(), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_navigation_restores_current_draft() {
|
||||||
|
let mut editor = LineEditor::new("› ", vec![]);
|
||||||
|
editor.push_history("/help");
|
||||||
|
editor.push_history("status report");
|
||||||
|
|
||||||
|
let mut input = InputBuffer::new();
|
||||||
|
for ch in "draft".chars() {
|
||||||
|
input.insert(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
|
||||||
|
assert_eq!(input.as_str(), "status report");
|
||||||
|
|
||||||
|
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
|
||||||
|
assert_eq!(input.as_str(), "/help");
|
||||||
|
|
||||||
|
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
|
||||||
|
assert_eq!(input.as_str(), "status report");
|
||||||
|
|
||||||
|
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
|
||||||
|
assert_eq!(input.as_str(), "draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_key_completes_from_editor_candidates() {
|
||||||
|
let mut editor = LineEditor::new(
|
||||||
|
"› ",
|
||||||
|
vec![
|
||||||
|
"/help".to_string(),
|
||||||
|
"/status".to_string(),
|
||||||
|
"/session".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut input = InputBuffer::new();
|
||||||
|
for ch in "/st".chars() {
|
||||||
|
input.insert(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
|
||||||
|
assert_eq!(input.as_str(), "/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_multiline_buffers_with_continuation_prompt() {
|
||||||
|
let mut input = InputBuffer::new();
|
||||||
|
for ch in "hello\nworld".chars() {
|
||||||
|
if ch == '\n' {
|
||||||
|
input.insert_newline();
|
||||||
|
} else {
|
||||||
|
input.insert(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = render_buffer("› ", "> ", &input);
|
||||||
|
assert_eq!(
|
||||||
|
rendered.lines(),
|
||||||
|
&["› hello".to_string(), "> world".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(rendered.cursor_position(), (1, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_exits_only_when_buffer_is_empty() {
|
||||||
|
let mut editor = LineEditor::new("› ", vec![]);
|
||||||
|
let mut empty = InputBuffer::new();
|
||||||
|
assert!(matches!(
|
||||||
|
editor.handle_key(
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
&mut empty,
|
||||||
|
),
|
||||||
|
super::EditorAction::Exit
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut filled = InputBuffer::new();
|
||||||
|
filled.insert('x');
|
||||||
|
assert!(matches!(
|
||||||
|
editor.handle_key(
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
&mut filled,
|
||||||
|
),
|
||||||
|
super::EditorAction::Cancel
|
||||||
|
));
|
||||||
|
assert!(filled.as_str().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
|||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
|
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
|
||||||
GrepSearchInput,
|
GrepSearchInput, PermissionMode,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -45,6 +45,7 @@ pub struct ToolSpec {
|
|||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
pub input_schema: Value,
|
pub input_schema: Value,
|
||||||
|
pub required_permission: PermissionMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -66,6 +67,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["command"],
|
"required": ["command"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "read_file",
|
name: "read_file",
|
||||||
@@ -80,6 +82,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["path"],
|
"required": ["path"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "write_file",
|
name: "write_file",
|
||||||
@@ -93,6 +96,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["path", "content"],
|
"required": ["path", "content"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::WorkspaceWrite,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "edit_file",
|
name: "edit_file",
|
||||||
@@ -108,6 +112,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["path", "old_string", "new_string"],
|
"required": ["path", "old_string", "new_string"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::WorkspaceWrite,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "glob_search",
|
name: "glob_search",
|
||||||
@@ -121,6 +126,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["pattern"],
|
"required": ["pattern"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "grep_search",
|
name: "grep_search",
|
||||||
@@ -146,6 +152,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["pattern"],
|
"required": ["pattern"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "WebFetch",
|
name: "WebFetch",
|
||||||
@@ -160,6 +167,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["url", "prompt"],
|
"required": ["url", "prompt"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "WebSearch",
|
name: "WebSearch",
|
||||||
@@ -180,6 +188,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "TodoWrite",
|
name: "TodoWrite",
|
||||||
@@ -207,6 +216,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["todos"],
|
"required": ["todos"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::WorkspaceWrite,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "Skill",
|
name: "Skill",
|
||||||
@@ -220,6 +230,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["skill"],
|
"required": ["skill"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "Agent",
|
name: "Agent",
|
||||||
@@ -236,6 +247,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["description", "prompt"],
|
"required": ["description", "prompt"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "ToolSearch",
|
name: "ToolSearch",
|
||||||
@@ -249,6 +261,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "NotebookEdit",
|
name: "NotebookEdit",
|
||||||
@@ -265,6 +278,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["notebook_path"],
|
"required": ["notebook_path"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::WorkspaceWrite,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "Sleep",
|
name: "Sleep",
|
||||||
@@ -277,6 +291,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["duration_ms"],
|
"required": ["duration_ms"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "SendUserMessage",
|
name: "SendUserMessage",
|
||||||
@@ -297,6 +312,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["message", "status"],
|
"required": ["message", "status"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "Config",
|
name: "Config",
|
||||||
@@ -312,6 +328,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["setting"],
|
"required": ["setting"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::WorkspaceWrite,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "StructuredOutput",
|
name: "StructuredOutput",
|
||||||
@@ -320,6 +337,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::ReadOnly,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "REPL",
|
name: "REPL",
|
||||||
@@ -334,6 +352,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["code", "language"],
|
"required": ["code", "language"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "PowerShell",
|
name: "PowerShell",
|
||||||
@@ -349,6 +368,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["command"],
|
"required": ["command"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user