Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dist/

# dotenv environment variables file
.env
.env.local

# .vscode workspace settings file (keep extensions.json and settings.json for team consistency)
.vscode/launch.json
Expand Down
6 changes: 5 additions & 1 deletion .rules → AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
- Commits should have the first character uppercased
- Do not prefix unused variables with an underscore, delete them instead
- Do not use emojis in commit messages, logs, or documentation
- Never change the .rules file unless the user specifically asks for it
- Never change the AGENTS.md file unless the user specifically asks for it
- Avoid unnecessary comments in UI components (keep code self-explanatory)
- Avoid unnecessary `cn(...)` calls: use it only for conditional or merged class names; do not wrap static strings
- Always use bun.
- PR descriptions should be simple, natural language, no headers or sections, just a few bullet points describing what changed and why.

## Zustand

Expand All @@ -30,16 +31,19 @@ This project uses Zustand for state management with specific patterns:
All theme colors are defined as CSS variables following this structure:

**Variable Naming Convention:**

- Use semantic names without prefixes: `--primary-bg`, `--text`, `--accent`
- No `--tw-` prefix (this was removed during standardization)
- Variables are defined in `:root` with system theme fallbacks via `@media (prefers-color-scheme: dark)`

**Tailwind Integration:**

- CSS variables map to Tailwind colors via `@theme inline` directive
- Use pattern: `--color-{name}: var(--{name})`
- Enables utilities like `bg-primary-bg`, `text-text`, `border-border`

**Theme System:**

- All themes (including built-ins) are defined in JSON files in `src/extensions/themes/builtin/`
- Themes override CSS variables via the Theme Registry
- No CSS classes for themes - pure variable-based theming
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md

This file was deleted.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion GEMINI.md

This file was deleted.

6 changes: 6 additions & 0 deletions crates/ai/src/acp/bridge_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ fn spawn_agent_process(
.stdout(Stdio::piped())
.stderr(Stdio::piped());

// Augment PATH with user's shell PATH for bundled app launches
if let Some(shell_path) = super::config::user_shell_path() {
let current = std::env::var("PATH").unwrap_or_default();
cmd.env("PATH", format!("{current}:{shell_path}"));
}

let uses_npx_codex_adapter = binary.ends_with("npx")
&& config
.args
Expand Down
30 changes: 29 additions & 1 deletion crates/ai/src/acp/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,35 @@ use std::{
collections::HashMap,
env, fs,
path::{Path, PathBuf},
process::Command,
sync::OnceLock,
time::Instant,
};
use tauri::{AppHandle, Manager};

/// Cache duration for binary detection (60 seconds)
const DETECTION_CACHE_SECONDS: u64 = 60;

/// Get the user's login shell PATH. Bundled apps inherit a minimal PATH,
/// so we source the full one from the user's shell and cache it.
pub(crate) fn user_shell_path() -> Option<&'static str> {
static CACHED: OnceLock<Option<String>> = OnceLock::new();
CACHED
.get_or_init(|| {
if cfg!(target_os = "windows") {
return None;
}
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let output = Command::new(&shell)
.args(["-ilc", "echo $PATH"])
.output()
.ok()?;
let path = String::from_utf8(output.stdout).ok()?.trim().to_string();
if path.is_empty() { None } else { Some(path) }
})
.as_deref()
}

/// Registry of known ACP-compatible agents
#[derive(Clone)]
pub struct AgentRegistry {
Expand Down Expand Up @@ -195,11 +217,17 @@ fn find_binary(binary_name: &str) -> Option<PathBuf> {

let mut candidates: Vec<PathBuf> = Vec::new();

// PATH entries
// PATH entries from the current process
if let Some(paths) = env::var_os("PATH") {
candidates.extend(env::split_paths(&paths));
}

// Bundled apps inherit a restricted PATH. Source the user's login shell
// to get the full PATH (cached for the process lifetime).
if let Some(shell_path) = user_shell_path() {
candidates.extend(env::split_paths(&std::ffi::OsString::from(shell_path)));
}

// Common global bin locations
if let Some(home) = env::var_os("HOME") {
let home = PathBuf::from(home);
Expand Down
42 changes: 32 additions & 10 deletions crates/github/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,50 @@
use crate::{
cli::{get_github_username, gh_command},
cli::{get_github_username, gh_command, resolve_gh_binary},
models::{
IssueDetails, IssueListItem, PullRequest, PullRequestComment, PullRequestDetails,
PullRequestFile, WorkflowRunDetails, WorkflowRunListItem,
},
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::AppHandle;

pub fn github_check_cli_auth(app: AppHandle) -> Result<bool, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GitHubCliStatus {
Authenticated,
NotAuthenticated,
NotInstalled,
}

pub fn github_check_cli_status(app: AppHandle) -> Result<GitHubCliStatus, String> {
let binary = resolve_gh_binary();
let exe_name = if cfg!(target_os = "windows") {
"gh.exe"
} else {
"gh"
};

// If resolve returned the bare name, check if it's actually reachable
if binary == exe_name
&& std::process::Command::new(&binary)
.arg("--version")
.output()
.is_err()
{
return Ok(GitHubCliStatus::NotInstalled);
}

let output = gh_command(&app, None)
.args(["auth", "status"])
.output()
.map_err(|e| format!("Failed to execute gh command: {}", e))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
log::warn!("GitHub CLI auth check failed: {}", stderr.trim());
}
if output.status.success() {
Ok(GitHubCliStatus::Authenticated)
} else {
Ok(GitHubCliStatus::NotAuthenticated)
}

Ok(output.status.success())
}

pub fn github_list_prs(
Expand Down
55 changes: 54 additions & 1 deletion crates/github/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,64 @@ use std::{
ffi::OsStr,
path::{Path, PathBuf},
process::Command,
sync::OnceLock,
};
use tauri::{AppHandle, Manager};

/// Get the user's login shell PATH by running `$SHELL -ilc 'echo $PATH'`.
/// Cached for the lifetime of the process since the user's PATH doesn't change.
fn user_shell_path() -> Option<&'static str> {
static CACHED: OnceLock<Option<String>> = OnceLock::new();
CACHED
.get_or_init(|| {
let shell = env::var("SHELL").unwrap_or_else(|_| {
if cfg!(target_os = "windows") {
return String::new();
}
"/bin/zsh".to_string()
});
if shell.is_empty() {
return None;
}
let output = Command::new(&shell)
.args(["-ilc", "echo $PATH"])
.output()
.ok()?;
let path = String::from_utf8(output.stdout).ok()?.trim().to_string();
if path.is_empty() { None } else { Some(path) }
})
.as_deref()
}

/// Find the `gh` binary. On bundled apps the inherited PATH is minimal,
/// so we resolve the full PATH from the user's login shell first.
pub(crate) fn resolve_gh_binary() -> String {
let exe = if cfg!(target_os = "windows") {
"gh.exe"
} else {
"gh"
};

// Combine current PATH with the user's shell PATH
let combined = match (env::var("PATH").ok(), user_shell_path()) {
(Some(current), Some(shell)) => format!("{current}:{shell}"),
(Some(current), None) => current,
(None, Some(shell)) => shell.to_string(),
(None, None) => String::new(),
};

for dir in env::split_paths(&combined) {
if dir.join(exe).exists() {
return dir.join(exe).to_string_lossy().into_owned();
}
}

// Fall back to bare name and let the OS try
exe.to_string()
}

pub(crate) fn gh_command(app: &AppHandle, repo_dir: Option<&Path>) -> Command {
let mut command = Command::new("gh");
let mut command = Command::new(resolve_gh_binary());

if let Some(dir) = repo_dir {
command.current_dir(dir);
Expand Down
8 changes: 4 additions & 4 deletions crates/github/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ mod models;
mod serde_helpers;

pub use api::{
github_check_cli_auth, github_checkout_pr, github_get_current_user, github_get_issue_details,
github_get_pr_comments, github_get_pr_details, github_get_pr_diff, github_get_pr_files,
github_get_workflow_run_details, github_list_issues, github_list_prs, github_list_workflow_runs,
github_open_pr_in_browser,
GitHubCliStatus, github_check_cli_status, github_checkout_pr, github_get_current_user,
github_get_issue_details, github_get_pr_comments, github_get_pr_details, github_get_pr_diff,
github_get_pr_files, github_get_workflow_run_details, github_list_issues, github_list_prs,
github_list_workflow_runs, github_open_pr_in_browser,
};
pub use models::{
IssueComment, IssueDetails, IssueListItem, Label, LinkedIssue, PullRequest, PullRequestAuthor,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ athas-ai = { path = "../crates/ai" }
athas-github = { path = "../crates/github" }
mimalloc = { version = "0.1", default-features = false }
log = "0.4.27"
percent-encoding = "2"
lsp-types = { version = "0.95", features = ["proposed"] }
portable-pty = "0.9"
regex = "1.10"
Expand Down
86 changes: 86 additions & 0 deletions src-tauri/src/commands/ui/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,92 @@ pub async fn set_webview_zoom(
}
}

#[derive(Serialize)]
pub struct WebviewMetadata {
pub title: String,
pub favicon: Option<String>,
}

#[command]
pub async fn poll_webview_metadata(
app: tauri::AppHandle,
webview_label: String,
) -> Result<Option<WebviewMetadata>, String> {
if let Some(webview) = app.get_webview(&webview_label) {
// Store metadata in a dedicated global (does not touch location.hash
// to avoid conflicts with the shortcut polling mechanism).
webview
.eval(
r#"
(function() {
var t = document.title || '';
var icon = '';
var el = document.querySelector('link[rel~="icon"]') || document.querySelector('link[rel="shortcut icon"]');
if (el && el.href) { icon = el.href; }
window.__ATHAS_PAGE_META__ = JSON.stringify({t:t,i:icon});
})();
"#,
)
.map_err(|e| format!("Failed to get metadata: {e}"))?;

tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;

// Read back via a hash round-trip (single step)
webview
.eval(
r#"
(function() {
var m = window.__ATHAS_PAGE_META__;
window.__ATHAS_PAGE_META__ = null;
if (m) window.location.hash = '__athas_meta=' + encodeURIComponent(m);
})();
"#,
)
.map_err(|e| format!("Failed to read metadata: {e}"))?;

tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;

let url = webview
.url()
.map_err(|e| format!("Failed to get URL: {e}"))?;
let hash = url.fragment().unwrap_or("");

if let Some(encoded) = hash.strip_prefix("__athas_meta=") {
webview
.eval("window.location.hash = '';")
.map_err(|e| format!("Failed to clear hash: {e}"))?;

let decoded = percent_encoding::percent_decode_str(encoded)
.decode_utf8()
.unwrap_or_default();

#[derive(Deserialize)]
struct Meta {
t: String,
i: String,
}

if let Ok(meta) = serde_json::from_str::<Meta>(&decoded) {
if meta.t.is_empty() {
return Ok(None);
}
return Ok(Some(WebviewMetadata {
title: meta.t,
favicon: if meta.i.is_empty() {
None
} else {
Some(meta.i)
},
}));
}
}

Ok(None)
} else {
Err(format!("Webview not found: {webview_label}"))
}
}

#[command]
pub async fn poll_webview_shortcut(
app: tauri::AppHandle,
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/src/commands/version_control/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ where
}

#[tauri::command]
pub async fn github_check_cli_auth(app: tauri::AppHandle) -> Result<bool, String> {
run_blocking(move || athas_github::github_check_cli_auth(app)).await
pub async fn github_check_cli_auth(
app: tauri::AppHandle,
) -> Result<athas_github::GitHubCliStatus, String> {
run_blocking(move || athas_github::github_check_cli_status(app)).await
}

#[tauri::command]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ fn main() {
open_webview_devtools,
set_webview_zoom,
poll_webview_shortcut,
poll_webview_metadata,
// File watcher commands
start_watching,
stop_watching,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
}
],
"security": {
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' http://localhost:3000 http://127.0.0.1:3000 https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'",
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' http://localhost:3000 http://127.0.0.1:3000 https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost https: data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'",
"capabilities": ["main-capability"],
"assetProtocol": {
"enable": true,
Expand Down
Loading
Loading