generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more (#197)
## Summary A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation. ### Features - **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion - **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control - **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel - **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost - **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume - **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically - **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure - **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine - **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app - **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature ### Performance & Fixes - **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions - **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected ### UX - Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation - Navigation icon cluster replaced with a single clean dropdown menu ## Closes Closes #179 Closes #180 Closes #181 Closes #188 Closes #189 Closes #190 Closes #191 Closes #192 Closes #193 Closes #194 Closes #195 Closes #196 --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #197 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #197.
This commit is contained in:
@@ -606,6 +606,62 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct GiteaChangelogRelease {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
body: Option<String>,
|
||||
prerelease: bool,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ChangelogEntry {
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub notes: Option<String>,
|
||||
pub prerelease: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
|
||||
const RELEASES_API: &str =
|
||||
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(RELEASES_API)
|
||||
.header("Accept", "application/json")
|
||||
.query(&[("limit", "50")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("API returned status: {}", response.status()));
|
||||
}
|
||||
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
|
||||
let releases: Vec<GiteaChangelogRelease> =
|
||||
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.map(|r| ChangelogEntry {
|
||||
version: r.tag_name,
|
||||
url: r.html_url,
|
||||
notes: r.body,
|
||||
prerelease: r.prerelease,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SavedFileInfo {
|
||||
pub path: String,
|
||||
@@ -2337,6 +2393,160 @@ pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Codebase Mapper ====================
|
||||
|
||||
/// Directories to skip when scanning (always ignored regardless of .gitignore)
|
||||
const SCAN_SKIP_DIRS: &[&str] = &[
|
||||
".git",
|
||||
"node_modules",
|
||||
"target",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"__pycache__",
|
||||
".cache",
|
||||
".pytest_cache",
|
||||
"vendor",
|
||||
".idea",
|
||||
".vscode",
|
||||
"coverage",
|
||||
".nyc_output",
|
||||
"venv",
|
||||
".venv",
|
||||
"env",
|
||||
".tox",
|
||||
];
|
||||
|
||||
/// Files that indicate the project type
|
||||
const PROJECT_MARKERS: &[(&str, &str)] = &[
|
||||
("Cargo.toml", "Rust"),
|
||||
("package.json", "Node.js"),
|
||||
("pyproject.toml", "Python"),
|
||||
("requirements.txt", "Python"),
|
||||
("go.mod", "Go"),
|
||||
("pom.xml", "Java (Maven)"),
|
||||
("build.gradle", "Java (Gradle)"),
|
||||
("Gemfile", "Ruby"),
|
||||
("composer.json", "PHP"),
|
||||
("*.csproj", "C#/.NET"),
|
||||
("CMakeLists.txt", "C/C++ (CMake)"),
|
||||
("Makefile", "C/C++"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProjectScan {
|
||||
pub working_dir: String,
|
||||
pub file_tree: String,
|
||||
pub detected_type: String,
|
||||
pub key_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// Recursively build a file tree string, respecting skip dirs, up to `max_depth` levels.
|
||||
fn build_file_tree(
|
||||
dir: &std::path::Path,
|
||||
prefix: &str,
|
||||
depth: usize,
|
||||
max_depth: usize,
|
||||
lines: &mut Vec<String>,
|
||||
) {
|
||||
if depth > max_depth {
|
||||
lines.push(format!("{}...", prefix));
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut items: Vec<std::fs::DirEntry> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
items.sort_by_key(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||
// Sort: hidden last, directories first
|
||||
let is_hidden = name.starts_with('.');
|
||||
let is_dir = e.path().is_dir();
|
||||
(is_hidden, !is_dir, name)
|
||||
});
|
||||
|
||||
let count = items.len();
|
||||
for (i, entry) in items.iter().enumerate() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_last = i == count - 1;
|
||||
let connector = if is_last { "└── " } else { "├── " };
|
||||
let child_prefix = if is_last {
|
||||
format!("{} ", prefix)
|
||||
} else {
|
||||
format!("{}│ ", prefix)
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if SCAN_SKIP_DIRS.contains(&name.as_str()) {
|
||||
lines.push(format!("{}{}{}/ (skipped)", prefix, connector, name));
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}{}/", prefix, connector, name));
|
||||
build_file_tree(&path, &child_prefix, depth + 1, max_depth, lines);
|
||||
} else {
|
||||
lines.push(format!("{}{}{}", prefix, connector, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
|
||||
let dir_path = std::path::Path::new(&working_dir);
|
||||
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", working_dir));
|
||||
}
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", working_dir));
|
||||
}
|
||||
|
||||
// Detect project type by checking for marker files
|
||||
let mut detected_type = "Unknown".to_string();
|
||||
let mut key_files: Vec<String> = Vec::new();
|
||||
|
||||
for (marker, project_type) in PROJECT_MARKERS {
|
||||
let marker_path = dir_path.join(marker);
|
||||
if marker_path.exists() {
|
||||
if detected_type == "Unknown" {
|
||||
detected_type = project_type.to_string();
|
||||
}
|
||||
key_files.push(marker.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Also collect other notable root-level files
|
||||
let notable_root_files = &[
|
||||
"README.md", "CLAUDE.md", "LICENSE", ".env.example",
|
||||
"docker-compose.yml", "Dockerfile", ".github",
|
||||
"tsconfig.json", "vitest.config.ts", "eslint.config.js",
|
||||
"check-all.sh", "tauri.conf.json",
|
||||
];
|
||||
for file in notable_root_files {
|
||||
let file_path = dir_path.join(file);
|
||||
if file_path.exists() && !key_files.contains(&file.to_string()) {
|
||||
key_files.push(file.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Build file tree (max 4 levels deep)
|
||||
let mut lines: Vec<String> = vec![format!("{}/", working_dir)];
|
||||
build_file_tree(dir_path, "", 0, 4, &mut lines);
|
||||
let file_tree = lines.join("\n");
|
||||
|
||||
Ok(ProjectScan {
|
||||
working_dir,
|
||||
file_tree,
|
||||
detected_type,
|
||||
key_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+106
-9
@@ -158,6 +158,16 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub custom_ui_font_family: Option<String>,
|
||||
|
||||
// Task Loop auto-commit settings
|
||||
#[serde(default)]
|
||||
pub task_loop_auto_commit: bool,
|
||||
|
||||
#[serde(default = "default_task_loop_commit_prefix")]
|
||||
pub task_loop_commit_prefix: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub task_loop_include_summary: bool,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -201,6 +211,9 @@ impl Default for HikariConfig {
|
||||
custom_font_family: None,
|
||||
custom_ui_font_path: None,
|
||||
custom_ui_font_family: None,
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat".to_string(),
|
||||
task_loop_include_summary: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +254,10 @@ fn default_background_image_opacity() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_task_loop_commit_prefix() -> String {
|
||||
"feat".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BudgetAction {
|
||||
@@ -258,6 +275,18 @@ pub enum Theme {
|
||||
#[serde(rename = "high-contrast")]
|
||||
HighContrast,
|
||||
Custom,
|
||||
Dracula,
|
||||
Catppuccin,
|
||||
Nord,
|
||||
Solarized,
|
||||
#[serde(rename = "solarized-light")]
|
||||
SolarizedLight,
|
||||
#[serde(rename = "catppuccin-latte")]
|
||||
CatppuccinLatte,
|
||||
#[serde(rename = "gruvbox-light")]
|
||||
GruvboxLight,
|
||||
#[serde(rename = "rose-pine-dawn")]
|
||||
RosePineDawn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
@@ -320,6 +349,9 @@ mod tests {
|
||||
assert!(config.custom_font_family.is_none());
|
||||
assert!(config.custom_ui_font_path.is_none());
|
||||
assert!(config.custom_ui_font_family.is_none());
|
||||
assert!(!config.task_loop_auto_commit);
|
||||
assert_eq!(config.task_loop_commit_prefix, "feat");
|
||||
assert!(!config.task_loop_include_summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -363,6 +395,9 @@ mod tests {
|
||||
custom_font_family: Some("MyFont".to_string()),
|
||||
custom_ui_font_path: None,
|
||||
custom_ui_font_family: None,
|
||||
task_loop_auto_commit: true,
|
||||
task_loop_commit_prefix: "fix".to_string(),
|
||||
task_loop_include_summary: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@@ -377,22 +412,84 @@ mod tests {
|
||||
deserialized.greeting_custom_prompt,
|
||||
Some("Hello!".to_string())
|
||||
);
|
||||
assert!(deserialized.task_loop_auto_commit);
|
||||
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
|
||||
assert!(deserialized.task_loop_include_summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialization() {
|
||||
let dark = Theme::Dark;
|
||||
let light = Theme::Light;
|
||||
let high_contrast = Theme::HighContrast;
|
||||
|
||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"light\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&high_contrast).unwrap(),
|
||||
serde_json::to_string(&Theme::HighContrast).unwrap(),
|
||||
"\"high-contrast\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&Theme::Custom).unwrap(), "\"custom\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Dracula).unwrap(),
|
||||
"\"dracula\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Catppuccin).unwrap(),
|
||||
"\"catppuccin\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&Theme::Nord).unwrap(), "\"nord\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Solarized).unwrap(),
|
||||
"\"solarized\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::SolarizedLight).unwrap(),
|
||||
"\"solarized-light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::CatppuccinLatte).unwrap(),
|
||||
"\"catppuccin-latte\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::GruvboxLight).unwrap(),
|
||||
"\"gruvbox-light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::RosePineDawn).unwrap(),
|
||||
"\"rose-pine-dawn\""
|
||||
);
|
||||
}
|
||||
|
||||
let custom = Theme::Custom;
|
||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||
#[test]
|
||||
fn test_theme_deserialization() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"dracula\"").unwrap(),
|
||||
Theme::Dracula
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"catppuccin\"").unwrap(),
|
||||
Theme::Catppuccin
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"nord\"").unwrap(),
|
||||
Theme::Nord
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"solarized\"").unwrap(),
|
||||
Theme::Solarized
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"solarized-light\"").unwrap(),
|
||||
Theme::SolarizedLight
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"catppuccin-latte\"").unwrap(),
|
||||
Theme::CatppuccinLatte
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"gruvbox-light\"").unwrap(),
|
||||
Theme::GruvboxLight
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"rose-pine-dawn\"").unwrap(),
|
||||
Theme::RosePineDawn
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ pub fn run() {
|
||||
validate_directory,
|
||||
list_skills,
|
||||
check_for_updates,
|
||||
fetch_changelog,
|
||||
save_temp_file,
|
||||
register_temp_file,
|
||||
get_temp_files,
|
||||
@@ -220,6 +221,7 @@ pub fn run() {
|
||||
save_draft,
|
||||
delete_draft,
|
||||
delete_all_drafts,
|
||||
scan_project,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in New Issue
Block a user