generated from nhcarrigan/template
feat: stuffy feature bundle (#159)
## Summary This PR bundles a collection of new features and quality-of-life improvements identified during a Claude CLI 2.1.50 audit. - **Tab status indicator** — Tab stays yellow until the greeting is responded to, then turns green. Fixed disconnect not resetting to grey. Closes #157 - **Auth status display** — New "Account" section in settings sidebar showing login status, email, org, API key source, and Hikari override indicator. Includes login/logout buttons. Closes #153 - **CLI version badge** — New "Supported" badge showing the highest audited CLI version, colour-coded green/amber/red based on installed vs supported version. Closes #154 (bump to 2.1.50) - **Rate limit events** — `rate_limit_event` messages from the stream are now parsed and shown as amber `[rate-limit]` lines in the terminal instead of being silently dropped. Closes #155 - **"Prompt is too long" handling** — Detects this error in assistant messages and shows a ⚡ Compact Conversation button to send `/compact` directly. Closes #158 - **`last_assistant_message` in Agent Monitor** — Extracts the agent's final output from the `ToolResult` content block in the JSON stream and displays it as a snippet on completed agent cards. Closes #156 - **`--worktree` flag** — New "Worktree isolation" toggle in session settings passes `--worktree` to Claude Code. Hook events (`WorktreeCreate`/`WorktreeRemove`) are displayed as green `[worktree]` lines. Closes #152, Closes #150 - **ConfigChange hook events** — `[ConfigChange Hook]` stderr events are now displayed as cyan `[config]` lines instead of errors. Closes #151 - **`CLAUDE_CODE_DISABLE_1M_CONTEXT` toggle** — New "Disable 1M context" setting in session configuration injects this env var into the Claude process. Closes #154 ## Test plan - [ ] Tab status indicator: start a new session and verify the tab stays yellow until Claude responds to the greeting, then turns green - [ ] Auth status: open settings and verify the Account section shows correct login info - [ ] CLI version badge: verify the "Supported 2.1.50" badge shows green when CLI matches - [ ] Rate limit events: unit tests cover parsing; amber `[rate-limit]` lines display correctly - [ ] Compact button: unit tests cover detection; button renders correctly in terminal - [ ] Agent Monitor: use the Task tool and verify completed agent cards show a message snippet - [ ] Worktree: enable toggle, start session, verify `--worktree` flag appears in process args - [ ] ConfigChange: hook events display as `[config]` lines rather than errors - [ ] Disable 1M context: enable toggle, start session, verify `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` in `/proc/<pid>/environ` ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #159 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #159.
This commit is contained in:
@@ -1360,6 +1360,136 @@ pub async fn get_claude_version() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Auth Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClaudeAuthStatus {
|
||||
pub is_logged_in: bool,
|
||||
pub email: Option<String>,
|
||||
pub org_name: Option<String>,
|
||||
pub api_key_source: Option<String>,
|
||||
pub api_provider: Option<String>,
|
||||
pub subscription_type: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
tracing::debug!("Getting Claude auth status");
|
||||
|
||||
let output = create_claude_command()
|
||||
.args(["auth", "status"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude auth status: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
|
||||
let is_logged_in = json
|
||||
.get("loggedIn")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let email = json
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let org_name = json
|
||||
.get("orgName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let api_key_source = json
|
||||
.get("apiKeySource")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let api_provider = json
|
||||
.get("apiProvider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let subscription_type = json
|
||||
.get("subscriptionType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
tracing::info!("Claude auth status: logged_in={}", is_logged_in);
|
||||
Ok(ClaudeAuthStatus {
|
||||
is_logged_in,
|
||||
email,
|
||||
org_name,
|
||||
api_key_source,
|
||||
api_provider,
|
||||
subscription_type,
|
||||
})
|
||||
} else {
|
||||
// Non-JSON output: fall back to heuristic
|
||||
let lower = raw.to_lowercase();
|
||||
let is_logged_in = output.status.success()
|
||||
&& !lower.contains("not logged in")
|
||||
&& !lower.contains("not authenticated")
|
||||
&& !lower.contains("no account");
|
||||
tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in);
|
||||
Ok(ClaudeAuthStatus {
|
||||
is_logged_in,
|
||||
email: None,
|
||||
org_name: None,
|
||||
api_key_source: None,
|
||||
api_provider: None,
|
||||
subscription_type: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_login() -> Result<String, String> {
|
||||
tracing::info!("Running claude auth login");
|
||||
|
||||
let output = create_claude_command()
|
||||
.args(["auth", "login"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude auth login: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
|
||||
if output.status.success() {
|
||||
let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout };
|
||||
tracing::info!("Claude auth login succeeded");
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = if stderr.is_empty() { stdout } else { stderr };
|
||||
tracing::error!("Claude auth login failed: {}", error);
|
||||
Err(format!("Login failed: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_logout() -> Result<String, String> {
|
||||
tracing::info!("Running claude auth logout");
|
||||
|
||||
let output = create_claude_command()
|
||||
.args(["auth", "logout"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude auth logout: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
|
||||
if output.status.success() {
|
||||
let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout };
|
||||
tracing::info!("Claude auth logout succeeded");
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = if stderr.is_empty() { stdout } else { stderr };
|
||||
tracing::error!("Claude auth logout failed: {}", error);
|
||||
Err(format!("Logout failed: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Plugin Management Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -25,6 +25,12 @@ pub struct ClaudeStartOptions {
|
||||
|
||||
#[serde(default)]
|
||||
pub resume_session_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub use_worktree: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_1m_context: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -113,6 +119,12 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default = "default_discord_rpc_enabled")]
|
||||
pub discord_rpc_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub use_worktree: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_1m_context: bool,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -145,6 +157,8 @@ impl Default for HikariConfig {
|
||||
budget_action: BudgetAction::Warn,
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +266,8 @@ mod tests {
|
||||
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||
assert!(config.discord_rpc_enabled);
|
||||
assert!(!config.use_worktree);
|
||||
assert!(!config.disable_1m_context);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -284,6 +300,8 @@ mod tests {
|
||||
budget_action: BudgetAction::Block,
|
||||
budget_warning_threshold: 0.75,
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: true,
|
||||
disable_1m_context: false,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -195,6 +195,9 @@ pub fn run() {
|
||||
close_application,
|
||||
list_memory_files,
|
||||
get_claude_version,
|
||||
get_auth_status,
|
||||
auth_login,
|
||||
auth_logout,
|
||||
list_plugins,
|
||||
install_plugin,
|
||||
uninstall_plugin,
|
||||
|
||||
@@ -63,6 +63,26 @@ pub struct PermissionDenial {
|
||||
pub tool_input: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Rate limit information from a `rate_limit_event` message.
|
||||
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RateLimitInfo {
|
||||
#[serde(default)]
|
||||
pub requests_limit: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub requests_remaining: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub requests_reset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tokens_limit: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tokens_remaining: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tokens_reset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub retry_after_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ClaudeMessage {
|
||||
@@ -100,6 +120,11 @@ pub enum ClaudeMessage {
|
||||
#[serde(default)]
|
||||
usage: Option<UsageInfo>,
|
||||
},
|
||||
#[serde(rename = "rate_limit_event")]
|
||||
RateLimitEvent {
|
||||
#[serde(default)]
|
||||
rate_limit_info: RateLimitInfo,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -280,6 +305,8 @@ pub struct AgentEndEvent {
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_turns: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_assistant_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -446,4 +473,77 @@ mod tests {
|
||||
assert!(serialized.contains("\"input_tokens\":100"));
|
||||
assert!(serialized.contains("\"output_tokens\":50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_info_default() {
|
||||
let info = RateLimitInfo::default();
|
||||
assert!(info.requests_limit.is_none());
|
||||
assert!(info.requests_remaining.is_none());
|
||||
assert!(info.requests_reset.is_none());
|
||||
assert!(info.tokens_limit.is_none());
|
||||
assert!(info.tokens_remaining.is_none());
|
||||
assert!(info.tokens_reset.is_none());
|
||||
assert!(info.retry_after_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_deserialization_empty_info() {
|
||||
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_deserialization_no_info() {
|
||||
// rate_limit_info field is optional via #[serde(default)]
|
||||
let json = r#"{"type":"rate_limit_event"}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_deserialization_with_data() {
|
||||
let json = r#"{
|
||||
"type": "rate_limit_event",
|
||||
"rate_limit_info": {
|
||||
"requests_limit": 1000,
|
||||
"requests_remaining": 0,
|
||||
"requests_reset": "2024-01-01T00:01:00Z",
|
||||
"tokens_limit": 50000,
|
||||
"tokens_remaining": 0,
|
||||
"tokens_reset": "2024-01-01T00:01:00Z",
|
||||
"retry_after_ms": 60000
|
||||
}
|
||||
}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
||||
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||
assert_eq!(
|
||||
rate_limit_info.requests_reset,
|
||||
Some("2024-01-01T00:01:00Z".to_string())
|
||||
);
|
||||
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
||||
} else {
|
||||
panic!("Expected RateLimitEvent variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_ignores_unknown_fields() {
|
||||
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
||||
let json = r#"{
|
||||
"type": "rate_limit_event",
|
||||
"rate_limit_info": {
|
||||
"requests_remaining": 0,
|
||||
"some_future_field": "some_value"
|
||||
}
|
||||
}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||
} else {
|
||||
panic!("Expected RateLimitEvent variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+304
-26
@@ -265,6 +265,11 @@ impl WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// Add worktree flag if requested
|
||||
if options.use_worktree {
|
||||
cmd.arg("--worktree");
|
||||
}
|
||||
|
||||
cmd.current_dir(working_dir);
|
||||
|
||||
// Set API key as environment variable if specified
|
||||
@@ -274,6 +279,11 @@ impl WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// Disable 1M context window if requested
|
||||
if options.disable_1m_context {
|
||||
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
|
||||
}
|
||||
|
||||
cmd
|
||||
} else {
|
||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||
@@ -314,6 +324,11 @@ impl WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// Disable 1M context window if requested
|
||||
if options.disable_1m_context {
|
||||
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
|
||||
}
|
||||
|
||||
claude_cmd.push_str(
|
||||
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||
);
|
||||
@@ -351,6 +366,11 @@ impl WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// Add worktree flag if requested
|
||||
if options.use_worktree {
|
||||
claude_cmd.push_str(" --worktree");
|
||||
}
|
||||
|
||||
// Use bash -lc to load login profile (ensures PATH includes claude)
|
||||
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||
|
||||
@@ -745,17 +765,28 @@ fn handle_stderr(
|
||||
conversation_id: conversation_id.clone(),
|
||||
duration_ms: None,
|
||||
num_turns: None,
|
||||
last_assistant_message: stop_data.last_assistant_message,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Still emit the stderr line as output
|
||||
// Hook events are informational — emit with distinct types instead of error
|
||||
let line_type = if line.contains("[WorktreeCreate Hook]")
|
||||
|| line.contains("[WorktreeRemove Hook]")
|
||||
{
|
||||
"worktree"
|
||||
} else if line.contains("[ConfigChange Hook]") {
|
||||
"config-change"
|
||||
} else {
|
||||
"error"
|
||||
};
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
line_type: line_type.to_string(),
|
||||
content: line,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
@@ -808,27 +839,78 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
#[derive(Debug)]
|
||||
struct SubagentStopData {
|
||||
parent_tool_use_id: Option<String>,
|
||||
last_assistant_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line.
|
||||
/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline).
|
||||
/// Returns `None` if the field is absent or formatted as `None`.
|
||||
fn extract_debug_string_value(line: &str, key: &str) -> Option<String> {
|
||||
let prefix = format!("{}=Some(\"", key);
|
||||
let start_idx = line.find(&prefix)? + prefix.len();
|
||||
let rest = &line[start_idx..];
|
||||
|
||||
let mut result = String::new();
|
||||
let mut chars = rest.chars();
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('"') => return Some(result),
|
||||
Some('\\') => match chars.next() {
|
||||
Some('n') => result.push('\n'),
|
||||
Some('t') => result.push('\t'),
|
||||
Some('"') => result.push('"'),
|
||||
Some('\\') => result.push('\\'),
|
||||
Some(c) => {
|
||||
result.push('\\');
|
||||
result.push(c);
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
Some(c) => result.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
|
||||
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
|
||||
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ...
|
||||
|
||||
// Extract parent_tool_use_id if present
|
||||
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
||||
line.split("parent_tool_use_id=Some(\"")
|
||||
.nth(1)?
|
||||
.split('"')
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id");
|
||||
let last_assistant_message = extract_debug_string_value(line, "last_assistant_message");
|
||||
|
||||
Some(SubagentStopData {
|
||||
parent_tool_use_id,
|
||||
last_assistant_message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract text content from a ToolResult's `content` field.
|
||||
/// The content may be a JSON string or an array of typed content blocks.
|
||||
fn extract_tool_result_text(content: &serde_json::Value) -> Option<String> {
|
||||
match content {
|
||||
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||
serde_json::Value::Array(blocks) => {
|
||||
let texts: Vec<String> = blocks
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
if block.get("type")?.as_str()? == "text" {
|
||||
block.get("text")?.as_str().map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if texts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(texts.join("\n"))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn process_json_line(
|
||||
line: &str,
|
||||
app: &AppHandle,
|
||||
@@ -1082,17 +1164,37 @@ fn process_json_line(
|
||||
stats.write().increment_code_blocks();
|
||||
}
|
||||
|
||||
let is_prompt_too_long = text.starts_with("Prompt is too long");
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "assistant".to_string(),
|
||||
line_type: if is_prompt_too_long {
|
||||
"error".to_string()
|
||||
} else {
|
||||
"assistant".to_string()
|
||||
},
|
||||
content: text.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: message_cost.clone(), // Include cost with assistant text
|
||||
cost: message_cost.clone(),
|
||||
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
if is_prompt_too_long {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "compact-prompt".to_string(),
|
||||
content: String::new(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
state = CharacterState::Thinking;
|
||||
@@ -1110,8 +1212,8 @@ fn process_json_line(
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
// Emit agent-end for all tool results
|
||||
// The frontend will ignore IDs that don't match known agents
|
||||
@@ -1129,6 +1231,7 @@ fn process_json_line(
|
||||
conversation_id: conversation_id.clone(),
|
||||
duration_ms: None,
|
||||
num_turns: None,
|
||||
last_assistant_message: extract_tool_result_text(content),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1521,6 +1624,23 @@ fn process_json_line(
|
||||
emit_state_change(app, state, None, conversation_id.clone());
|
||||
}
|
||||
|
||||
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
|
||||
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
|
||||
|
||||
let content = format_rate_limit_message(rate_limit_info);
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "rate-limit".to_string(),
|
||||
content,
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ClaudeMessage::User { message } => {
|
||||
// Increment message count for user messages
|
||||
stats.write().increment_messages();
|
||||
@@ -1529,8 +1649,8 @@ fn process_json_line(
|
||||
for block in &message.content {
|
||||
if let ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
let now = SystemTime::now()
|
||||
@@ -1547,6 +1667,7 @@ fn process_json_line(
|
||||
conversation_id: conversation_id.clone(),
|
||||
duration_ms: None,
|
||||
num_turns: None,
|
||||
last_assistant_message: extract_tool_result_text(content),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1629,6 +1750,35 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) {
|
||||
parts.push(format!("requests: {}/{}", remaining, limit));
|
||||
}
|
||||
|
||||
if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) {
|
||||
parts.push(format!("tokens: {}/{}", remaining, limit));
|
||||
}
|
||||
|
||||
if let Some(reset) = &info.requests_reset {
|
||||
parts.push(format!("resets at {}", reset));
|
||||
} else if let Some(reset) = &info.tokens_reset {
|
||||
parts.push(format!("resets at {}", reset));
|
||||
}
|
||||
|
||||
if let Some(retry_ms) = info.retry_after_ms {
|
||||
let secs = retry_ms / 1000;
|
||||
parts.push(format!("retry after {}s", secs));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
"Rate limit reached".to_string()
|
||||
} else {
|
||||
format!("Rate limit reached — {}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
// Helper function to check if a path is a memory file
|
||||
fn is_memory_path(path: &str) -> bool {
|
||||
@@ -1689,12 +1839,7 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
}
|
||||
"Bash" => {
|
||||
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
|
||||
let truncated = if cmd.len() > 50 {
|
||||
format!("{}...", &cmd[..50])
|
||||
} else {
|
||||
cmd.to_string()
|
||||
};
|
||||
format!("Running: {}", truncated)
|
||||
format!("Running: {}", cmd)
|
||||
} else {
|
||||
"Running command...".to_string()
|
||||
}
|
||||
@@ -1855,9 +2000,7 @@ mod tests {
|
||||
let long_cmd = "a".repeat(100);
|
||||
let input = serde_json::json!({"command": long_cmd});
|
||||
let desc = format_tool_description("Bash", &input);
|
||||
assert!(desc.starts_with("Running: "));
|
||||
assert!(desc.ends_with("..."));
|
||||
assert!(desc.len() < 70);
|
||||
assert_eq!(desc, format!("Running: {}", long_cmd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2076,5 +2219,140 @@ mod tests {
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
assert_eq!(data.last_assistant_message, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_last_message() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
|
||||
assert_eq!(
|
||||
data.last_assistant_message,
|
||||
Some("Task completed successfully.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_last_message_none() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.last_assistant_message, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_debug_string_value_simple() {
|
||||
let line = r#"key=Some("hello world")"#;
|
||||
assert_eq!(
|
||||
extract_debug_string_value(line, "key"),
|
||||
Some("hello world".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_debug_string_value_with_escaped_quotes() {
|
||||
let line = r#"key=Some("say \"hi\" there")"#;
|
||||
assert_eq!(
|
||||
extract_debug_string_value(line, "key"),
|
||||
Some(r#"say "hi" there"#.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_debug_string_value_none_variant() {
|
||||
let line = "key=None";
|
||||
assert_eq!(extract_debug_string_value(line, "key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_debug_string_value_missing_key() {
|
||||
let line = "other=Some(\"value\")";
|
||||
assert_eq!(extract_debug_string_value(line, "key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_commas_in_message() {
|
||||
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(
|
||||
data.last_assistant_message,
|
||||
Some("Found 3 files, all passing.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// extract_tool_result_text tests
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_plain_string() {
|
||||
let content = serde_json::json!("Hello from agent");
|
||||
assert_eq!(
|
||||
extract_tool_result_text(&content),
|
||||
Some("Hello from agent".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_empty_string() {
|
||||
let content = serde_json::json!("");
|
||||
assert_eq!(extract_tool_result_text(&content), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_array_single_text_block() {
|
||||
let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]);
|
||||
assert_eq!(
|
||||
extract_tool_result_text(&content),
|
||||
Some("Agent completed the task.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_array_multiple_text_blocks() {
|
||||
let content = serde_json::json!([
|
||||
{"type": "text", "text": "First part."},
|
||||
{"type": "text", "text": "Second part."}
|
||||
]);
|
||||
assert_eq!(
|
||||
extract_tool_result_text(&content),
|
||||
Some("First part.\nSecond part.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_array_non_text_block() {
|
||||
let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]);
|
||||
assert_eq!(extract_tool_result_text(&content), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_array_mixed_blocks() {
|
||||
let content = serde_json::json!([
|
||||
{"type": "image", "source": {}},
|
||||
{"type": "text", "text": "Found results."}
|
||||
]);
|
||||
assert_eq!(
|
||||
extract_tool_result_text(&content),
|
||||
Some("Found results.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_null() {
|
||||
let content = serde_json::Value::Null;
|
||||
assert_eq!(extract_tool_result_text(&content), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_empty_array() {
|
||||
let content = serde_json::json!([]);
|
||||
assert_eq!(extract_tool_result_text(&content), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user