Compare commits

...

13 Commits

Author SHA1 Message Date
hikari 9e2f2912bc feat: add disable_1m_context toggle to settings UI
Adds a new config option to expose the CLAUDE_CODE_DISABLE_1M_CONTEXT
environment variable through the settings sidebar, allowing users to
opt out of the extended 1M context window without manually setting
environment variables.

Closes #154
2026-02-24 20:05:50 -08:00
hikari ca08b990d8 fix: extract last_assistant_message from ToolResult content in JSON stream
Previously the feature relied on SubagentStop hook events via stderr,
but Claude Code writes nothing to stderr. Instead, extract the agent's
final output from the ToolResult content block in the stdout JSON stream,
which contains the actual task result text. Handles both plain string
and array-of-content-block formats.
2026-02-24 19:49:21 -08:00
hikari 1fe39bbbc1 feat: surface last_assistant_message from SubagentStop hook payloads
Extracts last_assistant_message from SubagentStop hook events and
surfaces it in the agent monitor panel as a summary snippet below
each completed agent card.

Closes #156
2026-02-24 18:26:12 -08:00
hikari 5063a4f415 feat: display ConfigChange hook events in terminal
Detects [ConfigChange Hook] lines in stderr and emits them as
config-change line type with [config] prefix in purple.

Closes #151
2026-02-24 18:07:53 -08:00
hikari 54280b3920 feat: add worktree isolation support and hook event display
Closes #152, closes #150

- Add use_worktree bool to ClaudeStartOptions and HikariConfig (Rust + TS)
- Pass --worktree flag to claude in both WSL and non-WSL command paths
- Detect WorktreeCreate/WorktreeRemove hook events in stderr handler
- Emit worktree events with distinct line_type instead of error
- Add worktree line type to TerminalLine union, Terminal rendering, and tests
- Display worktree events in green with [worktree] prefix
- Add worktree isolation toggle to Agent Settings section of ConfigSidebar
- Thread use_worktree through all seven start_claude call sites
2026-02-24 17:39:09 -08:00
hikari 108a1b16b2 feat: detect prompt-too-long errors and offer compact action
Closes #158

- Detect "Prompt is too long" in assistant text blocks in wsl_bridge
- Emit error line type instead of assistant for better visibility
- Emit compact-prompt event after the error line
- Render a compact-prompt line as an action button in the terminal
- Clicking the button sends /compact to the active session
- Add getLineClass/getLinePrefix tests for compact-prompt type
2026-02-24 17:18:44 -08:00
hikari 1c02ca1bb5 feat: parse and display rate_limit_event messages from Claude CLI
Closes #155

- Add RateLimitInfo struct and RateLimitEvent variant to ClaudeMessage
- Emit rate-limit OutputEvent with human-readable message in wsl_bridge
- Add rate-limit line type to TerminalLine union and Terminal rendering
- Display rate-limit lines in amber with [rate-limit] prefix
- Add Terminal.test.ts with 28 tests for getLineClass, getLinePrefix,
  formatTime, isToolContentLong, and truncateToolContent
2026-02-24 16:28:33 -08:00
hikari ac965ebe92 feat: add auth status display and account management to settings sidebar
Implements issue #153. Adds Account section to ConfigSidebar with:
- Claude auth status (logged in/out, email, org, plan, API source)
- API key override indicator reading from local Hikari config
- Login/logout action buttons
- Refresh button for manual status updates

Adds Rust commands: get_auth_status, auth_login, auth_logout
2026-02-24 14:35:51 -08:00
hikari 5cdb4508aa feat: clean up CLI version display and add compatibility warnings
Strips the '(Claude Code)' suffix from the installed version badge so
it shows cleanly as 'CLI 2.1.50'. Also adds an italic warning message
after the supported badge when versions differ: amber text for newer
installs, red text for outdated installs.
2026-02-24 13:12:37 -08:00
hikari b914240d20 fix: extract semver before comparing CLI versions
The installed version string includes a suffix like '(Claude Code)'
which caused Number() to parse the third segment as NaN, making the
comparison always return 0 (current) instead of the correct result.
Now extracts the semver portion with a regex before comparing.
2026-02-24 12:57:39 -08:00
hikari 302963f6c0 feat: add supported CLI version badge to header
Adds a second badge alongside the existing CLI version display showing
the highest audited version (currently 2.1.33). The badge is
colour-coded: green when installed matches supported, amber when ahead,
red when behind, and neutral grey whilst loading.
2026-02-24 12:47:41 -08:00
hikari 986dd54dd9 feat: hold tab at connecting until greeting response arrives
Track greeting-pending conversations so the tab stays yellow until
Claude actually responds, rather than going green as soon as the
process starts. Also fixes the disconnect handler missing a status
update that left tabs stuck at yellow after the process died.
2026-02-24 12:31:24 -08:00
hikari 24313facca feat: collapsible tool lines in terminal
Long tool messages (e.g. Bash commands) are now stored in full and
displayed collapsed by default, with a toggle button to expand/collapse.
Removes backend truncation of Bash commands at 50 chars.
2026-02-24 10:44:22 -08:00
22 changed files with 1396 additions and 56 deletions
+130
View File
@@ -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)]
+18
View File
@@ -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();
+3
View File
@@ -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,
+100
View File
@@ -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
View File
@@ -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);
}
}
+4
View File
@@ -61,6 +61,8 @@ async function changeDirectory(path: string): Promise<void> {
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -135,6 +137,8 @@ async function startNewConversation(): Promise<void> {
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -318,6 +318,16 @@
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</div>
<!-- Last assistant message snippet -->
{#if agent.lastAssistantMessage}
<p
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
title={agent.lastAssistantMessage}
>
{agent.lastAssistantMessage}
</p>
{/if}
</div>
{/each}
{/if}
+97 -5
View File
@@ -2,15 +2,43 @@
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
let version = $state("Loading...");
const SUPPORTED_CLI_VERSION = "2.1.33";
let installedVersion = $state("Loading...");
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
let displayVersion = $derived(installedVersion.split(" (")[0]);
let supportedBadgeState = $derived.by(() => {
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
return "neutral";
}
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
if (!semverMatch) return "neutral";
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
if (cmp > 0) return "ahead";
if (cmp < 0) return "behind";
return "current";
});
async function fetchVersion() {
try {
const result = await invoke<string>("get_claude_version");
version = result;
installedVersion = result;
} catch (error) {
console.error("Failed to get Claude CLI version:", error);
version = "Unknown";
installedVersion = "Unknown";
}
}
@@ -19,7 +47,8 @@
});
</script>
<div class="cli-version">
<div class="cli-versions">
<div class="cli-version">
<svg
class="terminal-icon"
width="14"
@@ -34,10 +63,44 @@
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {version}</span>
<span class="version-text">CLI {displayVersion}</span>
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
</div>
{#if supportedBadgeState === "ahead"}
<span class="version-warning ahead"
>Your version is newer, some features may not be supported</span
>
{:else if supportedBadgeState === "behind"}
<span class="version-warning behind"
>Your version is out of date, please update to ensure compatibility</span
>
{/if}
</div>
<style>
.cli-versions {
display: flex;
gap: 6px;
align-items: center;
}
.cli-version {
display: flex;
align-items: center;
@@ -57,6 +120,21 @@
color: var(--accent-primary);
}
.cli-version.supported.current {
border-color: var(--success-color, #4caf50);
color: var(--success-color, #4caf50);
}
.cli-version.supported.ahead {
border-color: var(--warning-color, #ff9800);
color: var(--warning-color, #ff9800);
}
.cli-version.supported.behind {
border-color: var(--error-color, #f44336);
color: var(--error-color, #f44336);
}
.terminal-icon {
flex-shrink: 0;
opacity: 0.7;
@@ -65,4 +143,18 @@
.version-text {
white-space: nowrap;
}
.version-warning {
font-size: 0.75rem;
font-style: italic;
white-space: nowrap;
}
.version-warning.ahead {
color: var(--warning-color, #ff9800);
}
.version-warning.behind {
color: var(--error-color, #f44336);
}
</style>
+184
View File
@@ -12,6 +12,7 @@
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({
@@ -52,10 +53,26 @@
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
});
let showCustomThemeEditor = $state(false);
interface AuthStatus {
is_logged_in: boolean;
email: string | null;
org_name: string | null;
api_key_source: string | null;
api_provider: string | null;
subscription_type: string | null;
}
let authStatus: AuthStatus | null = $state(null);
let authLoading = $state(false);
let authActionLoading = $state(false);
let authError: string | null = $state(null);
let isOpen = $state(false);
let isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -69,6 +86,9 @@
configStore.isSidebarOpen.subscribe((open) => {
isOpen = open;
if (open && authStatus === null) {
void refreshAuthStatus();
}
});
configStore.saveError.subscribe((error) => {
@@ -111,6 +131,44 @@
"Task",
];
async function refreshAuthStatus() {
authLoading = true;
authError = null;
try {
authStatus = await invoke<AuthStatus>("get_auth_status");
} catch (e) {
authError = String(e);
} finally {
authLoading = false;
}
}
async function handleAuthLogin() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_login");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleAuthLogout() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_logout");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleSave() {
isSaving = true;
saveError = null;
@@ -228,6 +286,101 @@
</div>
{/if}
<!-- Account Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Account
</h3>
{#if authLoading}
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
{:else if authStatus}
<div class="flex items-center gap-2 mb-3">
<span
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
? 'bg-green-500'
: 'bg-red-500'}"
></span>
<span class="text-sm font-medium text-[var(--text-primary)]">
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
</span>
</div>
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
<dl class="text-xs space-y-1 mb-3">
{#if authStatus.email}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
</div>
{/if}
{#if authStatus.org_name}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
</div>
{/if}
{#if authStatus.api_key_source}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
</div>
{/if}
{#if authStatus.subscription_type}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
<dd class="text-[var(--text-primary)]">
{#if config.api_key}
{config.streamer_mode ? "Custom key set đź”’" : "Custom key set"}
{:else}
None
{/if}
</dd>
</div>
</dl>
{/if}
{:else}
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
{/if}
{#if authError}
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
{authError}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={refreshAuthStatus}
disabled={authLoading || authActionLoading}
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
Refresh
</button>
{#if authStatus && !authStatus.is_logged_in}
<button
onclick={handleAuthLogin}
disabled={authActionLoading}
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{authActionLoading ? "Logging in..." : "Login"}
</button>
{:else if authStatus && authStatus.is_logged_in}
<button
onclick={handleAuthLogout}
disabled={authActionLoading}
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{authActionLoading ? "Logging out..." : "Logout"}
</button>
{/if}
</div>
</section>
<!-- Agent Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -322,6 +475,37 @@
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
></textarea>
</div>
<!-- Worktree Isolation -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.use_worktree}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p>
</div>
<!-- Disable 1M Context Window -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_1m_context}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
context window
</p>
</div>
</section>
<!-- Greeting Section -->
+2
View File
@@ -362,6 +362,8 @@ User: ${formattedMessage}`;
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -87,6 +87,8 @@
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
+6
View File
@@ -101,6 +101,8 @@
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
});
let streamerModeActive = $state(false);
@@ -178,6 +180,8 @@
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
},
});
@@ -289,6 +293,8 @@
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
},
});
+118 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
@@ -92,6 +94,14 @@
return "terminal-error";
case "thinking":
return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default:
return "terminal-default";
}
@@ -109,6 +119,12 @@
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
@@ -187,6 +203,27 @@
copiedMessageId = null;
}, 2000);
}
async function handleCompact() {
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
// Collapsible tool lines
const TOOL_COLLAPSE_THRESHOLD = 60;
let expandedToolLines: Record<string, boolean> = {};
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
function toggleToolLine(id: string) {
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
}
</script>
<div
@@ -262,7 +299,11 @@
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant" || line.type === "user"}
{#if line.type === "compact-prompt"}
<button class="compact-action-btn" onclick={handleCompact}>
⚡ Compact Conversation
</button>
{:else if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
@@ -289,6 +330,22 @@
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
<span class="tool-collapsible">
<HighlightedText
content={expandedToolLines[line.id]
? maskPaths(line.content, hidePaths)
: truncateToolContent(maskPaths(line.content, hidePaths))}
searchQuery={currentSearchQuery}
/>
<button
class="tool-toggle-btn"
onclick={() => toggleToolLine(line.id)}
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
>
{expandedToolLines[line.id] ? "â–˛" : "â–Ľ"}
</button>
</span>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
@@ -329,6 +386,42 @@
color: var(--terminal-error, #f87171);
}
.terminal-rate-limit {
color: var(--terminal-rate-limit, #fb923c);
}
.terminal-compact-prompt {
color: var(--text-secondary);
}
.terminal-worktree {
color: var(--terminal-worktree, #34d399);
}
.terminal-config-change {
color: var(--terminal-config-change, #a78bfa);
}
.compact-action-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
background: var(--bg-secondary);
border: 1px solid var(--terminal-error, #f87171);
color: var(--terminal-error, #f87171);
padding: 0.3em 0.8em;
cursor: pointer;
border-radius: 4px;
font-size: 0.9em;
font-family: inherit;
transition: all 0.15s ease;
}
.compact-action-btn:hover {
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
color: var(--terminal-error, #f87171);
}
.terminal-default {
color: var(--text-primary);
}
@@ -408,4 +501,28 @@
.terminal-line {
position: relative;
}
.tool-collapsible {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.tool-toggle-btn {
background: none;
border: none;
color: var(--text-tertiary, #6b7280);
cursor: pointer;
font-size: 0.7em;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s ease;
font-family: inherit;
}
.tool-toggle-btn:hover {
opacity: 1;
color: var(--terminal-tool, #c084fc);
}
</style>
+264
View File
@@ -0,0 +1,264 @@
/**
* Terminal Component Tests
*
* Tests the pure helper functions extracted from the Terminal component:
* - getLineClass: maps line types to CSS class names
* - getLinePrefix: maps line types to display prefixes
* - formatTime: formats a Date as "HH:MM AM/PM"
* - isToolContentLong: checks if tool content exceeds collapse threshold
* - truncateToolContent: truncates long tool content with ellipsis
*
* Manual testing checklist:
* - [ ] rate-limit lines appear in amber
* - [ ] error lines appear in red
* - [ ] tool lines appear in purple
* - [ ] system lines appear in grey italic
* - [ ] user lines appear in cyan
* - [ ] assistant lines appear in primary text colour
* - [ ] long tool content is collapsed by default with a toggle button
*/
import { describe, it, expect } from "vitest";
// Mirror functions from Terminal.svelte for isolated testing
function getLineClass(type: string): string {
switch (type) {
case "user":
return "terminal-user";
case "assistant":
return "terminal-assistant";
case "system":
return "terminal-system italic";
case "tool":
return "terminal-tool";
case "error":
return "terminal-error";
case "thinking":
return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default:
return "terminal-default";
}
}
function getLinePrefix(type: string): string {
switch (type) {
case "user":
return ">";
case "assistant":
return "";
case "system":
return "[system]";
case "tool":
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
const TOOL_COLLAPSE_THRESHOLD = 60;
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
// ---
describe("getLineClass", () => {
it("returns terminal-user for user lines", () => {
expect(getLineClass("user")).toBe("terminal-user");
});
it("returns terminal-assistant for assistant lines", () => {
expect(getLineClass("assistant")).toBe("terminal-assistant");
});
it("returns terminal-system italic for system lines", () => {
expect(getLineClass("system")).toBe("terminal-system italic");
});
it("returns terminal-tool for tool lines", () => {
expect(getLineClass("tool")).toBe("terminal-tool");
});
it("returns terminal-error for error lines", () => {
expect(getLineClass("error")).toBe("terminal-error");
});
it("returns terminal-thinking for thinking lines", () => {
expect(getLineClass("thinking")).toBe("terminal-thinking");
});
it("returns terminal-rate-limit for rate-limit lines", () => {
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
});
it("returns terminal-compact-prompt for compact-prompt lines", () => {
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
});
it("returns terminal-worktree for worktree lines", () => {
expect(getLineClass("worktree")).toBe("terminal-worktree");
});
it("returns terminal-config-change for config-change lines", () => {
expect(getLineClass("config-change")).toBe("terminal-config-change");
});
it("returns terminal-default for unknown line types", () => {
expect(getLineClass("unknown")).toBe("terminal-default");
expect(getLineClass("")).toBe("terminal-default");
expect(getLineClass("random-future-type")).toBe("terminal-default");
});
});
describe("getLinePrefix", () => {
it("returns > for user lines", () => {
expect(getLinePrefix("user")).toBe(">");
});
it("returns empty string for assistant lines", () => {
expect(getLinePrefix("assistant")).toBe("");
});
it("returns [system] for system lines", () => {
expect(getLinePrefix("system")).toBe("[system]");
});
it("returns [tool] for tool lines", () => {
expect(getLinePrefix("tool")).toBe("[tool]");
});
it("returns [error] for error lines", () => {
expect(getLinePrefix("error")).toBe("[error]");
});
it("returns [rate-limit] for rate-limit lines", () => {
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
});
it("returns empty string for compact-prompt lines (button renders instead)", () => {
expect(getLinePrefix("compact-prompt")).toBe("");
});
it("returns [worktree] for worktree lines", () => {
expect(getLinePrefix("worktree")).toBe("[worktree]");
});
it("returns [config] for config-change lines", () => {
expect(getLinePrefix("config-change")).toBe("[config]");
});
it("returns empty string for thinking lines (no prefix)", () => {
expect(getLinePrefix("thinking")).toBe("");
});
it("returns empty string for unknown line types", () => {
expect(getLinePrefix("unknown")).toBe("");
expect(getLinePrefix("")).toBe("");
});
});
describe("formatTime", () => {
it("formats time in 12-hour format with AM/PM", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
});
it("formats afternoon times correctly", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toContain("02:35");
expect(formatted.toUpperCase()).toContain("PM");
});
it("formats morning times correctly", () => {
const date = new Date(2026, 1, 7, 9, 5);
const formatted = formatTime(date);
expect(formatted).toContain("09:05");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats midnight correctly", () => {
const date = new Date(2026, 1, 7, 0, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats noon correctly", () => {
const date = new Date(2026, 1, 7, 12, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("PM");
});
});
describe("isToolContentLong", () => {
it("returns false for content at or below the threshold", () => {
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
expect(isToolContentLong(exactThreshold)).toBe(false);
});
it("returns true for content exceeding the threshold", () => {
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
expect(isToolContentLong(overThreshold)).toBe(true);
});
it("returns false for short content", () => {
expect(isToolContentLong("short")).toBe(false);
});
it("returns false for empty content", () => {
expect(isToolContentLong("")).toBe(false);
});
});
describe("truncateToolContent", () => {
it("truncates content to the threshold length with an ellipsis", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
});
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
const short = "hello";
const result = truncateToolContent(short);
expect(result).toBe("hello…");
});
it("uses the unicode ellipsis character (not three dots)", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result.endsWith("…")).toBe(true);
expect(result.endsWith("...")).toBe(false);
});
});
@@ -106,6 +106,8 @@
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
+26
View File
@@ -177,6 +177,32 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged
});
it("stores lastAssistantMessage when provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(
conversationId,
agent.toolUseId,
Date.now(),
false,
"Task completed successfully."
);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
});
it("leaves lastAssistantMessage undefined when not provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBeUndefined();
});
});
describe("markAllErrored", () => {
+8 -1
View File
@@ -45,7 +45,13 @@ function createAgentStore() {
});
},
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
endAgent(
conversationId: string,
toolUseId: string,
endedAt: number,
isError: boolean,
lastAssistantMessage?: string
) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
@@ -62,6 +68,7 @@ function createAgentStore() {
endedAt,
status: isError ? "errored" : "completed",
durationMs,
lastAssistantMessage,
};
return {
+6
View File
@@ -194,6 +194,8 @@ describe("config store", () => {
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -240,6 +242,8 @@ describe("config store", () => {
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
};
expect(config.model).toBeNull();
@@ -785,6 +789,8 @@ describe("config store", () => {
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
};
const mockInvokeImpl = vi.mocked(invoke);
+6
View File
@@ -47,6 +47,10 @@ export interface HikariConfig {
discord_rpc_enabled: boolean;
// Thinking blocks settings
show_thinking_blocks: boolean;
// Worktree isolation
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
}
const defaultConfig: HikariConfig = {
@@ -87,6 +91,8 @@ const defaultConfig: HikariConfig = {
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
};
function createConfigStore() {
+79 -8
View File
@@ -29,6 +29,7 @@ interface StateChangePayload {
}
const connectedConversations = new Set<string>();
const greetingPendingConversations = new Set<string>();
let unlisteners: Array<() => void> = [];
let skipNextGreeting = false;
@@ -55,17 +56,17 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
}
async function sendGreeting(conversationId: string) {
async function sendGreeting(conversationId: string): Promise<boolean> {
// Check if we should skip this greeting
if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag
return;
return false;
}
const config = configStore.getConfig();
if (!config.greeting_enabled) {
return;
return false;
}
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
@@ -81,10 +82,12 @@ async function sendGreeting(conversationId: string) {
conversationId,
message: greetingPrompt,
});
return true;
} catch (error) {
console.error("Failed to send greeting:", error);
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000);
return false;
}
}
@@ -118,6 +121,7 @@ interface WorkingDirectoryPayload {
export async function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
greetingPendingConversations.delete(conversationId);
// Clean up any temp files associated with this conversation
try {
@@ -173,7 +177,24 @@ export async function initializeTauriListeners() {
if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
// Immediately hold the tab at yellow while we wait for the greeting response.
// This avoids a brief green flash before the greeting is even sent.
greetingPendingConversations.add(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connecting" as ConnectionStatus
);
const greetingSent = await sendGreeting(targetConversationId);
if (!greetingSent) {
// Greeting was disabled or failed — flip straight to connected.
greetingPendingConversations.delete(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connected" as ConnectionStatus
);
}
}
}
} else if (status === "disconnected") {
@@ -191,6 +212,7 @@ export async function initializeTauriListeners() {
// Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
}
// Don't add system message if we're about to reconnect
@@ -205,6 +227,14 @@ export async function initializeTauriListeners() {
todos.clear();
}
// Update the tab's connection status on real disconnects
if (!skipNextGreeting && targetConversationId) {
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"disconnected" as ConnectionStatus
);
}
// Update character state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
@@ -214,6 +244,7 @@ export async function initializeTauriListeners() {
if (targetConversationId) {
connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
}
@@ -275,11 +306,34 @@ export async function initializeTauriListeners() {
}
: undefined;
// Flip to connected when first assistant message arrives after greeting
if (
conversation_id &&
line_type === "assistant" &&
greetingPendingConversations.has(conversation_id)
) {
greetingPendingConversations.delete(conversation_id);
claudeStore.setConnectionStatusForConversation(
conversation_id,
"connected" as ConnectionStatus
);
}
// Always store the output to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
line_type as
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content,
tool_name || undefined,
costData,
@@ -288,7 +342,17 @@ export async function initializeTauriListeners() {
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
line_type as
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content,
tool_name || undefined,
costData,
@@ -410,10 +474,17 @@ export async function initializeTauriListeners() {
unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } =
event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
agentStore.endAgent(
targetConversationId,
tool_use_id,
ended_at,
is_error,
last_assistant_message
);
}
});
unlisteners.push(agentEndUnlisten);
+2
View File
@@ -12,6 +12,7 @@ export interface AgentInfo {
durationMs?: number;
characterName: string;
characterAvatar: string;
lastAssistantMessage?: string;
}
export interface AgentStartPayload {
@@ -31,4 +32,5 @@ export interface AgentEndPayload {
conversation_id?: string;
duration_ms?: number;
num_turns?: number;
last_assistant_message?: string;
}
+11 -1
View File
@@ -1,6 +1,16 @@
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
type:
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change";
content: string;
timestamp: Date;
toolName?: string;