feat: multiple UI improvements, font settings, and memory file display names (#175)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled

## Summary

- **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load
- **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach
- **feat**: Add configurable max output tokens setting
- **feat**: Use random creative names for conversation tabs
- **test**: Significantly expanded frontend unit test coverage
- **docs**: Require tests for all changes in CLAUDE.md
- **feat**: Allow users to specify a custom terminal font (Closes #176)
- **feat**: Display friendly names for memory files derived from the first heading (Closes #177)
- **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs)
- **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #175
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #175.
This commit is contained in:
2026-03-03 20:21:58 -08:00
committed by Naomi Carrigan
parent 97b8243d24
commit fa906684c2
48 changed files with 7148 additions and 101 deletions
+129 -9
View File
@@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
.map_err(|e| format!("Failed to read file: {}", e))
}
/// Read the first `# Heading` from a WSL file path (for Windows).
/// Returns `None` if the file cannot be read or has no top-level heading.
#[cfg(target_os = "windows")]
fn read_wsl_file_first_heading(path: &str) -> Option<String> {
use std::process::Command;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-c", &format!("head -20 '{}'", path)])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let content = String::from_utf8_lossy(&output.stdout);
extract_first_heading(&content)
}
/// Read file content via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
@@ -1353,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
#[derive(serde::Serialize)]
pub struct MemoryFileInfo {
pub path: String,
pub heading: Option<String>,
}
#[derive(serde::Serialize)]
pub struct MemoryFilesResponse {
pub files: Vec<String>,
pub files: Vec<MemoryFileInfo>,
}
/// Extract the first `# Heading` from a string of file content.
fn extract_first_heading(content: &str) -> Option<String> {
content.lines().find_map(|line| {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("# ") {
let heading = rest.trim().to_string();
if !heading.is_empty() {
return Some(heading);
}
}
None
})
}
#[tauri::command]
@@ -1398,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = stdout
let paths: Vec<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect();
// Read first heading from each file via WSL
let mut files = Vec::new();
for path in paths {
let heading = read_wsl_file_first_heading(&path);
files.push(MemoryFileInfo { path, heading });
}
Ok(MemoryFilesResponse { files })
}
@@ -1425,10 +1472,13 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
return Ok(MemoryFilesResponse { files: Vec::new() });
}
let mut memory_files = Vec::new();
let mut memory_paths = Vec::new();
// Recursively find all memory directories
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
fn find_memory_files(
dir: &std::path::Path,
files: &mut Vec<String>,
) -> std::io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
@@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
Ok(())
}
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
return Err(format!("Failed to list memory files: {}", e));
}
// Sort files alphabetically
memory_files.sort();
memory_paths.sort();
Ok(MemoryFilesResponse {
files: memory_files,
})
// Read first heading from each file
let files = memory_paths
.into_iter()
.map(|path| {
let heading = fs::read_to_string(&path)
.ok()
.and_then(|content| extract_first_heading(&content));
MemoryFileInfo { path, heading }
})
.collect();
Ok(MemoryFilesResponse { files })
}
#[tauri::command]
@@ -2902,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[1].name, "gitea");
}
// ==================== extract_first_heading tests ====================
#[test]
fn test_extract_first_heading_returns_heading() {
let content = "# My Memory File\n\nSome content here.";
assert_eq!(
extract_first_heading(content),
Some("My Memory File".to_string())
);
}
#[test]
fn test_extract_first_heading_ignores_non_h1() {
let content = "## Section Header\n### Sub-section\nSome content.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_finds_first_h1_after_other_lines() {
let content = "Some intro text\n\n# The Real Title\n\nMore content.";
assert_eq!(
extract_first_heading(content),
Some("The Real Title".to_string())
);
}
#[test]
fn test_extract_first_heading_trims_whitespace() {
let content = "# Trimmed Heading \n\nContent.";
assert_eq!(
extract_first_heading(content),
Some("Trimmed Heading".to_string())
);
}
#[test]
fn test_extract_first_heading_returns_none_for_empty_content() {
assert_eq!(extract_first_heading(""), None);
}
#[test]
fn test_extract_first_heading_returns_none_for_empty_heading() {
let content = "# \n\nContent after empty heading.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_returns_none_when_no_headings() {
let content = "Just some plain text.\nNo headings here at all.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_handles_leading_whitespace_on_line() {
let content = " # Indented Heading\n\nContent.";
assert_eq!(
extract_first_heading(content),
Some("Indented Heading".to_string())
);
}
}