From b88f25a61b5a449ca3608dfa7cb3124803df2c20 Mon Sep 17 00:00:00 2001
From: Hikari
Date: Mon, 13 Apr 2026 13:32:03 -0700
Subject: [PATCH] feat: CLI v2.1.81 features + global CLAUDE.md editor (#263)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Implements support for Claude Code CLI v2.1.81 features and adds a global CLAUDE.md editor, closing issues #237, #239, #244, #245, #246, #247, #248, and #262.
### Stream-JSON forward-compatibility (#245, #246, #247, #248)
- **#248** — `output_style` field added to `System` init message; silently accepted for forward-compat
- **#245** — `fast_mode_state` field added to `Result` message; logged at debug level
- **#246** — `model_usage` field added to `Result` message; per-model breakdown logged at debug level
- **#247** — `total_cost_usd` field added to `Result` message; authoritative cost logged at debug level
### New config options (#237, #239, #244)
- **#237** — `bare_mode` config toggle: passes `--bare` to Claude Code, suppressing UI chrome for scripted headless `-p` calls
- **#239** — `show_clear_context_on_plan_accept` toggle: passes `showClearContextOnPlanAccept: false` in `--settings` when disabled
- **#244** — `custom_model_option` text field: sets `ANTHROPIC_CUSTOM_MODEL_OPTION` env var for custom model providers
### Global CLAUDE.md editor (#262)
- New Tauri commands `get_global_claude_md` / `save_global_claude_md` read/write `~/.claude/CLAUDE.md` (creates file + directory if absent)
- New "Global Instructions" section in the Config Sidebar with a textarea and Save button
### Bug fix (pre-existing)
`disable_cron` and `disable_skill_shell_execution` were saved to `HikariConfig` but never passed to `start_claude` invocations — fixed in all 9 call sites. All 3 new config fields are also wired through all 9 call sites.
All changes pass `check-all.sh` (ESLint → Prettier → svelte-check → Vitest → Clippy → cargo test with llvm-cov).
✨ This PR was created with help from Hikari~ 🌸
Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/263
Co-authored-by: Hikari
Co-committed-by: Hikari
---
src-tauri/src/commands.rs | 51 ++++++++
src-tauri/src/config.rs | 38 ++++++
src-tauri/src/lib.rs | 2 +
src-tauri/src/types.rs | 75 ++++++++++++
src-tauri/src/wsl_bridge.rs | 73 ++++++++++-
src/lib/commands/slashCommands.ts | 10 ++
src/lib/components/ConfigSidebar.svelte | 127 ++++++++++++++++++++
src/lib/components/ElicitationModal.svelte | 5 +
src/lib/components/InputBar.svelte | 5 +
src/lib/components/PermissionModal.svelte | 5 +
src/lib/components/StatusBar.svelte | 15 +++
src/lib/components/TaskLoopPanel.svelte | 5 +
src/lib/components/UserQuestionModal.svelte | 5 +
src/lib/stores/config.test.ts | 9 ++
src/lib/stores/config.ts | 9 ++
15 files changed, 432 insertions(+), 2 deletions(-)
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index 32c96ea..bbc1ef0 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -2618,6 +2618,39 @@ pub async fn open_binary_file(app: AppHandle, path: String) -> Result<(), String
}
}
+/// Read the contents of `~/.claude/CLAUDE.md`.
+/// Returns an empty string if the file does not exist.
+#[tauri::command]
+pub async fn get_global_claude_md() -> Result {
+ let path = dirs::home_dir()
+ .ok_or_else(|| "Could not determine home directory".to_string())?
+ .join(".claude")
+ .join("CLAUDE.md");
+
+ if !path.exists() {
+ return Ok(String::new());
+ }
+
+ std::fs::read_to_string(&path).map_err(|e| format!("Failed to read CLAUDE.md: {}", e))
+}
+
+/// Write content to `~/.claude/CLAUDE.md`.
+/// Creates the file (and `~/.claude/` directory) if they do not exist.
+#[tauri::command]
+pub async fn save_global_claude_md(content: String) -> Result<(), String> {
+ let claude_dir = dirs::home_dir()
+ .ok_or_else(|| "Could not determine home directory".to_string())?
+ .join(".claude");
+
+ if !claude_dir.exists() {
+ std::fs::create_dir_all(&claude_dir)
+ .map_err(|e| format!("Failed to create ~/.claude directory: {}", e))?;
+ }
+
+ let path = claude_dir.join("CLAUDE.md");
+ std::fs::write(&path, content).map_err(|e| format!("Failed to write CLAUDE.md: {}", e))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -3367,4 +3400,22 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
let (_, args) = build_wslpath_command(path);
assert_eq!(args[2], path);
}
+
+ #[test]
+ fn test_get_global_claude_md_path_construction() {
+ // Verify that home_dir() resolves successfully on the test platform
+ let home = dirs::home_dir();
+ assert!(home.is_some(), "home_dir() should be available in test environment");
+ let expected = home.unwrap().join(".claude").join("CLAUDE.md");
+ assert!(expected.to_string_lossy().contains(".claude"));
+ assert!(expected.to_string_lossy().ends_with("CLAUDE.md"));
+ }
+
+ #[test]
+ fn test_save_global_claude_md_dir_path_construction() {
+ let home = dirs::home_dir();
+ assert!(home.is_some());
+ let dir = home.unwrap().join(".claude");
+ assert!(dir.to_string_lossy().contains(".claude"));
+ }
}
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index fc89406..9ca2af7 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -57,6 +57,19 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub disable_skill_shell_execution: bool,
+
+ /// Pass `--bare` flag to suppress UI chrome, useful for scripted headless `-p` calls (v2.1.81+).
+ #[serde(default)]
+ pub bare_mode: bool,
+
+ /// Controls `showClearContextOnPlanAccept` in `--settings` (v2.1.81+).
+ /// Defaults to true (matching CLI default). Set to false to suppress the dialog.
+ #[serde(default = "default_show_clear_context")]
+ pub show_clear_context_on_plan_accept: bool,
+
+ /// Sets `ANTHROPIC_CUSTOM_MODEL_OPTION` env var for custom model providers (v2.1.81+).
+ #[serde(default)]
+ pub custom_model_option: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -211,6 +224,18 @@ pub struct HikariConfig {
/// Passes `"disableSkillShellExecution": true` via the `--settings` flag.
#[serde(default)]
pub disable_skill_shell_execution: bool,
+
+ /// Pass `--bare` flag to suppress UI chrome, useful for scripted headless `-p` calls (v2.1.81+).
+ #[serde(default)]
+ pub bare_mode: bool,
+
+ /// Controls `showClearContextOnPlanAccept` in `--settings` (v2.1.81+).
+ #[serde(default = "default_show_clear_context")]
+ pub show_clear_context_on_plan_accept: bool,
+
+ /// Sets `ANTHROPIC_CUSTOM_MODEL_OPTION` env var for custom model providers (v2.1.81+).
+ #[serde(default)]
+ pub custom_model_option: Option,
}
impl Default for HikariConfig {
@@ -263,6 +288,9 @@ impl Default for HikariConfig {
auto_memory_directory: None,
model_overrides: None,
disable_skill_shell_execution: false,
+ bare_mode: false,
+ show_clear_context_on_plan_accept: true,
+ custom_model_option: None,
}
}
}
@@ -315,6 +343,10 @@ fn default_enable_claudeai_mcp_servers() -> bool {
true
}
+fn default_show_clear_context() -> bool {
+ true
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
@@ -415,6 +447,9 @@ mod tests {
assert!(config.auto_memory_directory.is_none());
assert!(config.model_overrides.is_none());
assert!(!config.disable_skill_shell_execution);
+ assert!(!config.bare_mode);
+ assert!(config.show_clear_context_on_plan_accept);
+ assert!(config.custom_model_option.is_none());
}
#[test]
@@ -470,6 +505,9 @@ mod tests {
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
)])),
disable_skill_shell_execution: true,
+ bare_mode: false,
+ show_clear_context_on_plan_accept: true,
+ custom_model_option: None,
};
let json = serde_json::to_string(&config).unwrap();
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 0e1ec59..b81b434 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -224,6 +224,8 @@ pub fn run() {
delete_all_drafts,
scan_project,
open_binary_file,
+ get_global_claude_md,
+ save_global_claude_md,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs
index 097b67f..44cdf77 100644
--- a/src-tauri/src/types.rs
+++ b/src-tauri/src/types.rs
@@ -95,6 +95,9 @@ pub enum ClaudeMessage {
cwd: Option,
#[serde(default)]
tools: Option>,
+ /// Output style hint from Claude Code (v2.1.81+). Informational only.
+ #[serde(default)]
+ output_style: Option,
},
#[serde(rename = "assistant")]
Assistant {
@@ -119,6 +122,15 @@ pub enum ClaudeMessage {
permission_denials: Option>,
#[serde(default)]
usage: Option,
+ /// Fast mode state from Claude Code v2.1.81+. Values: "default" | "enabled" | "disabled".
+ #[serde(default)]
+ fast_mode_state: Option,
+ /// Per-model usage breakdown from Claude Code v2.1.81+.
+ #[serde(default)]
+ model_usage: Option,
+ /// Authoritative total cost in USD reported by Claude Code v2.1.81+.
+ #[serde(default)]
+ total_cost_usd: Option,
},
#[serde(rename = "rate_limit_event")]
RateLimitEvent {
@@ -910,4 +922,67 @@ mod tests {
assert!(!serialized.contains("reason"));
assert!(!serialized.contains("conversation_id"));
}
+
+ #[test]
+ fn test_system_init_with_output_style() {
+ let json = r#"{"type":"system","subtype":"init","session_id":"sess-1","output_style":"auto"}"#;
+ let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
+ if let ClaudeMessage::System { output_style, .. } = msg {
+ assert_eq!(output_style, Some("auto".to_string()));
+ } else {
+ panic!("Expected System variant");
+ }
+ }
+
+ #[test]
+ fn test_system_init_without_output_style() {
+ let json = r#"{"type":"system","subtype":"init","session_id":"sess-1"}"#;
+ let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
+ if let ClaudeMessage::System { output_style, .. } = msg {
+ assert!(output_style.is_none());
+ } else {
+ panic!("Expected System variant");
+ }
+ }
+
+ #[test]
+ fn test_result_message_with_fast_mode_state() {
+ let json = r#"{"type":"result","subtype":"success","fast_mode_state":"enabled"}"#;
+ let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
+ if let ClaudeMessage::Result { fast_mode_state, .. } = msg {
+ assert_eq!(fast_mode_state, Some("enabled".to_string()));
+ } else {
+ panic!("Expected Result variant");
+ }
+ }
+
+ #[test]
+ fn test_result_message_with_total_cost_usd() {
+ let json = r#"{"type":"result","subtype":"success","total_cost_usd":0.05}"#;
+ let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
+ if let ClaudeMessage::Result { total_cost_usd, .. } = msg {
+ assert!((total_cost_usd.unwrap() - 0.05).abs() < f64::EPSILON);
+ } else {
+ panic!("Expected Result variant");
+ }
+ }
+
+ #[test]
+ fn test_result_message_without_new_fields() {
+ let json = r#"{"type":"result","subtype":"success"}"#;
+ let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
+ if let ClaudeMessage::Result {
+ fast_mode_state,
+ model_usage,
+ total_cost_usd,
+ ..
+ } = msg
+ {
+ assert!(fast_mode_state.is_none());
+ assert!(model_usage.is_none());
+ assert!(total_cost_usd.is_none());
+ } else {
+ panic!("Expected Result variant");
+ }
+ }
}
diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs
index 6a8f9a6..4197fdf 100644
--- a/src-tauri/src/wsl_bridge.rs
+++ b/src-tauri/src/wsl_bridge.rs
@@ -303,6 +303,11 @@ impl WslBridge {
cmd.arg("--worktree");
}
+ // Add bare flag if requested (v2.1.81+)
+ if options.bare_mode {
+ cmd.arg("--bare");
+ }
+
// Pass combined settings via --settings flag if any settings are specified
{
let has_memory_dir = options
@@ -315,8 +320,13 @@ impl WslBridge {
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false);
+ let suppress_clear_context = !options.show_clear_context_on_plan_accept;
- if has_memory_dir || has_overrides || options.disable_skill_shell_execution {
+ if has_memory_dir
+ || has_overrides
+ || options.disable_skill_shell_execution
+ || suppress_clear_context
+ {
let mut settings = serde_json::Map::new();
if let Some(ref dir) = options.auto_memory_directory {
if !dir.is_empty() {
@@ -339,6 +349,12 @@ impl WslBridge {
serde_json::Value::Bool(true),
);
}
+ if suppress_clear_context {
+ settings.insert(
+ "showClearContextOnPlanAccept".to_string(),
+ serde_json::Value::Bool(false),
+ );
+ }
if let Ok(settings_json) = serde_json::to_string(&settings) {
cmd.args(["--settings", &settings_json]);
}
@@ -379,6 +395,13 @@ impl WslBridge {
cmd.env("ENABLE_CLAUDEAI_MCP_SERVERS", "false");
}
+ // Set custom model option if specified (v2.1.81+)
+ if let Some(ref custom_opt) = options.custom_model_option {
+ if !custom_opt.is_empty() {
+ cmd.env("ANTHROPIC_CUSTOM_MODEL_OPTION", custom_opt);
+ }
+ }
+
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
@@ -446,6 +469,14 @@ impl WslBridge {
claude_cmd.push_str("ENABLE_CLAUDEAI_MCP_SERVERS=false ");
}
+ // Set custom model option if specified (v2.1.81+)
+ if let Some(ref custom_opt) = options.custom_model_option {
+ if !custom_opt.is_empty() {
+ let escaped = custom_opt.replace('\'', "'\\''");
+ claude_cmd.push_str(&format!("ANTHROPIC_CUSTOM_MODEL_OPTION='{}' ", escaped));
+ }
+ }
+
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
@@ -496,6 +527,11 @@ impl WslBridge {
claude_cmd.push_str(" --worktree");
}
+ // Add bare flag if requested (v2.1.81+)
+ if options.bare_mode {
+ claude_cmd.push_str(" --bare");
+ }
+
// Pass combined settings via --settings flag if any settings are specified
{
let has_memory_dir = options
@@ -508,8 +544,13 @@ impl WslBridge {
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false);
+ let suppress_clear_context = !options.show_clear_context_on_plan_accept;
- if has_memory_dir || has_overrides || options.disable_skill_shell_execution {
+ if has_memory_dir
+ || has_overrides
+ || options.disable_skill_shell_execution
+ || suppress_clear_context
+ {
let mut settings = serde_json::Map::new();
if let Some(ref dir) = options.auto_memory_directory {
if !dir.is_empty() {
@@ -532,6 +573,12 @@ impl WslBridge {
serde_json::Value::Bool(true),
);
}
+ if suppress_clear_context {
+ settings.insert(
+ "showClearContextOnPlanAccept".to_string(),
+ serde_json::Value::Bool(false),
+ );
+ }
if let Ok(settings_json) = serde_json::to_string(&settings) {
let escaped = settings_json.replace('\'', "'\\''");
claude_cmd.push_str(&format!(" --settings '{}'", escaped));
@@ -2122,6 +2169,9 @@ fn process_json_line(
usage,
duration_ms,
num_turns,
+ fast_mode_state,
+ model_usage,
+ total_cost_usd,
} => {
tracing::info!(
"Received Result message: subtype={}, has_denials={}, denial_count={:?}",
@@ -2206,6 +2256,25 @@ fn process_json_line(
});
}
+ // Log fast mode state if present (v2.1.81+)
+ if let Some(ref state) = fast_mode_state {
+ tracing::debug!("Fast mode state: {}", state);
+ }
+
+ // Log per-model usage if available (v2.1.81+)
+ if let Some(ref model_usage_val) = model_usage {
+ if let Some(map) = model_usage_val.as_object() {
+ for (model_name, _usage_val) in map {
+ tracing::debug!("Per-model usage logged for: {}", model_name);
+ }
+ }
+ }
+
+ // Log authoritative cost from Claude Code if available (v2.1.81+)
+ if let Some(auth_cost) = total_cost_usd {
+ tracing::debug!("Authoritative total cost from Claude Code: ${:.6}", auth_cost);
+ }
+
// Clear tracking fields since request completed successfully
{
let mut stats_guard = stats.write();
diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts
index d19e871..4bef72e 100644
--- a/src/lib/commands/slashCommands.ts
+++ b/src/lib/commands/slashCommands.ts
@@ -67,11 +67,16 @@ async function changeDirectory(path: string): Promise {
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
max_output_tokens: config.max_output_tokens ?? null,
+ disable_cron: config.disable_cron ?? false,
+ disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
include_git_instructions: config.include_git_instructions ?? true,
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
auto_memory_directory: config.auto_memory_directory || null,
model_overrides: config.model_overrides || null,
session_name: null,
+ bare_mode: config.bare_mode ?? false,
+ show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
+ custom_model_option: config.custom_model_option || null,
},
});
@@ -149,11 +154,16 @@ async function startNewConversation(): Promise {
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
max_output_tokens: config.max_output_tokens ?? null,
+ disable_cron: config.disable_cron ?? false,
+ disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
include_git_instructions: config.include_git_instructions ?? true,
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
auto_memory_directory: config.auto_memory_directory || null,
model_overrides: config.model_overrides || null,
session_name: null,
+ bare_mode: config.bare_mode ?? false,
+ show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
+ custom_model_option: config.custom_model_option || null,
},
});
diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte
index b9ccb1b..9b2877e 100644
--- a/src/lib/components/ConfigSidebar.svelte
+++ b/src/lib/components/ConfigSidebar.svelte
@@ -64,6 +64,9 @@
auto_memory_directory: null,
model_overrides: null,
disable_skill_shell_execution: false,
+ bare_mode: false,
+ show_clear_context_on_plan_accept: true,
+ custom_model_option: null,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
@@ -86,6 +89,9 @@
let customUiFontStatus: string | null = $state(null);
let modelOverridesJson = $state("");
let modelOverridesError: string | null = $state(null);
+ let globalClaudeMd = $state("");
+ let globalClaudeMdSaving = $state(false);
+ let globalClaudeMdSaveStatus: string | null = $state(null);
interface AuthStatus {
is_logged_in: boolean;
@@ -123,6 +129,9 @@
if (open && authStatus === null) {
void refreshAuthStatus();
}
+ if (open) {
+ void loadGlobalClaudeMd();
+ }
});
configStore.saveError.subscribe((error) => {
@@ -198,6 +207,30 @@
}
}
+ async function loadGlobalClaudeMd() {
+ try {
+ globalClaudeMd = await invoke("get_global_claude_md");
+ } catch (error) {
+ console.error("Failed to load global CLAUDE.md:", error);
+ }
+ }
+
+ async function saveGlobalClaudeMd() {
+ globalClaudeMdSaving = true;
+ globalClaudeMdSaveStatus = null;
+ try {
+ await invoke("save_global_claude_md", { content: globalClaudeMd });
+ globalClaudeMdSaveStatus = "Saved!";
+ setTimeout(() => {
+ globalClaudeMdSaveStatus = null;
+ }, 2000);
+ } catch (error) {
+ globalClaudeMdSaveStatus = `Error: ${error}`;
+ } finally {
+ globalClaudeMdSaving = false;
+ }
+ }
+
async function handleSave() {
isSaving = true;
saveError = null;
@@ -679,6 +712,59 @@
defaults.
+
+
+
+
+
+ Passes --bare to suppress UI chrome — useful for scripted
+ headless -p calls (requires Claude Code v2.1.81+)
+
+
+
+
+
+
+
+ When enabled, prompts to clear context when accepting a plan. Passes
+ showClearContextOnPlanAccept: false in
+ --settings when disabled (requires Claude Code v2.1.81+)
+
+
+
+
+
+
+
+
+ Sets ANTHROPIC_CUSTOM_MODEL_OPTION environment variable for custom
+ model providers (requires Claude Code v2.1.81+)
+