From 0e8c07b055c8e95355b8550b58db6c9276a03830 Mon Sep 17 00:00:00 2001 From: tfedorko <138805047+stier1ba@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:15:52 +0700 Subject: [PATCH 1/3] feat: add native PowerShell integration with shared shell abstraction layer Add full PowerShell shell integration mirroring the existing zsh feature set: - `: text` interactive chat mode via PreCommandLookupAction - `:command` dispatch for 50+ commands (agent, model, conversation, git, etc.) - Right-aligned RPROMPT with agent, model, token count, and cost - Tab completion for :commands and @files via fzf - Plugin/theme generation, setup wizard, doctor diagnostics, keyboard shortcuts - Session state management (conversation ID, model overrides, etc.) Create shared shell abstraction layer (`shell/`) to eliminate duplication: - `shell/prompt.rs`: ShellPromptData + fetch_prompt_data() shared by all shells - `shell/style.rs`: AnsiStyled with basic 4-bit ANSI colors (PS 5.1 compatible) - `shell/setup.rs`: Marker-based profile editing reused by zsh and PowerShell Refactor existing zsh integration to use the shared layer: - ZshRPrompt::from_prompt_data() replaces inline data fetching - setup_zsh_integration() delegates to shared setup_shell_integration() Fix Windows compatibility issues: - Normalize paths to forward slashes in walker and workspace_status - Platform-appropriate test paths for Windows - Fix executor test expectations for cmd.exe quoting - Gate Unix-only path tests with #[cfg(not(windows))] - Fix SummaryMessage -> SummaryBlock rename in Windows cfg test blocks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/transformers/strip_working_dir.rs | 18 +- crates/forge_app/src/workspace_status.rs | 5 +- crates/forge_domain/src/snapshot.rs | 5 +- crates/forge_infra/src/executor.rs | 6 +- crates/forge_main/src/cli.rs | 26 ++ crates/forge_main/src/lib.rs | 2 + crates/forge_main/src/powershell/mod.rs | 16 + crates/forge_main/src/powershell/plugin.rs | 313 ++++++++++++++++++ crates/forge_main/src/powershell/rprompt.rs | 205 ++++++++++++ crates/forge_main/src/shell/mod.rs | 18 + crates/forge_main/src/shell/prompt.rs | 105 ++++++ crates/forge_main/src/shell/setup.rs | 233 +++++++++++++ crates/forge_main/src/shell/style.rs | 135 ++++++++ crates/forge_main/src/ui.rs | 129 +++++--- crates/forge_main/src/zsh/mod.rs | 10 +- crates/forge_main/src/zsh/plugin.rs | 154 ++------- crates/forge_main/src/zsh/rprompt.rs | 69 +++- crates/forge_services/src/utils/path.rs | 1 + crates/forge_walker/src/walker.rs | 3 +- shell-plugin/forge.theme.zsh | 1 + shell-plugin/pwsh/forge-plugin.ps1 | 6 + shell-plugin/pwsh/forge-setup.ps1 | 12 + shell-plugin/pwsh/forge-theme.ps1 | 55 +++ shell-plugin/pwsh/lib/actions/auth.ps1 | 48 +++ shell-plugin/pwsh/lib/actions/config.ps1 | 155 +++++++++ .../pwsh/lib/actions/conversation.ps1 | 101 ++++++ shell-plugin/pwsh/lib/actions/core.ps1 | 53 +++ shell-plugin/pwsh/lib/actions/doctor.ps1 | 5 + shell-plugin/pwsh/lib/actions/editor.ps1 | 25 ++ shell-plugin/pwsh/lib/actions/git.ps1 | 43 +++ shell-plugin/pwsh/lib/actions/keyboard.ps1 | 5 + shell-plugin/pwsh/lib/actions/provider.ps1 | 30 ++ shell-plugin/pwsh/lib/bindings.ps1 | 30 ++ shell-plugin/pwsh/lib/completion.ps1 | 24 ++ shell-plugin/pwsh/lib/config.ps1 | 32 ++ shell-plugin/pwsh/lib/dispatcher.ps1 | 152 +++++++++ shell-plugin/pwsh/lib/helpers.ps1 | 102 ++++++ shell-plugin/pwsh/lib/highlight.ps1 | 15 + 38 files changed, 2125 insertions(+), 222 deletions(-) create mode 100644 crates/forge_main/src/powershell/mod.rs create mode 100644 crates/forge_main/src/powershell/plugin.rs create mode 100644 crates/forge_main/src/powershell/rprompt.rs create mode 100644 crates/forge_main/src/shell/mod.rs create mode 100644 crates/forge_main/src/shell/prompt.rs create mode 100644 crates/forge_main/src/shell/setup.rs create mode 100644 crates/forge_main/src/shell/style.rs create mode 100644 shell-plugin/pwsh/forge-plugin.ps1 create mode 100644 shell-plugin/pwsh/forge-setup.ps1 create mode 100644 shell-plugin/pwsh/forge-theme.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/auth.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/config.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/conversation.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/core.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/doctor.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/editor.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/git.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/keyboard.ps1 create mode 100644 shell-plugin/pwsh/lib/actions/provider.ps1 create mode 100644 shell-plugin/pwsh/lib/bindings.ps1 create mode 100644 shell-plugin/pwsh/lib/completion.ps1 create mode 100644 shell-plugin/pwsh/lib/config.ps1 create mode 100644 shell-plugin/pwsh/lib/dispatcher.ps1 create mode 100644 shell-plugin/pwsh/lib/helpers.ps1 create mode 100644 shell-plugin/pwsh/lib/highlight.ps1 diff --git a/crates/forge_app/src/transformers/strip_working_dir.rs b/crates/forge_app/src/transformers/strip_working_dir.rs index 078bcacbaf..da6f9e52b7 100644 --- a/crates/forge_app/src/transformers/strip_working_dir.rs +++ b/crates/forge_app/src/transformers/strip_working_dir.rs @@ -347,7 +347,7 @@ mod tests { // On Windows, paths are recognized and stripped #[cfg(windows)] - let expected = ContextSummary::new(vec![SummaryMessage::new( + let expected = ContextSummary::new(vec![SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read(r"src\main.rs").into(), @@ -402,7 +402,7 @@ mod tests { let actual = StripWorkingDir::new(r"C:\Users\user\project").transform(fixture); #[cfg(windows)] - let expected = ContextSummary::new(vec![SummaryMessage::new( + let expected = ContextSummary::new(vec![SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read(r"src\main.rs").into(), @@ -435,7 +435,7 @@ mod tests { let actual = StripWorkingDir::new(r"C:\Users\user\project").transform(fixture); #[cfg(windows)] - let expected = ContextSummary::new(vec![SummaryMessage::new( + let expected = ContextSummary::new(vec![SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read(r"src\main.rs").into(), @@ -469,7 +469,7 @@ mod tests { let actual = StripWorkingDir::new(r"\\server\share\project").transform(fixture); #[cfg(windows)] - let expected = ContextSummary::new(vec![SummaryMessage::new( + let expected = ContextSummary::new(vec![SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read(r"src\main.rs").into(), @@ -498,7 +498,7 @@ mod tests { let actual = StripWorkingDir::new(r"C:\Users\user\project\").transform(fixture); #[cfg(windows)] - let expected = ContextSummary::new(vec![SummaryMessage::new( + let expected = ContextSummary::new(vec![SummaryBlock::new( Role::Assistant, vec![SummaryToolCall::read(r"src\main.rs").into()], )]); @@ -529,7 +529,7 @@ mod tests { // On Unix: case-sensitive matching, neither path strips (Windows paths not // recognized) #[cfg(windows)] - let expected = ContextSummary::new(vec![SummaryMessage::new( + let expected = ContextSummary::new(vec![SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read(r"src\main.rs").into(), @@ -572,18 +572,18 @@ mod tests { #[cfg(windows)] let expected = ContextSummary::new(vec![ - SummaryMessage::new( + SummaryBlock::new( Role::User, vec![SummaryToolCall::read(r"src\main.rs").into()], ), - SummaryMessage::new( + SummaryBlock::new( Role::Assistant, vec![ SummaryToolCall::read(r"src\lib.rs").into(), SummaryToolCall::update("README.md").into(), ], ), - SummaryMessage::new(Role::User, vec![SummaryToolCall::remove("old.rs").into()]), + SummaryBlock::new(Role::User, vec![SummaryToolCall::remove("old.rs").into()]), ]); #[cfg(not(windows))] diff --git a/crates/forge_app/src/workspace_status.rs b/crates/forge_app/src/workspace_status.rs index 7acb49dc4f..afae7d0fcf 100644 --- a/crates/forge_app/src/workspace_status.rs +++ b/crates/forge_app/src/workspace_status.rs @@ -136,7 +136,10 @@ fn absolutize(base_dir: &Path, path: &str) -> String { if p.is_absolute() { path.to_owned() } else { - base_dir.join(p).to_string_lossy().into_owned() + // Use forward slashes for joined paths to ensure consistent path + // separators across platforms (workspace paths are not OS-specific). + let joined = base_dir.join(p).to_string_lossy().into_owned(); + joined.replace('\\', "/") } } diff --git a/crates/forge_domain/src/snapshot.rs b/crates/forge_domain/src/snapshot.rs index d758265261..138650b3e9 100644 --- a/crates/forge_domain/src/snapshot.rs +++ b/crates/forge_domain/src/snapshot.rs @@ -116,7 +116,10 @@ mod tests { #[test] fn test_create_with_nonexistent_absolute_path() { - // Test with a non-existent absolute path + // Test with a non-existent absolute path (platform-appropriate) + #[cfg(windows)] + let nonexistent_path = PathBuf::from(r"C:\this\path\does\not\exist\file.txt"); + #[cfg(not(windows))] let nonexistent_path = PathBuf::from("/this/path/does/not/exist/file.txt"); let snapshot = Snapshot::create(nonexistent_path.clone()).unwrap(); diff --git a/crates/forge_infra/src/executor.rs b/crates/forge_infra/src/executor.rs index 13f30d8c8d..eca0cf9b3e 100644 --- a/crates/forge_infra/src/executor.rs +++ b/crates/forge_infra/src/executor.rs @@ -269,7 +269,8 @@ mod tests { }; if cfg!(target_os = "windows") { - expected.stdout = format!("'{}'", expected.stdout); + // cmd.exe does not strip single quotes, so they appear in the output + expected.stdout = "'hello world'\n".to_string(); } assert_eq!(actual.stdout.trim(), expected.stdout.trim()); @@ -411,7 +412,8 @@ mod tests { }; if cfg!(target_os = "windows") { - expected.stdout = format!("'{}'", expected.stdout); + // cmd.exe does not strip single quotes, so they appear in the output + expected.stdout = "'silent test'\n".to_string(); } // The output should still be captured in the CommandOutput diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 4cb6ff66f6..a6ddb5fd7f 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -86,6 +86,10 @@ pub enum TopLevelCommand { #[command(subcommand, alias = "extension")] Zsh(ZshCommandGroup), + /// PowerShell integration (native on Windows). + #[command(subcommand, alias = "pwsh")] + Powershell(PowershellCommandGroup), + /// List agents, models, providers, tools, or MCP servers. List(ListCommandGroup), @@ -388,6 +392,28 @@ pub enum ZshCommandGroup { Keyboard, } +/// PowerShell integration commands (native on Windows). +#[derive(Subcommand, Debug, Clone)] +pub enum PowershellCommandGroup { + /// Generate shell plugin script for eval. + Plugin, + + /// Generate shell theme script for eval. + Theme, + + /// Run diagnostics on PowerShell environment. + Doctor, + + /// Get rprompt information (ANSI colors for PowerShell prompt). + Rprompt, + + /// Setup PowerShell integration by updating $PROFILE. + Setup, + + /// Show keyboard shortcuts for PowerShell. + Keyboard, +} + /// Command group for MCP server management. #[derive(Parser, Debug, Clone)] pub struct McpCommandGroup { diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index c5b342df7c..2128e52088 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -19,6 +19,8 @@ pub mod tracker; mod ui; mod utils; mod vscode; +mod powershell; +mod shell; mod zsh; mod update; diff --git a/crates/forge_main/src/powershell/mod.rs b/crates/forge_main/src/powershell/mod.rs new file mode 100644 index 0000000000..8b3850bdf4 --- /dev/null +++ b/crates/forge_main/src/powershell/mod.rs @@ -0,0 +1,16 @@ +//! PowerShell shell integration. +//! +//! This module provides PowerShell-specific functionality including: +//! - Plugin generation and installation +//! - Theme generation +//! - Shell diagnostics +//! - Right prompt (rprompt) display using ANSI escape codes + +mod plugin; +mod rprompt; + +pub use plugin::{ + generate_powershell_plugin, generate_powershell_theme, run_powershell_doctor, + run_powershell_keyboard, setup_powershell_integration, +}; +pub use rprompt::PowerShellRPrompt; diff --git a/crates/forge_main/src/powershell/plugin.rs b/crates/forge_main/src/powershell/plugin.rs new file mode 100644 index 0000000000..e6361c0443 --- /dev/null +++ b/crates/forge_main/src/powershell/plugin.rs @@ -0,0 +1,313 @@ +//! PowerShell plugin and theme generation, setup, and diagnostics. +//! Embeds .ps1 files from shell-plugin/pwsh/ at compile time. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use include_dir::{Dir, include_dir}; + +use crate::shell::normalize_script; +use crate::shell::setup::{self, ShellSetupConfig}; + +/// Embeds shell plugin files for PowerShell integration. +static PWSH_PLUGIN_LIB: Dir<'static> = + include_dir!("$CARGO_MANIFEST_DIR/../../shell-plugin/pwsh/lib"); + +/// Generates the complete PowerShell plugin by combining embedded files. +pub fn generate_powershell_plugin() -> Result { + let mut output = String::new(); + + // Header + output.push_str("# Forge PowerShell Plugin (auto-generated)\n"); + output.push_str("# Do not edit - regenerate with: forge powershell plugin\n\n"); + + // Concatenate all .ps1 files from the embedded lib directory + collect_ps1_files(&PWSH_PLUGIN_LIB, &mut output); + + // Mark plugin as loaded + output.push_str("\n$env:_FORGE_PLUGIN_LOADED = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()\n"); + + Ok(normalize_script(&output)) +} + +/// Recursively collects and appends all .ps1 files from a directory. +fn collect_ps1_files(dir: &Dir<'_>, output: &mut String) { + // Process files in this directory first + for file in dir.files() { + if let Some(ext) = file.path().extension() { + if ext == "ps1" { + if let Some(contents) = file.contents_utf8() { + output.push_str(&format!( + "\n# --- {} ---\n", + file.path().display() + )); + // Strip comment-only lines to reduce size + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + output.push_str(line); + output.push('\n'); + } + } + } + } + } + + // Recurse into subdirectories + for subdir in dir.dirs() { + collect_ps1_files(subdir, output); + } +} + +/// Generates the PowerShell theme script. +pub fn generate_powershell_theme() -> Result { + const THEME_RAW: &str = include_str!("../../../../shell-plugin/pwsh/forge-theme.ps1"); + let mut output = normalize_script(THEME_RAW); + output.push_str("\n$env:_FORGE_THEME_LOADED = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()\n"); + Ok(output) +} + +fn powershell_format_export(key: &str, value: &str) -> String { + format!("$env:{} = \"{}\"", key, value) +} + +/// Result of PowerShell setup operation. +#[derive(Debug)] +pub struct PowerShellSetupResult { + pub message: String, + pub backup_path: Option, +} + +/// Sets up PowerShell integration by modifying `$PROFILE`. +pub fn setup_powershell_integration( + disable_nerd_font: bool, + forge_editor: Option<&str>, +) -> Result { + const INIT_RAW: &str = include_str!("../../../../shell-plugin/pwsh/forge-setup.ps1"); + let init_content = normalize_script(INIT_RAW); + + let profile_path = find_powershell_profile()?; + + let config = ShellSetupConfig { + start_marker: "# >>> forge initialize >>>", + end_marker: "# <<< forge initialize <<<", + profile_path: &profile_path, + init_content: &init_content, + disable_nerd_font, + forge_editor, + format_export: powershell_format_export, + }; + + let result = setup::setup_shell_integration(&config)?; + + Ok(PowerShellSetupResult { + message: result.message, + backup_path: result.backup_path, + }) +} + +/// Finds the PowerShell profile path. +/// +/// Tries pwsh (PowerShell 7+) first, falls back to Windows PowerShell 5.1. +fn find_powershell_profile() -> Result { + // Try pwsh first (cross-platform PowerShell 7+) + if let Ok(output) = std::process::Command::new("pwsh") + .args(["-NoProfile", "-Command", "$PROFILE"]) + .output() + { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + } + + // Fall back to Windows PowerShell + if cfg!(target_os = "windows") { + if let Ok(output) = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "$PROFILE"]) + .output() + { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + } + } + + // Final fallback: construct the standard path + let home = if cfg!(target_os = "windows") { + std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) + } else { + std::env::var("HOME") + } + .context("Could not determine home directory")?; + + let profile_dir = if cfg!(target_os = "windows") { + PathBuf::from(&home) + .join("Documents") + .join("PowerShell") + } else { + PathBuf::from(&home).join(".config").join("powershell") + }; + + Ok(profile_dir.join("Microsoft.PowerShell_profile.ps1")) +} + +/// Runs the PowerShell doctor diagnostics script. +pub fn run_powershell_doctor() -> Result<()> { + let doctor_script = generate_doctor_script(); + execute_powershell_script(&doctor_script) +} + +/// Runs the PowerShell keyboard shortcuts display. +pub fn run_powershell_keyboard() -> Result<()> { + let keyboard_script = generate_keyboard_script(); + execute_powershell_script(&keyboard_script) +} + +fn execute_powershell_script(script: &str) -> Result<()> { + // Prefer pwsh, fall back to powershell.exe + let shell = if std::process::Command::new("pwsh") + .arg("--version") + .output() + .is_ok() + { + "pwsh" + } else { + "powershell" + }; + + let output = std::process::Command::new(shell) + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .context(format!("Failed to execute {} script", shell))?; + + if !output.success() { + anyhow::bail!("Script exited with code: {:?}", output.code()); + } + + Ok(()) +} + +fn generate_doctor_script() -> String { + let e = r#"[char]27"#; + format!( + r#" +$e = {e} +Write-Host "$($e)[1mForge PowerShell Doctor$($e)[0m" +Write-Host "" + +# PowerShell version +$psVer = $PSVersionTable.PSVersion +Write-Host "$($e)[32m[OK]$($e)[0m PowerShell $psVer" + +# PSReadLine version +$psr = Get-Module PSReadLine -ErrorAction SilentlyContinue +if ($psr) {{ + $v = $psr.Version + if ($v -ge [version]"2.2.0") {{ + Write-Host "$($e)[32m[OK]$($e)[0m PSReadLine $v" + }} else {{ + Write-Host "$($e)[31m[!!]$($e)[0m PSReadLine $v (need >= 2.2.0, run: Install-Module PSReadLine -Force)" + }} +}} else {{ + Write-Host "$($e)[31m[!!]$($e)[0m PSReadLine not loaded" +}} + +# fzf +if (Get-Command fzf -ErrorAction SilentlyContinue) {{ + $fzfVer = & fzf --version 2>$null + Write-Host "$($e)[32m[OK]$($e)[0m fzf $fzfVer" +}} else {{ + Write-Host "$($e)[33m[--]$($e)[0m fzf not found (optional, for interactive selection)" +}} + +# fd +if (Get-Command fd -ErrorAction SilentlyContinue) {{ + Write-Host "$($e)[32m[OK]$($e)[0m fd found" +}} else {{ + Write-Host "$($e)[33m[--]$($e)[0m fd not found (optional, for file search)" +}} + +# bat +if (Get-Command bat -ErrorAction SilentlyContinue) {{ + Write-Host "$($e)[32m[OK]$($e)[0m bat found" +}} else {{ + Write-Host "$($e)[33m[--]$($e)[0m bat not found (optional, for preview)" +}} + +# forge binary +if (Get-Command forge -ErrorAction SilentlyContinue) {{ + $forgeVer = & forge --version 2>$null + Write-Host "$($e)[32m[OK]$($e)[0m forge $forgeVer" +}} else {{ + Write-Host "$($e)[31m[!!]$($e)[0m forge not found in PATH" +}} + +# Plugin loaded +if ($env:_FORGE_PLUGIN_LOADED) {{ + Write-Host "$($e)[32m[OK]$($e)[0m Plugin loaded (at $env:_FORGE_PLUGIN_LOADED)" +}} else {{ + Write-Host "$($e)[33m[--]$($e)[0m Plugin not loaded (run: forge powershell setup)" +}} + +# Theme loaded +if ($env:_FORGE_THEME_LOADED) {{ + Write-Host "$($e)[32m[OK]$($e)[0m Theme loaded (at $env:_FORGE_THEME_LOADED)" +}} else {{ + Write-Host "$($e)[33m[--]$($e)[0m Theme not loaded (run: forge powershell setup)" +}} + +# Terminal ANSI support +if ($env:WT_SESSION) {{ + Write-Host "$($e)[32m[OK]$($e)[0m Windows Terminal detected" +}} elseif ($env:TERM_PROGRAM) {{ + Write-Host "$($e)[32m[OK]$($e)[0m Terminal: $env:TERM_PROGRAM" +}} else {{ + Write-Host "$($e)[33m[--]$($e)[0m Terminal not detected (ANSI colors may not work)" +}} + +Write-Host "" +Write-Host "Done." +"# + ) +} + +fn generate_keyboard_script() -> String { + let e = r#"[char]27"#; + format!( + r#" +$e = {e} +Write-Host "$($e)[1mForge PowerShell Keyboard Shortcuts$($e)[0m" +Write-Host "" +Write-Host "$($e)[36mEnter$($e)[0m Execute :command or normal command" +Write-Host "$($e)[36mTab$($e)[0m Complete :command or @file (with fzf)" +Write-Host "" +Write-Host "$($e)[1mColon Commands:$($e)[0m" +Write-Host " $($e)[33m: $($e)[0m Send prompt to default agent" +Write-Host " $($e)[33m: $($e)[0m Send prompt to specific agent" +Write-Host " $($e)[33m:new$($e)[0m / $($e)[33m:n$($e)[0m Start new conversation" +Write-Host " $($e)[33m:info$($e)[0m / $($e)[33m:i$($e)[0m Show session info" +Write-Host " $($e)[33m:agent$($e)[0m / $($e)[33m:a$($e)[0m Switch agent" +Write-Host " $($e)[33m:conversation$($e)[0m / $($e)[33m:c$($e)[0m Switch conversation" +Write-Host " $($e)[33m:session-model$($e)[0m / $($e)[33m:m$($e)[0m Session model override" +Write-Host " $($e)[33m:config-model$($e)[0m / $($e)[33m:cm$($e)[0m Global model config" +Write-Host " $($e)[33m:reasoning-effort$($e)[0m / $($e)[33m:re$($e)[0m Reasoning effort" +Write-Host " $($e)[33m:commit$($e)[0m Generate commit message" +Write-Host " $($e)[33m:suggest$($e)[0m / $($e)[33m:s$($e)[0m Suggest shell command" +Write-Host " $($e)[33m:edit$($e)[0m Open editor for prompt" +Write-Host " $($e)[33m:copy$($e)[0m Copy last response" +Write-Host " $($e)[33m:doctor$($e)[0m Run diagnostics" +Write-Host "" +Write-Host "$($e)[90mSee all commands: forge list commands$($e)[0m" +"# + ) +} diff --git a/crates/forge_main/src/powershell/rprompt.rs b/crates/forge_main/src/powershell/rprompt.rs new file mode 100644 index 0000000000..a1d9478d69 --- /dev/null +++ b/crates/forge_main/src/powershell/rprompt.rs @@ -0,0 +1,205 @@ +//! PowerShell right prompt implementation. +//! +//! Provides the right prompt display for PowerShell integration, +//! showing agent name, model, token count, and cost using ANSI escape codes. + +use std::fmt::{self, Display}; + +use convert_case::{Case, Casing}; +use forge_domain::TokenCount; + +use crate::shell::prompt::ShellPromptData; +use crate::shell::style::{AnsiColor, AnsiStyle}; +use crate::utils::humanize_number; + +/// Nerd Font glyph constants (shared with zsh, but no width wrapping needed +/// since Windows Terminal handles glyph width natively). +const AGENT_GLYPH: char = '\u{f167a}'; +const MODEL_GLYPH: char = '\u{ec19}'; +const CURRENCY_GLYPH: char = '\u{f155}'; + +/// PowerShell prompt displaying agent, model, token count, and cost. +/// +/// Uses ANSI escape codes for styling: +/// - Inactive state (no tokens): dimmed colors +/// - Active state (has tokens): bright white/cyan colors +pub struct PowerShellRPrompt { + data: ShellPromptData, +} + +impl PowerShellRPrompt { + /// Creates a `PowerShellRPrompt` from shared [`ShellPromptData`]. + pub fn from_prompt_data(data: ShellPromptData) -> Self { + Self { data } + } +} + +impl Display for PowerShellRPrompt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let active = self + .data + .token_count + .map(|tc| *tc > 0usize) + .unwrap_or(false); + + // Agent + let agent_name = self + .data + .agent + .as_ref() + .map(|a| a.to_string().to_case(Case::UpperSnake)) + .unwrap_or_else(|| "FORGE".to_string()); + + let agent_str = if self.data.use_nerd_font { + format!("{} {}", AGENT_GLYPH, agent_name) + } else { + agent_name + }; + + let (agent_color, model_color) = if active { + (AnsiColor::WHITE, AnsiColor::CYAN) + } else { + (AnsiColor::DIMMED, AnsiColor::DIMMED) + }; + + write!(f, " {}", agent_str.ansi().bold().fg(agent_color))?; + + // Token count + if let Some(count) = self.data.token_count { + let num = humanize_number(*count); + let prefix = match count { + TokenCount::Actual(_) => "", + TokenCount::Approx(_) => "~", + }; + if active { + write!( + f, + " {}", + format!("{}{}", prefix, num) + .ansi() + .bold() + .fg(AnsiColor::WHITE) + )?; + } + } + + // Cost + if let Some(cost) = self.data.cost { + if active { + let converted = cost * self.data.conversion_ratio; + let currency = if self.data.use_nerd_font && self.data.currency_symbol == "$" { + CURRENCY_GLYPH.to_string() + } else { + self.data.currency_symbol.clone() + }; + let cost_str = format!("{}{:.2}", currency, converted); + write!(f, " {}", cost_str.ansi().bold().fg(AnsiColor::GREEN))?; + } + } + + // Model + if let Some(ref model_id) = self.data.model { + let model_str = if self.data.use_nerd_font { + format!("{} {}", MODEL_GLYPH, model_id) + } else { + model_id.to_string() + }; + write!(f, " {}", model_str.ansi().fg(model_color))?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use forge_api::{AgentId, ModelId}; + use forge_domain::TokenCount; + + use super::*; + + fn make_data( + agent: Option<&str>, + model: Option<&str>, + token_count: Option, + cost: Option, + ) -> ShellPromptData { + ShellPromptData { + agent: agent.map(|a| AgentId::new(a)), + model: model.map(|m| ModelId::new(m)), + token_count, + cost, + use_nerd_font: true, + currency_symbol: "$".to_string(), + conversion_ratio: 1.0, + } + } + + #[test] + fn test_init_state_dimmed() { + let data = make_data(Some("forge"), Some("gpt-4"), None, None); + let prompt = PowerShellRPrompt::from_prompt_data(data); + let output = prompt.to_string(); + + // Should use dimmed color (90 = dark gray) for both agent and model + assert!(output.contains("\x1b[1;90m"), "agent should be dimmed: {}", output); + assert!(output.contains("FORGE")); + assert!(output.contains("gpt-4")); + } + + #[test] + fn test_active_state_with_tokens() { + let data = make_data( + Some("forge"), + Some("gpt-4"), + Some(TokenCount::Actual(1500)), + None, + ); + let prompt = PowerShellRPrompt::from_prompt_data(data); + let output = prompt.to_string(); + + // Should use bright white (97) for agent/tokens and cyan (36) for model + assert!(output.contains("\x1b[1;97m"), "agent should be bright white: {}", output); + assert!(output.contains("\x1b[36m"), "model should be cyan: {}", output); + assert!(output.contains("1.5k")); + } + + #[test] + fn test_active_state_with_cost() { + let data = ShellPromptData { + agent: Some(AgentId::new("forge")), + model: Some(ModelId::new("gpt-4")), + token_count: Some(TokenCount::Actual(1500)), + cost: Some(0.0123), + use_nerd_font: false, + currency_symbol: "$".to_string(), + conversion_ratio: 1.0, + }; + let prompt = PowerShellRPrompt::from_prompt_data(data); + let output = prompt.to_string(); + + assert!(output.contains("$0.01")); + assert!(output.contains("\x1b[1;32m"), "cost should be green: {}", output); + } + + #[test] + fn test_without_nerd_font() { + let data = ShellPromptData { + agent: Some(AgentId::new("forge")), + model: Some(ModelId::new("gpt-4")), + token_count: Some(TokenCount::Actual(1500)), + cost: None, + use_nerd_font: false, + currency_symbol: "$".to_string(), + conversion_ratio: 1.0, + }; + let prompt = PowerShellRPrompt::from_prompt_data(data); + let output = prompt.to_string(); + + // Should NOT contain nerd font glyphs + assert!(!output.contains(AGENT_GLYPH)); + assert!(!output.contains(MODEL_GLYPH)); + assert!(output.contains("FORGE")); + assert!(output.contains("gpt-4")); + } +} diff --git a/crates/forge_main/src/shell/mod.rs b/crates/forge_main/src/shell/mod.rs new file mode 100644 index 0000000000..9ae48f5d78 --- /dev/null +++ b/crates/forge_main/src/shell/mod.rs @@ -0,0 +1,18 @@ +//! Shared shell integration utilities. +//! +//! This module provides shell-agnostic types and functions used by +//! shell-specific modules (zsh, powershell, etc.) to avoid duplicating +//! common logic like prompt data fetching, ANSI styling, and profile setup. + +pub mod prompt; +pub mod setup; +pub mod style; + +/// Normalizes shell script content for cross-platform compatibility. +/// +/// Strips carriage returns (`\r`) that appear when `include_str!` or +/// `include_dir!` embed files on Windows (where `git core.autocrlf=true` +/// converts LF to CRLF on checkout). Most shells cannot parse `\r` in scripts. +pub(crate) fn normalize_script(content: &str) -> String { + content.replace("\r\n", "\n").replace('\r', "\n") +} diff --git a/crates/forge_main/src/shell/prompt.rs b/crates/forge_main/src/shell/prompt.rs new file mode 100644 index 0000000000..f0c3b37480 --- /dev/null +++ b/crates/forge_main/src/shell/prompt.rs @@ -0,0 +1,105 @@ +//! Shared prompt data fetching for shell integrations. +//! +//! Provides a shell-agnostic [`ShellPromptData`] struct and an async +//! [`fetch_prompt_data`] function that collects prompt information from the API +//! and environment. Each shell module (zsh, powershell, etc.) consumes this +//! data and formats it with shell-specific escape sequences. + +use std::str::FromStr; + +use forge_api::{API, AgentId, Conversation, ConversationId, ModelId}; +use forge_domain::TokenCount; +use futures::future; + +/// Shell-agnostic prompt data, collected once and passed to any shell formatter. +pub struct ShellPromptData { + pub agent: Option, + pub model: Option, + pub token_count: Option, + pub cost: Option, + pub use_nerd_font: bool, + pub currency_symbol: String, + pub conversion_ratio: f64, +} + +/// Fetches prompt data from the API and environment variables. +/// +/// This extracts the common logic shared by all shell rprompt handlers: +/// reading env vars, fetching model/conversation data in parallel, and +/// computing cost across related conversations. +pub async fn fetch_prompt_data(api: &(dyn API + Send + Sync)) -> ShellPromptData { + let cid = std::env::var("_FORGE_CONVERSATION_ID") + .ok() + .filter(|text| !text.trim().is_empty()) + .and_then(|str| ConversationId::from_str(str.as_str()).ok()); + + // Make IO calls in parallel + let (model_id, conversation) = tokio::join!(api.get_default_model(), async { + if let Some(cid) = cid { + api.conversation(&cid).await.ok().flatten() + } else { + None + } + }); + + // Calculate total cost including related conversations + let cost = if let Some(ref conv) = conversation { + let related = fetch_related_conversations(api, conv).await; + let all: Vec<_> = std::iter::once(conv).chain(related.iter()).cloned().collect(); + Conversation::total_cost(&all) + } else { + None + }; + + let agent = std::env::var("_FORGE_ACTIVE_AGENT") + .ok() + .filter(|text| !text.trim().is_empty()) + .map(AgentId::new); + + let use_nerd_font = std::env::var("NERD_FONT") + .or_else(|_| std::env::var("USE_NERD_FONT")) + .map(|val| val == "1") + .unwrap_or(true); + + let currency_symbol = + std::env::var("FORGE_CURRENCY_SYMBOL").unwrap_or_else(|_| "$".to_string()); + + let conversion_ratio = std::env::var("FORGE_CURRENCY_CONVERSION_RATE") + .ok() + .and_then(|val| val.parse::().ok()) + .unwrap_or(1.0); + + let token_count = conversation.and_then(|c| c.token_count()); + + ShellPromptData { + agent, + model: model_id, + token_count, + cost, + use_nerd_font, + currency_symbol, + conversion_ratio, + } +} + +/// Fetches related conversations for a given conversation in parallel. +async fn fetch_related_conversations( + api: &(dyn API + Send + Sync), + conversation: &Conversation, +) -> Vec { + let related_ids = conversation.related_conversation_ids(); + + let related_futures: Vec<_> = related_ids + .iter() + .map(|id| { + let id = *id; + async move { api.conversation(&id).await } + }) + .collect(); + + future::join_all(related_futures) + .await + .into_iter() + .filter_map(|result| result.ok().flatten()) + .collect() +} diff --git a/crates/forge_main/src/shell/setup.rs b/crates/forge_main/src/shell/setup.rs new file mode 100644 index 0000000000..48dc0f9066 --- /dev/null +++ b/crates/forge_main/src/shell/setup.rs @@ -0,0 +1,233 @@ +//! Shared marker-based shell profile setup utilities. +//! +//! Provides the core logic for inserting/updating managed configuration blocks +//! in shell profile files (`.zshrc`, PowerShell `$PROFILE`, etc.) using +//! start/end markers for idempotent updates. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +/// State of the forge markers in a profile file. +#[derive(Debug)] +pub enum MarkerState { + /// No markers found + NotFound, + /// Valid markers with correct positions + Valid { start: usize, end: usize }, + /// Invalid markers (incorrect order or incomplete) + Invalid { + start: Option, + end: Option, + }, +} + +/// Parses the file content to find and validate marker positions. +pub fn parse_markers(lines: &[String], start_marker: &str, end_marker: &str) -> MarkerState { + let start_idx = lines.iter().position(|line| line.trim() == start_marker); + let end_idx = lines.iter().position(|line| line.trim() == end_marker); + + match (start_idx, end_idx) { + (Some(start), Some(end)) if start < end => MarkerState::Valid { start, end }, + (None, None) => MarkerState::NotFound, + (start, end) => MarkerState::Invalid { start, end }, + } +} + +/// Configuration for setting up a shell integration profile. +pub struct ShellSetupConfig<'a> { + /// The start marker string (e.g., `# >>> forge initialize >>>`) + pub start_marker: &'a str, + /// The end marker string (e.g., `# <<< forge initialize <<<`) + pub end_marker: &'a str, + /// Path to the shell profile file + pub profile_path: &'a Path, + /// The init content to place between markers + pub init_content: &'a str, + /// Whether to disable nerd fonts + pub disable_nerd_font: bool, + /// Optional editor to configure + pub forge_editor: Option<&'a str>, + /// Shell-specific function to format an env var export line. + /// Takes (key, value) and returns the full export line, e.g.: + /// - zsh/bash: `export KEY="value"` + /// - PowerShell: `$env:KEY = "value"` + pub format_export: fn(&str, &str) -> String, +} + +/// Result of a shell setup operation. +#[derive(Debug)] +pub struct SetupResult { + /// Status message describing what was done + pub message: String, + /// Path to backup file if one was created + pub backup_path: Option, +} + +/// Sets up shell integration by inserting or updating a managed block in the +/// profile file. Creates a timestamped backup before modifying existing files. +pub fn setup_shell_integration(config: &ShellSetupConfig<'_>) -> Result { + let profile_path = config.profile_path; + + // Read existing profile or start fresh + let content = if profile_path.exists() { + fs::read_to_string(profile_path) + .context(format!("Failed to read {}", profile_path.display()))? + } else { + String::new() + }; + + let mut lines: Vec = content.lines().map(String::from).collect(); + let marker_state = parse_markers(&lines, config.start_marker, config.end_marker); + + // Build the forge config block with markers + let mut forge_config: Vec = vec![config.start_marker.to_string()]; + forge_config.extend(config.init_content.lines().map(String::from)); + + // Add nerd font configuration if requested + if config.disable_nerd_font { + forge_config.push(String::new()); + forge_config.push( + "# Disable Nerd Fonts (set during setup - icons not displaying correctly)".to_string(), + ); + forge_config.push( + "# To re-enable: remove this line and install a Nerd Font from https://www.nerdfonts.com/" + .to_string(), + ); + forge_config.push((config.format_export)("NERD_FONT", "0")); + } + + // Add editor configuration if requested + if let Some(editor) = config.forge_editor { + forge_config.push(String::new()); + forge_config.push("# Editor for editing prompts (set during setup)".to_string()); + forge_config.push( + "# To change: update FORGE_EDITOR or remove to use $EDITOR".to_string(), + ); + forge_config.push((config.format_export)("FORGE_EDITOR", editor)); + } + + forge_config.push(config.end_marker.to_string()); + + // Add or update forge configuration block based on marker state + let (new_content, config_action) = match marker_state { + MarkerState::Valid { start, end } => { + lines.splice(start..=end, forge_config.iter().cloned()); + (lines.join("\n") + "\n", "updated") + } + MarkerState::Invalid { start, end } => { + let location = match (start, end) { + (Some(s), Some(e)) => { + Some(format!("{}:{}-{}", profile_path.display(), s + 1, e + 1)) + } + (Some(s), None) => Some(format!("{}:{}", profile_path.display(), s + 1)), + (None, Some(e)) => Some(format!("{}:{}", profile_path.display(), e + 1)), + (None, None) => None, + }; + + let mut error = + anyhow::anyhow!("Invalid forge markers found in {}", profile_path.display()); + if let Some(loc) = location { + error = error.context(format!("Markers found at {}", loc)); + } + return Err(error); + } + MarkerState::NotFound => { + if !lines.is_empty() && !lines[lines.len() - 1].trim().is_empty() { + lines.push(String::new()); + } + lines.extend(forge_config.iter().cloned()); + (lines.join("\n") + "\n", "added") + } + }; + + // Create backup of existing profile if it exists + let backup_path = if profile_path.exists() { + let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); + let parent = profile_path + .parent() + .context("profile path has no parent directory")?; + let filename = profile_path + .file_name() + .context("profile path has no filename")?; + let filename_str = filename + .to_str() + .context("profile filename is not valid UTF-8")?; + + let backup = parent.join(format!("{}.bak.{}", filename_str, timestamp)); + fs::copy(profile_path, &backup) + .context(format!("Failed to create backup at {}", backup.display()))?; + Some(backup) + } else { + // Create parent directory if needed + if let Some(parent) = profile_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .context(format!("Failed to create directory {}", parent.display()))?; + } + } + None + }; + + // Write back to profile + fs::write(profile_path, &new_content) + .context(format!("Failed to write to {}", profile_path.display()))?; + + Ok(SetupResult { + message: format!("forge plugins {}", config_action), + backup_path, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_markers_not_found() { + let lines: Vec = vec!["some line".into(), "another line".into()]; + assert!(matches!( + parse_markers(&lines, "# >>> start >>>", "# <<< end <<<"), + MarkerState::NotFound + )); + } + + #[test] + fn test_parse_markers_valid() { + let lines: Vec = vec![ + "# >>> start >>>".into(), + "content".into(), + "# <<< end <<<".into(), + ]; + assert!(matches!( + parse_markers(&lines, "# >>> start >>>", "# <<< end <<<"), + MarkerState::Valid { start: 0, end: 2 } + )); + } + + #[test] + fn test_parse_markers_invalid_order() { + let lines: Vec = vec![ + "# <<< end <<<".into(), + "content".into(), + "# >>> start >>>".into(), + ]; + assert!(matches!( + parse_markers(&lines, "# >>> start >>>", "# <<< end <<<"), + MarkerState::Invalid { .. } + )); + } + + #[test] + fn test_parse_markers_only_start() { + let lines: Vec = vec!["# >>> start >>>".into(), "content".into()]; + assert!(matches!( + parse_markers(&lines, "# >>> start >>>", "# <<< end <<<"), + MarkerState::Invalid { + start: Some(0), + end: None + } + )); + } +} diff --git a/crates/forge_main/src/shell/style.rs b/crates/forge_main/src/shell/style.rs new file mode 100644 index 0000000000..f8734b0948 --- /dev/null +++ b/crates/forge_main/src/shell/style.rs @@ -0,0 +1,135 @@ +//! ANSI escape code styling utilities for shell prompts. +//! +//! Provides helpers for generating ANSI escape sequences, used by shells +//! that support standard ANSI codes (PowerShell, fish, nushell, etc.) as +//! opposed to shell-specific escapes (zsh's `%F{N}`). +//! +//! Uses basic 4-bit ANSI color codes (30-37, 90-97) for maximum +//! compatibility with Windows PowerShell 5.1 and older terminals. + +use std::fmt::{self, Display}; + +/// Basic ANSI foreground color code (4-bit, universally supported). +/// +/// Uses standard codes (30-37) and bright codes (90-97) instead of +/// 256-color `38;5;N` which is not supported by Windows PowerShell 5.1. +#[derive(Debug, Clone, Copy)] +pub struct AnsiColor(u8); + +impl AnsiColor { + /// Bright white (code 97) + pub const WHITE: Self = Self(97); + /// Cyan (code 36) + pub const CYAN: Self = Self(36); + /// Green (code 32) + pub const GREEN: Self = Self(32); + /// Dark gray / dimmed (code 90) + pub const DIMMED: Self = Self(90); +} + +/// A styled string using ANSI escape codes. +#[derive(Debug, Clone)] +pub struct AnsiStyled<'a> { + text: &'a str, + fg: Option, + bold: bool, +} + +impl<'a> AnsiStyled<'a> { + /// Creates a new styled string with the given text. + pub fn new(text: &'a str) -> Self { + Self { text, fg: None, bold: false } + } + + /// Sets the foreground color. + pub fn fg(mut self, color: AnsiColor) -> Self { + self.fg = Some(color); + self + } + + /// Makes the text bold. + pub fn bold(mut self) -> Self { + self.bold = true; + self + } +} + +impl Display for AnsiStyled<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let has_style = self.bold || self.fg.is_some(); + + if has_style { + write!(f, "\x1b[")?; + let mut first = true; + + if self.bold { + write!(f, "1")?; + first = false; + } + + if let Some(ref color) = self.fg { + if !first { + write!(f, ";")?; + } + write!(f, "{}", color.0)?; + } + + write!(f, "m")?; + } + + write!(f, "{}", self.text)?; + + if has_style { + write!(f, "\x1b[0m")?; + } + + Ok(()) + } +} + +/// Extension trait for styling strings with ANSI escape codes. +pub trait AnsiStyle { + /// Creates an ANSI-styled wrapper for this string. + fn ansi(&self) -> AnsiStyled<'_>; +} + +impl AnsiStyle for str { + fn ansi(&self) -> AnsiStyled<'_> { + AnsiStyled::new(self) + } +} + +impl AnsiStyle for String { + fn ansi(&self) -> AnsiStyled<'_> { + AnsiStyled::new(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_text() { + let actual = "hello".ansi().to_string(); + assert_eq!(actual, "hello"); + } + + #[test] + fn test_bold() { + let actual = "hello".ansi().bold().to_string(); + assert_eq!(actual, "\x1b[1mhello\x1b[0m"); + } + + #[test] + fn test_color() { + let actual = "hello".ansi().fg(AnsiColor::DIMMED).to_string(); + assert_eq!(actual, "\x1b[90mhello\x1b[0m"); + } + + #[test] + fn test_bold_and_color() { + let actual = "hello".ansi().bold().fg(AnsiColor::WHITE).to_string(); + assert_eq!(actual, "\x1b[1;97mhello\x1b[0m"); + } +} diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index f5954dbb91..f0c32ce433 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::fmt::Display; use std::path::PathBuf; -use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -454,6 +453,35 @@ impl A + Send + Sync> UI { } return Ok(()); } + TopLevelCommand::Powershell(powershell_group) => { + match powershell_group { + crate::cli::PowershellCommandGroup::Plugin => { + let output = crate::powershell::generate_powershell_plugin()?; + print!("{}", output); + } + crate::cli::PowershellCommandGroup::Theme => { + let output = crate::powershell::generate_powershell_theme()?; + print!("{}", output); + } + crate::cli::PowershellCommandGroup::Doctor => { + self.spinner.stop(None)?; + crate::powershell::run_powershell_doctor()?; + } + crate::cli::PowershellCommandGroup::Rprompt => { + if let Some(text) = self.handle_powershell_rprompt_command().await { + print!("{}", text) + } + } + crate::cli::PowershellCommandGroup::Setup => { + self.on_powershell_setup().await?; + } + crate::cli::PowershellCommandGroup::Keyboard => { + self.spinner.stop(None)?; + crate::powershell::run_powershell_keyboard()?; + } + } + return Ok(()); + } TopLevelCommand::Mcp(mcp_command) => match mcp_command.command { McpCommand::Import(import_args) => { let scope: forge_domain::Scope = import_args.scope.into(); @@ -3581,66 +3609,61 @@ impl A + Send + Sync> UI { Ok(()) } - /// Handle prompt command - returns model and conversation stats for shell - /// integration async fn handle_zsh_rprompt_command(&mut self) -> Option { - let cid = std::env::var("_FORGE_CONVERSATION_ID") - .ok() - .filter(|text| !text.trim().is_empty()) - .and_then(|str| ConversationId::from_str(str.as_str()).ok()); + let data = crate::shell::prompt::fetch_prompt_data(&*self.api).await; + let rprompt = ZshRPrompt::from_prompt_data(&data); + Some(rprompt.to_string()) + } - // Make IO calls in parallel - let (model_id, conversation) = tokio::join!(self.api.get_default_model(), async { - if let Some(cid) = cid { - self.api.conversation(&cid).await.ok().flatten() - } else { - None - } - }); + /// Handle PowerShell rprompt command - returns ANSI colored prompt for + /// native Windows PowerShell integration. + async fn handle_powershell_rprompt_command(&mut self) -> Option { + let data = crate::shell::prompt::fetch_prompt_data(&*self.api).await; + let rprompt = crate::powershell::PowerShellRPrompt::from_prompt_data(data); + Some(rprompt.to_string()) + } - // Calculate total cost including related conversations - let cost = if let Some(ref conv) = conversation { - let related_conversations = self.fetch_related_conversations(conv).await; - let all_conversations: Vec<_> = std::iter::once(conv) - .chain(related_conversations.iter()) - .cloned() - .collect(); - Conversation::total_cost(&all_conversations) - } else { + /// Interactive PowerShell setup wizard. + async fn on_powershell_setup(&mut self) -> Result<()> { + self.spinner.stop(None)?; + + // Ask about nerd fonts + println!("Do you see these icons correctly? \u{f167a} \u{ec19} \u{f155}"); + println!("(They should appear as small symbols, not boxes or question marks)"); + print!("[y/N] "); + std::io::Write::flush(&mut std::io::stdout())?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let disable_nerd_font = !input.trim().eq_ignore_ascii_case("y"); + + // Ask about editor + println!("\nWould you like to configure an editor for :edit command?"); + println!("Examples: code --wait, vim, nvim, nano"); + print!("Editor (leave empty to skip): "); + std::io::Write::flush(&mut std::io::stdout())?; + + let mut editor_input = String::new(); + std::io::stdin().read_line(&mut editor_input)?; + let forge_editor = editor_input.trim(); + let forge_editor = if forge_editor.is_empty() { None + } else { + Some(forge_editor) }; - // Check if nerd fonts should be used (NERD_FONT or USE_NERD_FONT set to "1") - let use_nerd_font = std::env::var("NERD_FONT") - .or_else(|_| std::env::var("USE_NERD_FONT")) - .map(|val| val == "1") - .unwrap_or(true); // Default to true + let result = + crate::powershell::setup_powershell_integration(disable_nerd_font, forge_editor)?; - // Get currency symbol from environment variable, default to "$" - let currency_symbol = - std::env::var("FORGE_CURRENCY_SYMBOL").unwrap_or_else(|_| "$".to_string()); - - // Get conversion ratio from environment variable, default to 1.0 - let conversion_ratio = std::env::var("FORGE_CURRENCY_CONVERSION_RATE") - .ok() - .and_then(|val| val.parse::().ok()) - .unwrap_or(1.0); - - let rprompt = ZshRPrompt::default() - .agent( - std::env::var("_FORGE_ACTIVE_AGENT") - .ok() - .filter(|text| !text.trim().is_empty()) - .map(AgentId::new), - ) - .model(model_id) - .token_count(conversation.and_then(|conversation| conversation.token_count())) - .cost(cost) - .use_nerd_font(use_nerd_font) - .currency_symbol(currency_symbol) - .conversion_ratio(conversion_ratio); + println!("\n{}", result.message); + if let Some(backup) = result.backup_path { + println!("Backup created at: {}", backup.display()); + } + println!( + "\nRestart PowerShell or run: . $PROFILE" + ); - Some(rprompt.to_string()) + Ok(()) } /// Validates that a model exists, optionally scoped to a specific provider. diff --git a/crates/forge_main/src/zsh/mod.rs b/crates/forge_main/src/zsh/mod.rs index 3348473002..c7ef873408 100644 --- a/crates/forge_main/src/zsh/mod.rs +++ b/crates/forge_main/src/zsh/mod.rs @@ -11,14 +11,8 @@ mod plugin; mod rprompt; mod style; -/// Normalizes shell script content for cross-platform compatibility. -/// -/// Strips carriage returns (`\r`) that appear when `include_str!` or -/// `include_dir!` embed files on Windows (where `git core.autocrlf=true` -/// converts LF to CRLF on checkout). Zsh cannot parse `\r` in scripts. -pub(crate) fn normalize_script(content: &str) -> String { - content.replace("\r\n", "\n").replace('\r', "\n") -} +/// Re-export from shared shell module for backward compatibility. +pub(crate) use crate::shell::normalize_script; pub use plugin::{ generate_zsh_plugin, generate_zsh_theme, run_zsh_doctor, run_zsh_keyboard, diff --git a/crates/forge_main/src/zsh/plugin.rs b/crates/forge_main/src/zsh/plugin.rs index 27ec8678eb..d9541b5b95 100644 --- a/crates/forge_main/src/zsh/plugin.rs +++ b/crates/forge_main/src/zsh/plugin.rs @@ -193,36 +193,6 @@ pub fn run_zsh_keyboard() -> Result<()> { } /// Represents the state of markers in a file -enum MarkerState { - /// No markers found - NotFound, - /// Valid markers with correct positions - Valid { start: usize, end: usize }, - /// Invalid markers (incorrect order or incomplete) - Invalid { - start: Option, - end: Option, - }, -} - -/// Parses the file content to find and validate marker positions -/// -/// # Arguments -/// -/// * `lines` - The lines of the file to parse -/// * `start_marker` - The start marker to look for -/// * `end_marker` - The end marker to look for -fn parse_markers(lines: &[String], start_marker: &str, end_marker: &str) -> MarkerState { - let start_idx = lines.iter().position(|line| line.trim() == start_marker); - let end_idx = lines.iter().position(|line| line.trim() == end_marker); - - match (start_idx, end_idx) { - (Some(start), Some(end)) if start < end => MarkerState::Valid { start, end }, - (None, None) => MarkerState::NotFound, - (start, end) => MarkerState::Invalid { start, end }, - } -} - /// Result of ZSH setup operation #[derive(Debug)] pub struct ZshSetupResult { @@ -232,6 +202,10 @@ pub struct ZshSetupResult { pub backup_path: Option, } +fn zsh_format_export(key: &str, value: &str) -> String { + format!("export {}=\"{}\"", key, value) +} + /// Sets up ZSH integration with optional nerd font and editor configuration /// /// # Arguments @@ -250,8 +224,6 @@ pub fn setup_zsh_integration( disable_nerd_font: bool, forge_editor: Option<&str>, ) -> Result { - const START_MARKER: &str = "# >>> forge initialize >>>"; - const END_MARKER: &str = "# <<< forge initialize <<<"; const FORGE_INIT_CONFIG_RAW: &str = include_str!("../../../../shell-plugin/forge.setup.zsh"); let forge_init_config = super::normalize_script(FORGE_INIT_CONFIG_RAW); @@ -259,109 +231,21 @@ pub fn setup_zsh_integration( let zdotdir = std::env::var("ZDOTDIR").unwrap_or_else(|_| home.clone()); let zshrc_path = PathBuf::from(&zdotdir).join(".zshrc"); - // Read existing .zshrc or create new one - let content = if zshrc_path.exists() { - fs::read_to_string(&zshrc_path) - .context(format!("Failed to read {}", zshrc_path.display()))? - } else { - String::new() - }; - - let mut lines: Vec = content.lines().map(String::from).collect(); - - // Parse markers to determine their state - let marker_state = parse_markers(&lines, START_MARKER, END_MARKER); - - // Build the forge config block with markers - let mut forge_config: Vec = vec![START_MARKER.to_string()]; - forge_config.extend(forge_init_config.lines().map(String::from)); - - // Add nerd font configuration if requested - if disable_nerd_font { - forge_config.push(String::new()); // Add blank line before comment - forge_config.push( - "# Disable Nerd Fonts (set during setup - icons not displaying correctly)".to_string(), - ); - forge_config.push("# To re-enable: remove this line and install a Nerd Font from https://www.nerdfonts.com/".to_string()); - forge_config.push("export NERD_FONT=0".to_string()); - } - - // Add editor configuration if requested - if let Some(editor) = forge_editor { - forge_config.push(String::new()); // Add blank line before comment - forge_config.push("# Editor for editing prompts (set during setup)".to_string()); - forge_config.push("# To change: update FORGE_EDITOR or remove to use $EDITOR".to_string()); - forge_config.push(format!("export FORGE_EDITOR=\"{}\"", editor)); - } - - forge_config.push(END_MARKER.to_string()); - - // Add or update forge configuration block based on marker state - let (new_content, config_action) = match marker_state { - MarkerState::Valid { start, end } => { - // Markers exist - replace content between them - lines.splice(start..=end, forge_config.iter().cloned()); - (lines.join("\n") + "\n", "updated") - } - MarkerState::Invalid { start, end } => { - let location = match (start, end) { - (Some(s), Some(e)) => Some(format!("{}:{}-{}", zshrc_path.display(), s + 1, e + 1)), - (Some(s), None) => Some(format!("{}:{}", zshrc_path.display(), s + 1)), - (None, Some(e)) => Some(format!("{}:{}", zshrc_path.display(), e + 1)), - (None, None) => None, - }; - - let mut error = - anyhow::anyhow!("Invalid forge markers found in {}", zshrc_path.display()); - if let Some(loc) = location { - error = error.context(format!("Markers found at {}", loc)); - } - return Err(error); - } - MarkerState::NotFound => { - // No markers - add them at the end - // Add blank line before markers if file is not empty and doesn't end with blank - // line - if !lines.is_empty() && !lines[lines.len() - 1].trim().is_empty() { - lines.push(String::new()); - } - - lines.extend(forge_config.iter().cloned()); - (lines.join("\n") + "\n", "added") - } - }; - - // Create backup of existing .zshrc if it exists - let backup_path = if zshrc_path.exists() { - // Generate timestamp for backup filename - let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); - - // Safe to unwrap: zshrc_path was constructed from a valid HOME/ZDOTDIR path - let parent = zshrc_path - .parent() - .context("zshrc path has no parent directory")?; - let filename = zshrc_path - .file_name() - .context("zshrc path has no filename")?; - let filename_str = filename - .to_str() - .context("zshrc filename is not valid UTF-8")?; - - let backup = parent.join(format!("{}.bak.{}", filename_str, timestamp)); - fs::copy(&zshrc_path, &backup) - .context(format!("Failed to create backup at {}", backup.display()))?; - Some(backup) - } else { - None + let config = crate::shell::setup::ShellSetupConfig { + start_marker: "# >>> forge initialize >>>", + end_marker: "# <<< forge initialize <<<", + profile_path: &zshrc_path, + init_content: &forge_init_config, + disable_nerd_font, + forge_editor, + format_export: zsh_format_export, }; - // Write back to .zshrc - fs::write(&zshrc_path, &new_content) - .context(format!("Failed to write to {}", zshrc_path.display()))?; + let result = crate::shell::setup::setup_shell_integration(&config)?; Ok(ZshSetupResult { - message: format!("forge plugins {}", config_action), - backup_path, + message: result.message, + backup_path: result.backup_path, }) } @@ -500,7 +384,7 @@ mod tests { // Should contain NERD_FONT=0 with explanatory comments assert!( - content.contains("export NERD_FONT=0"), + content.contains("export NERD_FONT=\"0\""), "Content should contain NERD_FONT=0:\n{}", content ); @@ -624,7 +508,7 @@ mod tests { // Should contain both configurations assert!( - content.contains("export NERD_FONT=0"), + content.contains("export NERD_FONT=\"0\""), "Content should contain NERD_FONT=0:\n{}", content ); @@ -683,7 +567,7 @@ mod tests { let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); assert!( - content.contains("export NERD_FONT=0"), + content.contains("export NERD_FONT=\"0\""), "Should contain NERD_FONT=0 after first setup" ); assert!( @@ -718,7 +602,7 @@ mod tests { // Should not contain NERD_FONT=0 anymore assert!( - !content.contains("export NERD_FONT=0"), + !content.contains("export NERD_FONT=\"0\""), "Should not contain NERD_FONT=0 after update:\n{}", content ); diff --git a/crates/forge_main/src/zsh/rprompt.rs b/crates/forge_main/src/zsh/rprompt.rs index 6e01059302..b6598a19b1 100644 --- a/crates/forge_main/src/zsh/rprompt.rs +++ b/crates/forge_main/src/zsh/rprompt.rs @@ -10,6 +10,7 @@ use derive_setters::Setters; use forge_domain::{AgentId, ModelId, TokenCount}; use super::style::{ZshColor, ZshStyle}; +use crate::shell::prompt::ShellPromptData; use crate::utils::humanize_number; /// ZSH right prompt displaying agent, model, and token count. @@ -36,20 +37,62 @@ pub struct ZshRPrompt { } impl Default for ZshRPrompt { fn default() -> Self { + let width = nerd_glyph_width(); Self { agent: None, model: None, token_count: None, cost: None, use_nerd_font: true, - currency_symbol: "\u{f155}".to_string(), + currency_symbol: wrap_glyph(CURRENCY_GLYPH, width), conversion_ratio: 1.0, } } } -const AGENT_SYMBOL: &str = "\u{f167a}"; -const MODEL_SYMBOL: &str = "\u{ec19}"; +impl ZshRPrompt { + /// Creates a `ZshRPrompt` from shared [`ShellPromptData`]. + pub fn from_prompt_data(data: &ShellPromptData) -> Self { + let width = nerd_glyph_width(); + + // If the currency symbol is the default "$", use the nerd font glyph + let currency_symbol = if data.currency_symbol == "$" { + wrap_glyph(CURRENCY_GLYPH, width) + } else { + data.currency_symbol.clone() + }; + + Self { + agent: data.agent.clone(), + model: data.model.clone(), + token_count: data.token_count, + cost: data.cost, + use_nerd_font: data.use_nerd_font, + currency_symbol, + conversion_ratio: data.conversion_ratio, + } + } +} + +const AGENT_GLYPH: char = '\u{f167a}'; +const MODEL_GLYPH: char = '\u{ec19}'; +const CURRENCY_GLYPH: char = '\u{f155}'; + +// Nerd Font glyphs wrapped with %{…%WG%} so zsh counts each as the correct +// number of visible columns. These Private Use Area codepoints have +// terminal-dependent rendering width (1 or 2 columns). Use the +// NERD_FONT_GLYPH_WIDTH=1 or =2 environment variable to override (default: 2 for Windows 11). +fn nerd_glyph_width() -> u8 { + std::env::var("NERD_FONT_GLYPH_WIDTH") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|&w| w == 1 || w == 2) + .unwrap_or(2) +} + +fn wrap_glyph(c: char, width: u8) -> String { + format!("%{{{}%{}G%}}", c, width) +} impl Display for ZshRPrompt { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -58,8 +101,10 @@ impl Display for ZshRPrompt { // Add agent let agent_id = self.agent.clone().unwrap_or_default(); let agent_id = if self.use_nerd_font { + let width = nerd_glyph_width(); format!( - "{AGENT_SYMBOL} {}", + "{} {}", + wrap_glyph(AGENT_GLYPH, width), agent_id.to_string().to_case(Case::UpperSnake) ) } else { @@ -95,10 +140,10 @@ impl Display for ZshRPrompt { write!(f, " {}", cost_str.zsh().fg(ZshColor::GREEN).bold())?; } - // Add model if let Some(ref model_id) = self.model { let model_id = if self.use_nerd_font { - format!("{MODEL_SYMBOL} {}", model_id) + let width = nerd_glyph_width(); + format!("{} {}", wrap_glyph(MODEL_GLYPH, width), model_id) } else { model_id.to_string() }; @@ -126,7 +171,7 @@ mod tests { .model(Some(ModelId::new("gpt-4"))) .to_string(); - let expected = " %B%F{240}\u{f167a} FORGE%f%b %F{240}\u{ec19} gpt-4%f"; + let expected = " %B%F{240}%{\u{f167a}%2G%} FORGE%f%b %F{240}%{\u{ec19}%2G%} gpt-4%f"; assert_eq!(actual, expected); } @@ -139,7 +184,7 @@ mod tests { .token_count(Some(TokenCount::Actual(1500))) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}%{\u{f167a}%2G%} FORGE%f%b %B%F{15}1.5k%f%b %F{134}%{\u{ec19}%2G%} gpt-4%f"; assert_eq!(actual, expected); } @@ -151,10 +196,10 @@ mod tests { .model(Some(ModelId::new("gpt-4"))) .token_count(Some(TokenCount::Actual(1500))) .cost(Some(0.0123)) - .currency_symbol("\u{f155}") + .currency_symbol("%{\u{f155}%2G%}") .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}\u{f155}0.01%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}%{\u{f167a}%2G%} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}%{\u{f155}%2G%}0.01%f%b %F{134}%{\u{ec19}%2G%} gpt-4%f"; assert_eq!(actual, expected); } @@ -184,7 +229,7 @@ mod tests { .conversion_ratio(83.5) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}INR0.83%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}%{\u{f167a}%2G%} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}INR0.83%f%b %F{134}%{\u{ec19}%2G%} gpt-4%f"; assert_eq!(actual, expected); } #[test] @@ -199,7 +244,7 @@ mod tests { .conversion_ratio(0.92) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}€0.01%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}%{\u{f167a}%2G%} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}€0.01%f%b %F{134}%{\u{ec19}%2G%} gpt-4%f"; assert_eq!(actual, expected); } } diff --git a/crates/forge_services/src/utils/path.rs b/crates/forge_services/src/utils/path.rs index 4424170f7f..11373281c7 100644 --- a/crates/forge_services/src/utils/path.rs +++ b/crates/forge_services/src/utils/path.rs @@ -23,6 +23,7 @@ mod tests { use super::*; #[test] + #[cfg(not(windows))] fn test_unix_absolute_path() { let path = Path::new("/absolute/path"); assert!(assert_absolute_path(path).is_ok()); diff --git a/crates/forge_walker/src/walker.rs b/crates/forge_walker/src/walker.rs index 526b44d62f..5461f6d983 100644 --- a/crates/forge_walker/src/walker.rs +++ b/crates/forge_walker/src/walker.rs @@ -176,7 +176,8 @@ impl Walker { let relative_path = path .strip_prefix(&self.cwd) .with_context(|| format!("Failed to strip prefix from path: {}", path.display()))?; - let path_string = relative_path.to_string_lossy().to_string(); + // Normalize to forward slashes for consistent cross-platform paths + let path_string = relative_path.to_string_lossy().replace('\\', "/"); let file_name = path .file_name() diff --git a/shell-plugin/forge.theme.zsh b/shell-plugin/forge.theme.zsh index 8081396174..f115a45f5e 100644 --- a/shell-plugin/forge.theme.zsh +++ b/shell-plugin/forge.theme.zsh @@ -22,6 +22,7 @@ function _forge_prompt_info() { } # Right prompt: agent and model with token count (uses single forge prompt command) +# Glyphs are now raw (no hardcoded %G width escapes). ZSH + terminal handles width dynamically. # Set RPROMPT if empty, otherwise append to existing value if [[ -z "$_FORGE_THEME_LOADED" ]]; then RPROMPT='$(_forge_prompt_info)'"${RPROMPT:+ ${RPROMPT}}" diff --git a/shell-plugin/pwsh/forge-plugin.ps1 b/shell-plugin/pwsh/forge-plugin.ps1 new file mode 100644 index 0000000000..c5dab03a8b --- /dev/null +++ b/shell-plugin/pwsh/forge-plugin.ps1 @@ -0,0 +1,6 @@ +# Forge PowerShell Plugin - Main Loader +# This file is generated by `forge powershell plugin` and should not be edited. +# Sources all forge plugin modules in the correct order. + +# The individual module contents are concatenated here by the Rust plugin +# generator. This file serves as the template/entry point. diff --git a/shell-plugin/pwsh/forge-setup.ps1 b/shell-plugin/pwsh/forge-setup.ps1 new file mode 100644 index 0000000000..924515d6fa --- /dev/null +++ b/shell-plugin/pwsh/forge-setup.ps1 @@ -0,0 +1,12 @@ +# !! Contents within this block are managed by 'forge powershell setup' !! +# !! Do not edit manually - changes will be overwritten !! + +# Load forge shell plugin if not already loaded +if (-not $env:_FORGE_PLUGIN_LOADED) { + Invoke-Expression ((& forge powershell plugin) -join "`n") +} + +# Load forge shell theme if not already loaded +if (-not $env:_FORGE_THEME_LOADED) { + Invoke-Expression ((& forge powershell theme) -join "`n") +} diff --git a/shell-plugin/pwsh/forge-theme.ps1 b/shell-plugin/pwsh/forge-theme.ps1 new file mode 100644 index 0000000000..c96902c5d9 --- /dev/null +++ b/shell-plugin/pwsh/forge-theme.ps1 @@ -0,0 +1,55 @@ +# Forge PowerShell Theme - Custom Prompt +# Displays forge info right-aligned on the prompt line (like zsh RPROMPT). + +function global:prompt { + $forgeBin = if ($env:FORGE_BIN) { $env:FORGE_BIN } else { "forge" } + + # Pass session state to the rprompt command via env vars + if ($script:ForgeConversationId) { + $env:_FORGE_CONVERSATION_ID = $script:ForgeConversationId + } + if ($script:ForgeActiveAgent) { + $env:_FORGE_ACTIVE_AGENT = $script:ForgeActiveAgent + } + if ($script:ForgeSessionModel) { + $env:FORGE_SESSION__MODEL_ID = $script:ForgeSessionModel + } + if ($script:ForgeSessionProvider) { + $env:FORGE_SESSION__PROVIDER_ID = $script:ForgeSessionProvider + } + + $forgePrompt = "" + try { + $forgePrompt = & $forgeBin powershell rprompt 2>$null + if ($forgePrompt -is [array]) { + $forgePrompt = $forgePrompt -join "" + } + } catch {} + + # Build left prompt + $leftPrompt = "PS $($executionContext.SessionState.Path.CurrentLocation)" + + if ($forgePrompt) { + # Strip ANSI escapes to measure visible length of forge info + $esc = [char]0x1b + $plainForge = $forgePrompt -replace "$esc\[[0-9;]*m", '' + $plainLeft = $leftPrompt + + $width = $Host.UI.RawUI.WindowSize.Width + $gap = $width - $plainLeft.Length - $plainForge.Length + + if ($gap -gt 0) { + # Right-align: left prompt + spaces + forge info + Write-Host "$leftPrompt$(' ' * $gap)$forgePrompt" + } else { + # Terminal too narrow — put forge info on its own line + Write-Host "$forgePrompt" + Write-Host $leftPrompt -NoNewline + return "> " + } + } else { + Write-Host $leftPrompt + } + + return "> " +} diff --git a/shell-plugin/pwsh/lib/actions/auth.ps1 b/shell-plugin/pwsh/lib/actions/auth.ps1 new file mode 100644 index 0000000000..8642c9b779 --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/auth.ps1 @@ -0,0 +1,48 @@ +# Forge PowerShell Plugin - Auth Actions + +function Invoke-ForgeActionLogin { + $providers = & $script:ForgeBin list provider --porcelain 2>$null + if (-not $providers) { + Write-ForgeLog 'error' 'Failed to list providers' + return + } + + if (Get-Command fzf -ErrorAction SilentlyContinue) { + $selected = $providers | + Where-Object { $_ -match '\S' } | + Select-Object -Skip 1 | + Invoke-ForgeFzf -FzfArgs @("--prompt=Login Provider > ") + + if ($selected) { + $providerId = ($selected -split '\s{2,}')[0].Trim() + Invoke-ForgeExec provider login $providerId + } + } else { + $providers | Write-Host + Write-ForgeLog 'info' 'Use: forge provider login ' + } +} + +function Invoke-ForgeActionLogout { + $providers = & $script:ForgeBin list provider --porcelain 2>$null + if (-not $providers) { + Write-ForgeLog 'error' 'Failed to list providers' + return + } + + if (Get-Command fzf -ErrorAction SilentlyContinue) { + $selected = $providers | + Where-Object { $_ -match '\S' } | + Select-Object -Skip 1 | + Where-Object { $_ -match 'logged.in|active' } | + Invoke-ForgeFzf -FzfArgs @("--prompt=Logout Provider > ") + + if ($selected) { + $providerId = ($selected -split '\s{2,}')[0].Trim() + Invoke-ForgeExec provider logout $providerId + } + } else { + $providers | Write-Host + Write-ForgeLog 'info' 'Use: forge provider logout ' + } +} diff --git a/shell-plugin/pwsh/lib/actions/config.ps1 b/shell-plugin/pwsh/lib/actions/config.ps1 new file mode 100644 index 0000000000..20879ef12b --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/config.ps1 @@ -0,0 +1,155 @@ +# Forge PowerShell Plugin - Config Actions + +function Invoke-ForgeActionAgent { + param([string]$InputText) + + if ($InputText) { + $script:ForgeActiveAgent = $InputText + $env:_FORGE_ACTIVE_AGENT = $InputText + Write-ForgeLog 'success' "Switched to agent: $InputText" + return + } + + $agents = & $script:ForgeBin list agents --porcelain 2>$null + if (-not $agents) { + Write-ForgeLog 'error' 'Failed to list agents' + return + } + + if (Get-Command fzf -ErrorAction SilentlyContinue) { + $selected = $agents | + Where-Object { $_ -match '\S' } | + Select-Object -Skip 1 | + Invoke-ForgeFzf -FzfArgs @("--prompt=Agent > ") + + if ($selected) { + $agentId = ($selected -split '\s{2,}')[0].Trim() + $script:ForgeActiveAgent = $agentId + $env:_FORGE_ACTIVE_AGENT = $agentId + Write-ForgeLog 'success' "Switched to agent: $agentId" + } + } else { + $agents | Write-Host + } +} + +function Invoke-ForgeActionSessionModel { + param([string]$InputText) + + if ($InputText) { + $script:ForgeSessionModel = $InputText + $env:FORGE_SESSION__MODEL_ID = $InputText + Write-ForgeLog 'success' "Session model: $InputText" + return + } + + $models = & $script:ForgeBin list models --porcelain 2>$null + if (-not $models -or (Get-Command fzf -ErrorAction SilentlyContinue) -eq $null) { + Write-ForgeLog 'error' 'Requires fzf for interactive selection, or provide model name as argument' + return + } + + $selected = $models | + Where-Object { $_ -match '\S' } | + Select-Object -Skip 1 | + Invoke-ForgeFzf -FzfArgs @("--prompt=Model > ") + + if ($selected) { + $modelId = ($selected -split '\s{2,}')[0].Trim() + $script:ForgeSessionModel = $modelId + $env:FORGE_SESSION__MODEL_ID = $modelId + Write-ForgeLog 'success' "Session model: $modelId" + } +} + +function Invoke-ForgeActionConfigModel { + param([string]$InputText) + if ($InputText) { + Invoke-ForgeExec config set model $InputText + } else { + Invoke-ForgeExec config set model + } +} + +function Invoke-ForgeActionCommitModel { + param([string]$InputText) + if ($InputText) { + Invoke-ForgeExec config set commit-model $InputText + } else { + Invoke-ForgeExec config set commit-model + } +} + +function Invoke-ForgeActionSuggestModel { + param([string]$InputText) + if ($InputText) { + Invoke-ForgeExec config set suggest-model $InputText + } else { + Invoke-ForgeExec config set suggest-model + } +} + +function Invoke-ForgeActionReasoningEffort { + param([string]$InputText) + + if ($InputText) { + $script:ForgeSessionReasoningEffort = $InputText + $env:FORGE_REASONING__EFFORT = $InputText + Write-ForgeLog 'success' "Session reasoning effort: $InputText" + return + } + + $choices = @('low', 'medium', 'high') + if (Get-Command fzf -ErrorAction SilentlyContinue) { + $selected = $choices | Invoke-ForgeFzf -FzfArgs @("--prompt=Reasoning Effort > ") + if ($selected) { + $script:ForgeSessionReasoningEffort = $selected + $env:FORGE_REASONING__EFFORT = $selected + Write-ForgeLog 'success' "Session reasoning effort: $selected" + } + } else { + Write-ForgeLog 'info' "Options: $($choices -join ', '). Use :reasoning-effort " + } +} + +function Invoke-ForgeActionConfigReasoningEffort { + param([string]$InputText) + if ($InputText) { + Invoke-ForgeExec config set reasoning-effort $InputText + } else { + Invoke-ForgeExec config set reasoning-effort + } +} + +function Invoke-ForgeActionConfig { + Invoke-ForgeExec config list +} + +function Invoke-ForgeActionConfigEdit { + $configPath = Join-Path $HOME "forge" ".forge.toml" + $editor = if ($env:FORGE_EDITOR) { $env:FORGE_EDITOR } elseif ($env:EDITOR) { $env:EDITOR } else { "notepad" } + & $editor $configPath +} + +function Invoke-ForgeActionConfigReload { + $script:ForgeSessionModel = $null + $script:ForgeSessionProvider = $null + $script:ForgeSessionReasoningEffort = $null + Remove-Item Env:FORGE_SESSION__MODEL_ID -ErrorAction SilentlyContinue + Remove-Item Env:FORGE_SESSION__PROVIDER_ID -ErrorAction SilentlyContinue + Remove-Item Env:FORGE_REASONING__EFFORT -ErrorAction SilentlyContinue + Write-ForgeLog 'success' 'Session overrides cleared' +} + +function Invoke-ForgeActionTools { + Invoke-ForgeExec list tools +} + +function Invoke-ForgeActionSkills { + Invoke-ForgeExec list skills +} + +function Invoke-ForgeActionSync { + Write-ForgeLog 'info' 'Syncing workspace...' + Invoke-ForgeExec workspace sync --init +} diff --git a/shell-plugin/pwsh/lib/actions/conversation.ps1 b/shell-plugin/pwsh/lib/actions/conversation.ps1 new file mode 100644 index 0000000000..ab062403ee --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/conversation.ps1 @@ -0,0 +1,101 @@ +# Forge PowerShell Plugin - Conversation Actions + +function Invoke-ForgeActionConversation { + param([string]$InputText) + + # Toggle to previous conversation + if ($InputText -eq '-') { + if ($script:ForgePreviousConversationId) { + Switch-ForgeConversation $script:ForgePreviousConversationId + Write-ForgeLog 'success' "Switched to conversation: $script:ForgeConversationId" + Invoke-ForgeExec info + } else { + Write-ForgeLog 'warning' 'No previous conversation' + } + return + } + + # Direct ID switch + if ($InputText) { + Switch-ForgeConversation $InputText + Write-ForgeLog 'success' "Switched to conversation: $InputText" + Invoke-ForgeExec info + return + } + + # Interactive selection + $conversations = & $script:ForgeBin list conversations --porcelain 2>$null + if (-not $conversations) { + Write-ForgeLog 'warning' 'No conversations found' + return + } + + if (Get-Command fzf -ErrorAction SilentlyContinue) { + $selected = $conversations | + Where-Object { $_ -match '\S' } | + Select-Object -Skip 1 | + Invoke-ForgeFzf -FzfArgs @("--prompt=Conversation > ") + + if ($selected) { + $convId = ($selected -split '\s{2,}')[0].Trim() + Switch-ForgeConversation $convId + Write-ForgeLog 'success' "Switched to conversation: $convId" + Invoke-ForgeExec info + } + } else { + $conversations | Write-Host + } +} + +function Invoke-ForgeActionClone { + param([string]$InputText) + + $sourceId = if ($InputText) { $InputText } else { $script:ForgeConversationId } + + if (-not $sourceId) { + Write-ForgeLog 'error' 'No conversation to clone. Start or switch to one first.' + return + } + + $result = & $script:ForgeBin conversation clone $sourceId 2>$null + if ($result) { + $newId = ($result | Select-String -Pattern '[0-9a-f-]{36}').Matches.Value + if ($newId) { + Switch-ForgeConversation $newId + Write-ForgeLog 'success' "Cloned conversation to: $newId" + Invoke-ForgeExec info + } + } +} + +function Invoke-ForgeActionCopy { + $output = Invoke-ForgeExec dump --raw 2>$null + if ($output) { + $output | Set-Clipboard + Write-ForgeLog 'success' 'Last assistant message copied to clipboard' + } else { + Write-ForgeLog 'warning' 'No content to copy' + } +} + +function Invoke-ForgeActionRename { + param([string]$InputText) + + if (-not $script:ForgeConversationId) { + Write-ForgeLog 'error' 'No active conversation to rename' + return + } + + if (-not $InputText) { + Write-ForgeLog 'error' 'Usage: :rename ' + return + } + + Invoke-ForgeExec conversation rename $script:ForgeConversationId $InputText + Write-ForgeLog 'success' "Conversation renamed to: $InputText" +} + +function Invoke-ForgeActionConversationRename { + param([string]$InputText) + Invoke-ForgeActionRename $InputText +} diff --git a/shell-plugin/pwsh/lib/actions/core.ps1 b/shell-plugin/pwsh/lib/actions/core.ps1 new file mode 100644 index 0000000000..07416e65d6 --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/core.ps1 @@ -0,0 +1,53 @@ +# Forge PowerShell Plugin - Core Actions + +function Invoke-ForgeActionNew { + param([string]$InputText) + Clear-ForgeConversation + $script:ForgeCommands = $null # Reset commands cache + Write-ForgeLog 'success' 'New conversation started' + if ($InputText) { + Invoke-ForgeExec $InputText + } +} + +function Invoke-ForgeActionInfo { + $info = @() + $info += "Agent: $(if ($script:ForgeActiveAgent) { $script:ForgeActiveAgent } else { 'forge' })" + $info += "Conversation: $(if ($script:ForgeConversationId) { $script:ForgeConversationId } else { '(none)' })" + if ($script:ForgeSessionModel) { + $info += "Session Model: $script:ForgeSessionModel" + } + if ($script:ForgeSessionProvider) { + $info += "Session Provider: $script:ForgeSessionProvider" + } + if ($script:ForgeSessionReasoningEffort) { + $info += "Reasoning Effort: $script:ForgeSessionReasoningEffort" + } + Invoke-ForgeExec info +} + +function Invoke-ForgeActionEnv { + Write-Host "FORGE_BIN=$script:ForgeBin" + Write-Host "_FORGE_CONVERSATION_ID=$script:ForgeConversationId" + Write-Host "_FORGE_ACTIVE_AGENT=$script:ForgeActiveAgent" + Write-Host "FORGE_SESSION__MODEL_ID=$script:ForgeSessionModel" + Write-Host "FORGE_SESSION__PROVIDER_ID=$script:ForgeSessionProvider" + Write-Host "FORGE_REASONING__EFFORT=$script:ForgeSessionReasoningEffort" +} + +function Invoke-ForgeActionDump { + param([string]$InputText) + if ($InputText -eq '--html' -or $InputText -eq 'html') { + Invoke-ForgeExec dump --html + } else { + Invoke-ForgeExec dump + } +} + +function Invoke-ForgeActionCompact { + Invoke-ForgeExec compact +} + +function Invoke-ForgeActionRetry { + Invoke-ForgeExec retry +} diff --git a/shell-plugin/pwsh/lib/actions/doctor.ps1 b/shell-plugin/pwsh/lib/actions/doctor.ps1 new file mode 100644 index 0000000000..f235ea9ff3 --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/doctor.ps1 @@ -0,0 +1,5 @@ +# Forge PowerShell Plugin - Doctor Action + +function Invoke-ForgeActionDoctor { + & $script:ForgeBin powershell doctor +} diff --git a/shell-plugin/pwsh/lib/actions/editor.ps1 b/shell-plugin/pwsh/lib/actions/editor.ps1 new file mode 100644 index 0000000000..5fb32055ac --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/editor.ps1 @@ -0,0 +1,25 @@ +# Forge PowerShell Plugin - Editor Actions + +function Invoke-ForgeActionEditor { + $editor = if ($env:FORGE_EDITOR) { $env:FORGE_EDITOR } + elseif ($env:EDITOR) { $env:EDITOR } + else { "notepad" } + + $tempFile = [System.IO.Path]::GetTempFileName() + ".md" + + try { + # Open editor with temp file + $proc = Start-Process -FilePath $editor -ArgumentList $tempFile -Wait -PassThru -NoNewWindow + + if ($proc.ExitCode -eq 0 -and (Test-Path $tempFile)) { + $content = Get-Content $tempFile -Raw + if ($content -and $content.Trim()) { + Invoke-ForgeExec $content.Trim() + } + } + } finally { + if (Test-Path $tempFile) { + Remove-Item $tempFile -Force + } + } +} diff --git a/shell-plugin/pwsh/lib/actions/git.ps1 b/shell-plugin/pwsh/lib/actions/git.ps1 new file mode 100644 index 0000000000..8ff8e9bdac --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/git.ps1 @@ -0,0 +1,43 @@ +# Forge PowerShell Plugin - Git Actions + +function Invoke-ForgeActionCommit { + param([string]$InputText) + + $args = @('commit') + if ($InputText) { + $args += $InputText + } + Invoke-ForgeExec @args +} + +function Invoke-ForgeActionCommitPreview { + param([string]$InputText) + + $args = @('commit', '--preview') + if ($InputText) { + $args += $InputText + } + + $result = Invoke-ForgeExec @args 2>$null + if ($result) { + # Check for staged changes + $staged = git diff --cached --quiet 2>$null + $prefix = if ($LASTEXITCODE -ne 0) { 'git commit -m' } else { 'git commit -am' } + $message = $result -join "`n" + Write-Host "$prefix `"$message`"" + } +} + +function Invoke-ForgeActionSuggest { + param([string]$InputText) + + if (-not $InputText) { + Write-ForgeLog 'error' 'Usage: :suggest ' + return + } + + $result = & $script:ForgeBin suggest $InputText 2>$null + if ($result) { + Write-Host $result + } +} diff --git a/shell-plugin/pwsh/lib/actions/keyboard.ps1 b/shell-plugin/pwsh/lib/actions/keyboard.ps1 new file mode 100644 index 0000000000..7f14c156ec --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/keyboard.ps1 @@ -0,0 +1,5 @@ +# Forge PowerShell Plugin - Keyboard Action + +function Invoke-ForgeActionKeyboard { + & $script:ForgeBin powershell keyboard +} diff --git a/shell-plugin/pwsh/lib/actions/provider.ps1 b/shell-plugin/pwsh/lib/actions/provider.ps1 new file mode 100644 index 0000000000..aab62f2d5d --- /dev/null +++ b/shell-plugin/pwsh/lib/actions/provider.ps1 @@ -0,0 +1,30 @@ +# Forge PowerShell Plugin - Provider Actions + +function Invoke-ForgeActionProvider { + param([string]$InputText) + + if ($InputText) { + Invoke-ForgeExec config set provider $InputText + return + } + + $providers = & $script:ForgeBin list provider --porcelain 2>$null + if (-not $providers) { + Write-ForgeLog 'error' 'Failed to list providers' + return + } + + if (Get-Command fzf -ErrorAction SilentlyContinue) { + $selected = $providers | + Where-Object { $_ -match '\S' } | + Select-Object -Skip 1 | + Invoke-ForgeFzf -FzfArgs @("--prompt=Provider > ") + + if ($selected) { + $providerId = ($selected -split '\s{2,}')[0].Trim() + Invoke-ForgeExec config set provider $providerId + } + } else { + $providers | Write-Host + } +} diff --git a/shell-plugin/pwsh/lib/bindings.ps1 b/shell-plugin/pwsh/lib/bindings.ps1 new file mode 100644 index 0000000000..0f68e82099 --- /dev/null +++ b/shell-plugin/pwsh/lib/bindings.ps1 @@ -0,0 +1,30 @@ +# Forge PowerShell Plugin - Command Interception +# Uses a PreCommandLookupAction to intercept :command patterns before +# PowerShell tries to execute them. Works on all PowerShell versions. + +$ExecutionContext.SessionState.InvokeCommand.PreCommandLookupAction = { + param($commandName, $commandLookupEventArgs) + + # Check if the command starts with ':' + if ($commandName -match '^:(.*)$') { + # Mark as handled so PowerShell doesn't throw "command not found" + $commandLookupEventArgs.StopSearch = $true + $commandLookupEventArgs.CommandScriptBlock = { + # Parse the full invocation line from $MyInvocation + $fullLine = $MyInvocation.Line.Trim() + + if ($fullLine -match '^:([a-zA-Z][a-zA-Z0-9_-]*)(\s+(.*))?$') { + $action = $Matches[1] + $text = if ($Matches[3]) { $Matches[3].Trim() } else { "" } + Invoke-ForgeDispatch -Action $action -InputText $text + } + elseif ($fullLine -match '^:\s+(.+)$') { + $text = $Matches[1].Trim() + Invoke-ForgeDispatch -Action "" -InputText $text + } + else { + # Just ":" with nothing — ignore + } + }.GetNewClosure() + } +} diff --git a/shell-plugin/pwsh/lib/completion.ps1 b/shell-plugin/pwsh/lib/completion.ps1 new file mode 100644 index 0000000000..acfeb94997 --- /dev/null +++ b/shell-plugin/pwsh/lib/completion.ps1 @@ -0,0 +1,24 @@ +# Forge PowerShell Plugin - Tab Completion +# Provides argument completion for the forge CLI. + +Register-ArgumentCompleter -Native -CommandName forge -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandLine = $commandAst.ToString() + + # Use forge's built-in completion mechanism + $env:_CLAP_COMPLETE = "powershell" + $completions = & $script:ForgeBin complete -- "$commandLine" 2>$null + Remove-Item Env:_CLAP_COMPLETE -ErrorAction SilentlyContinue + + if ($completions) { + $completions | ForEach-Object { + $parts = $_ -split '\t', 2 + $text = $parts[0] + $tooltip = if ($parts.Length -gt 1) { $parts[1] } else { $text } + [System.Management.Automation.CompletionResult]::new( + $text, $text, 'ParameterValue', $tooltip + ) + } + } +} diff --git a/shell-plugin/pwsh/lib/config.ps1 b/shell-plugin/pwsh/lib/config.ps1 new file mode 100644 index 0000000000..3759f49026 --- /dev/null +++ b/shell-plugin/pwsh/lib/config.ps1 @@ -0,0 +1,32 @@ +# Forge PowerShell Plugin - Session Configuration +# Session state variables for forge shell integration. + +if (-not (Get-Variable -Name 'ForgeBin' -Scope Script -ErrorAction SilentlyContinue)) { + $script:ForgeBin = if ($env:FORGE_BIN) { $env:FORGE_BIN } else { "forge" } +} + +$script:ForgeConversationId = $null +$script:ForgePreviousConversationId = $null +$script:ForgeActiveAgent = $null +$script:ForgeSessionModel = $null +$script:ForgeSessionProvider = $null +$script:ForgeSessionReasoningEffort = $null +$script:ForgeMaxCommitDiff = if ($env:FORGE_MAX_COMMIT_DIFF) { [int]$env:FORGE_MAX_COMMIT_DIFF } else { 100000 } +$script:ForgeCommands = $null + +# Tool detection +$script:ForgeFdCmd = if (Get-Command fdfind -ErrorAction SilentlyContinue) { 'fdfind' } + elseif (Get-Command fd -ErrorAction SilentlyContinue) { 'fd' } + else { $null } + +$script:ForgeCatCmd = if (Get-Command bat -ErrorAction SilentlyContinue) { + 'bat --color=always --style=numbers,changes --line-range=:500' +} else { 'Get-Content' } + +# Default nerd fonts to off on Windows PowerShell 5.1 (most terminals lack PUA glyphs) +# User can override with $env:NERD_FONT = "1" +if (-not $env:NERD_FONT -and -not $env:USE_NERD_FONT) { + if ($PSVersionTable.PSVersion.Major -le 5) { + $env:NERD_FONT = "0" + } +} diff --git a/shell-plugin/pwsh/lib/dispatcher.ps1 b/shell-plugin/pwsh/lib/dispatcher.ps1 new file mode 100644 index 0000000000..d13ae6158c --- /dev/null +++ b/shell-plugin/pwsh/lib/dispatcher.ps1 @@ -0,0 +1,152 @@ +# Forge PowerShell Plugin - Command Dispatcher +# Intercepts Enter key to detect and route :command patterns via PSReadLine. + +function Invoke-ForgeChat { + param([string]$InputText) + + if (-not $InputText) { return } + + # Generate conversation ID if needed + if (-not $script:ForgeConversationId) { + $newId = & $script:ForgeBin conversation new 2>$null + if ($newId) { + $newId = $newId.Trim() + $script:ForgeConversationId = $newId + $env:_FORGE_CONVERSATION_ID = $newId + } + } + + # Build the command: forge --agent -p "text" --cid + $agentId = if ($script:ForgeActiveAgent) { $script:ForgeActiveAgent } else { "forge" } + $cmd = @("--agent", $agentId, "-p", $InputText) + + if ($script:ForgeConversationId) { + $cmd += @("--cid", $script:ForgeConversationId) + } + + # Set session env vars + if ($script:ForgeSessionModel) { + $env:FORGE_SESSION__MODEL_ID = $script:ForgeSessionModel + } + if ($script:ForgeSessionProvider) { + $env:FORGE_SESSION__PROVIDER_ID = $script:ForgeSessionProvider + } + if ($script:ForgeSessionReasoningEffort) { + $env:FORGE_REASONING__EFFORT = $script:ForgeSessionReasoningEffort + } + + & $script:ForgeBin @cmd +} + +function Invoke-ForgeDispatch { + param( + [string]$Action, + [string]$InputText + ) + + # Default action (no command name, just ": text") + if (-not $Action) { + if ($InputText) { + Invoke-ForgeChat $InputText + } + return + } + + # Resolve aliases + $resolvedAction = switch ($Action) { + 'n' { 'new' } + 'i' { 'info' } + 'a' { 'agent' } + 'c' { 'conversation' } + 'm' { 'session-model' } + 'cm' { 'config-model' } + 're' { 'reasoning-effort' } + 'ce' { 'config-edit' } + 'kb' { 'keyboard-shortcuts' } + 's' { 'suggest' } + 'ask' { 'sage' } + 'plan' { 'muse' } + default { $Action } + } + + switch ($resolvedAction) { + # Core actions + 'new' { Invoke-ForgeActionNew $InputText } + 'info' { Invoke-ForgeActionInfo } + 'env' { Invoke-ForgeActionEnv } + 'dump' { Invoke-ForgeActionDump $InputText } + 'compact' { Invoke-ForgeActionCompact } + 'retry' { Invoke-ForgeActionRetry } + + # Config actions + 'agent' { Invoke-ForgeActionAgent $InputText } + 'model' { Invoke-ForgeActionConfigModel $InputText } + 'session-model' { Invoke-ForgeActionSessionModel $InputText } + 'config-model' { Invoke-ForgeActionConfigModel $InputText } + 'commit-model' { Invoke-ForgeActionCommitModel $InputText } + 'suggest-model' { Invoke-ForgeActionSuggestModel $InputText } + 'reasoning-effort' { Invoke-ForgeActionReasoningEffort $InputText } + 'config-reasoning-effort' { Invoke-ForgeActionConfigReasoningEffort $InputText } + 'config' { Invoke-ForgeActionConfig } + 'config-edit' { Invoke-ForgeActionConfigEdit } + 'config-reload' { Invoke-ForgeActionConfigReload } + 'tools' { Invoke-ForgeActionTools } + 'skill' { Invoke-ForgeActionSkills } + 'sync' { Invoke-ForgeActionSync } + + # Conversation actions + 'conversation' { Invoke-ForgeActionConversation $InputText } + 'clone' { Invoke-ForgeActionClone $InputText } + 'copy' { Invoke-ForgeActionCopy } + 'rename' { Invoke-ForgeActionRename $InputText } + 'conversation-rename' { Invoke-ForgeActionConversationRename $InputText } + + # Git actions + 'commit' { Invoke-ForgeActionCommit $InputText } + 'commit-preview' { Invoke-ForgeActionCommitPreview $InputText } + 'suggest' { Invoke-ForgeActionSuggest $InputText } + + # Auth actions + 'provider-login' { Invoke-ForgeActionLogin } + 'login' { Invoke-ForgeActionLogin } + 'logout' { Invoke-ForgeActionLogout } + + # Editor + 'edit' { Invoke-ForgeActionEditor } + + # Diagnostics + 'doctor' { Invoke-ForgeActionDoctor } + 'keyboard-shortcuts' { Invoke-ForgeActionKeyboard } + + # Provider + 'provider' { Invoke-ForgeActionProvider $InputText } + 'config-provider' { Invoke-ForgeActionProvider $InputText } + + default { + # Check if it's a known agent name — set it and chat + $commands = Get-ForgeCommands + $isAgent = $false + if ($commands) { + foreach ($line in $commands) { + $fields = $line -split '\s{2,}' + if ($fields[0].Trim() -eq $resolvedAction -and $fields.Length -gt 3 -and $fields[3].Trim() -eq 'AGENT') { + $isAgent = $true + break + } + } + } + + if ($isAgent) { + $script:ForgeActiveAgent = $resolvedAction + $env:_FORGE_ACTIVE_AGENT = $resolvedAction + if ($InputText) { + Invoke-ForgeChat $InputText + } else { + Write-ForgeLog 'info' "$($resolvedAction.ToUpper()) is now the active agent" + } + } else { + Write-ForgeLog 'error' "Unknown command: :$Action" + } + } + } +} diff --git a/shell-plugin/pwsh/lib/helpers.ps1 b/shell-plugin/pwsh/lib/helpers.ps1 new file mode 100644 index 0000000000..2dbc7a30f2 --- /dev/null +++ b/shell-plugin/pwsh/lib/helpers.ps1 @@ -0,0 +1,102 @@ +# Forge PowerShell Plugin - Helper Functions + +function Get-ForgeCommands { + if (-not $script:ForgeCommands) { + $origColor = $env:CLICOLOR_FORCE + $env:CLICOLOR_FORCE = "0" + $script:ForgeCommands = & $script:ForgeBin list commands --porcelain 2>$null + $env:CLICOLOR_FORCE = $origColor + } + return $script:ForgeCommands +} + +function Invoke-ForgeExec { + param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + $agentId = if ($script:ForgeActiveAgent) { $script:ForgeActiveAgent } else { "forge" } + $cmd = @("--agent", $agentId) + + if ($script:ForgeConversationId) { + $env:_FORGE_CONVERSATION_ID = $script:ForgeConversationId + } + if ($script:ForgeActiveAgent) { + $env:_FORGE_ACTIVE_AGENT = $script:ForgeActiveAgent + } + if ($script:ForgeSessionModel) { + $env:FORGE_SESSION__MODEL_ID = $script:ForgeSessionModel + } + if ($script:ForgeSessionProvider) { + $env:FORGE_SESSION__PROVIDER_ID = $script:ForgeSessionProvider + } + if ($script:ForgeSessionReasoningEffort) { + $env:FORGE_REASONING__EFFORT = $script:ForgeSessionReasoningEffort + } + + & $script:ForgeBin @cmd @Arguments +} + +function Invoke-ForgeFzf { + param( + [Parameter(ValueFromPipeline = $true)] + [string[]]$InputObject, + [string[]]$FzfArgs = @() + ) + + begin { $lines = @() } + process { $lines += $InputObject } + end { + $defaultArgs = @( + '--reverse', '--exact', '--cycle', '--select-1', + '--height', '80%', '--no-scrollbar', '--ansi', + '--color=header:bold' + ) + $allArgs = $defaultArgs + $FzfArgs + $lines | & fzf @allArgs + } +} + +function Write-ForgeLog { + param( + [ValidateSet('error', 'info', 'success', 'warning', 'debug')] + [string]$Level, + [string]$Message + ) + + $ts = Get-Date -Format "HH:mm:ss" + $e = [char]27 + + switch ($Level) { + 'error' { Write-Host "$e[31m*$e[0m [$ts] $e[31m$Message$e[0m" } + 'info' { Write-Host "$e[37m*$e[0m [$ts] $e[37m$Message$e[0m" } + 'success' { Write-Host "$e[33m*$e[0m [$ts] $e[37m$Message$e[0m" } + 'warning' { Write-Host "$e[93m!$e[0m [$ts] $e[93m$Message$e[0m" } + 'debug' { Write-Host "$e[36m*$e[0m [$ts] $e[90m$Message$e[0m" } + } +} + +function Switch-ForgeConversation { + param([string]$NewId) + if ($script:ForgeConversationId) { + $script:ForgePreviousConversationId = $script:ForgeConversationId + } + $script:ForgeConversationId = $NewId + $env:_FORGE_CONVERSATION_ID = $NewId +} + +function Clear-ForgeConversation { + if ($script:ForgeConversationId) { + $script:ForgePreviousConversationId = $script:ForgeConversationId + } + $script:ForgeConversationId = $null + $env:_FORGE_CONVERSATION_ID = $null +} + +function Start-ForgeBackgroundSync { + Start-Job -ScriptBlock { + param($bin) + & $bin workspace sync --init 2>$null + } -ArgumentList $script:ForgeBin | Out-Null +} diff --git a/shell-plugin/pwsh/lib/highlight.ps1 b/shell-plugin/pwsh/lib/highlight.ps1 new file mode 100644 index 0000000000..a9628b4381 --- /dev/null +++ b/shell-plugin/pwsh/lib/highlight.ps1 @@ -0,0 +1,15 @@ +# Forge PowerShell Plugin - Syntax Highlighting +# PSReadLine token-based coloring. Note: PSReadLine does not support +# regex-based pattern highlighting like zsh-syntax-highlighting, +# so :command highlighting is limited to token-level colors. + +if (Get-Module PSReadLine) { + $esc = [char]0x1b + Set-PSReadLineOption -Colors @{ + Command = "$esc[33m" # Yellow for commands + Parameter = "$esc[36m" # Cyan for parameters + String = "$esc[32m" # Green for strings + Number = "$esc[35m" # Magenta for numbers + Comment = "$esc[90m" # Gray for comments + } +} From df867dda6b6f18d754b2d6d1798017e9b88369db Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 08:36:03 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- crates/forge_main/src/lib.rs | 4 +- crates/forge_main/src/powershell/plugin.rs | 43 ++++++++------------- crates/forge_main/src/powershell/rprompt.rs | 33 +++++++++++----- crates/forge_main/src/shell/prompt.rs | 8 +++- crates/forge_main/src/shell/setup.rs | 14 ++----- crates/forge_main/src/ui.rs | 4 +- crates/forge_main/src/zsh/mod.rs | 6 +-- crates/forge_main/src/zsh/plugin.rs | 5 +-- crates/forge_main/src/zsh/rprompt.rs | 6 ++- 9 files changed, 62 insertions(+), 61 deletions(-) diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 2128e52088..9c9c294a45 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -8,8 +8,10 @@ mod info; mod input; mod model; mod porcelain; +mod powershell; mod prompt; mod sandbox; +mod shell; mod state; mod stream_renderer; mod sync_display; @@ -19,8 +21,6 @@ pub mod tracker; mod ui; mod utils; mod vscode; -mod powershell; -mod shell; mod zsh; mod update; diff --git a/crates/forge_main/src/powershell/plugin.rs b/crates/forge_main/src/powershell/plugin.rs index e6361c0443..db7dd56de3 100644 --- a/crates/forge_main/src/powershell/plugin.rs +++ b/crates/forge_main/src/powershell/plugin.rs @@ -34,13 +34,10 @@ pub fn generate_powershell_plugin() -> Result { fn collect_ps1_files(dir: &Dir<'_>, output: &mut String) { // Process files in this directory first for file in dir.files() { - if let Some(ext) = file.path().extension() { - if ext == "ps1" { - if let Some(contents) = file.contents_utf8() { - output.push_str(&format!( - "\n# --- {} ---\n", - file.path().display() - )); + if let Some(ext) = file.path().extension() + && ext == "ps1" + && let Some(contents) = file.contents_utf8() { + output.push_str(&format!("\n# --- {} ---\n", file.path().display())); // Strip comment-only lines to reduce size for line in contents.lines() { let trimmed = line.trim(); @@ -51,8 +48,6 @@ fn collect_ps1_files(dir: &Dir<'_>, output: &mut String) { output.push('\n'); } } - } - } } // Recurse into subdirectories @@ -102,10 +97,7 @@ pub fn setup_powershell_integration( let result = setup::setup_shell_integration(&config)?; - Ok(PowerShellSetupResult { - message: result.message, - backup_path: result.backup_path, - }) + Ok(PowerShellSetupResult { message: result.message, backup_path: result.backup_path }) } /// Finds the PowerShell profile path. @@ -116,29 +108,24 @@ fn find_powershell_profile() -> Result { if let Ok(output) = std::process::Command::new("pwsh") .args(["-NoProfile", "-Command", "$PROFILE"]) .output() - { - if output.status.success() { + && output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { return Ok(PathBuf::from(path)); } } - } // Fall back to Windows PowerShell - if cfg!(target_os = "windows") { - if let Ok(output) = std::process::Command::new("powershell") + if cfg!(target_os = "windows") + && let Ok(output) = std::process::Command::new("powershell") .args(["-NoProfile", "-Command", "$PROFILE"]) .output() - { - if output.status.success() { + && output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { return Ok(PathBuf::from(path)); } } - } - } // Final fallback: construct the standard path let home = if cfg!(target_os = "windows") { @@ -149,9 +136,7 @@ fn find_powershell_profile() -> Result { .context("Could not determine home directory")?; let profile_dir = if cfg!(target_os = "windows") { - PathBuf::from(&home) - .join("Documents") - .join("PowerShell") + PathBuf::from(&home).join("Documents").join("PowerShell") } else { PathBuf::from(&home).join(".config").join("powershell") }; @@ -184,7 +169,13 @@ fn execute_powershell_script(script: &str) -> Result<()> { }; let output = std::process::Command::new(shell) - .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]) + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() diff --git a/crates/forge_main/src/powershell/rprompt.rs b/crates/forge_main/src/powershell/rprompt.rs index a1d9478d69..1db8eca2bb 100644 --- a/crates/forge_main/src/powershell/rprompt.rs +++ b/crates/forge_main/src/powershell/rprompt.rs @@ -84,8 +84,8 @@ impl Display for PowerShellRPrompt { } // Cost - if let Some(cost) = self.data.cost { - if active { + if let Some(cost) = self.data.cost + && active { let converted = cost * self.data.conversion_ratio; let currency = if self.data.use_nerd_font && self.data.currency_symbol == "$" { CURRENCY_GLYPH.to_string() @@ -95,7 +95,6 @@ impl Display for PowerShellRPrompt { let cost_str = format!("{}{:.2}", currency, converted); write!(f, " {}", cost_str.ansi().bold().fg(AnsiColor::GREEN))?; } - } // Model if let Some(ref model_id) = self.data.model { @@ -125,8 +124,8 @@ mod tests { cost: Option, ) -> ShellPromptData { ShellPromptData { - agent: agent.map(|a| AgentId::new(a)), - model: model.map(|m| ModelId::new(m)), + agent: agent.map(AgentId::new), + model: model.map(ModelId::new), token_count, cost, use_nerd_font: true, @@ -142,7 +141,11 @@ mod tests { let output = prompt.to_string(); // Should use dimmed color (90 = dark gray) for both agent and model - assert!(output.contains("\x1b[1;90m"), "agent should be dimmed: {}", output); + assert!( + output.contains("\x1b[1;90m"), + "agent should be dimmed: {}", + output + ); assert!(output.contains("FORGE")); assert!(output.contains("gpt-4")); } @@ -159,8 +162,16 @@ mod tests { let output = prompt.to_string(); // Should use bright white (97) for agent/tokens and cyan (36) for model - assert!(output.contains("\x1b[1;97m"), "agent should be bright white: {}", output); - assert!(output.contains("\x1b[36m"), "model should be cyan: {}", output); + assert!( + output.contains("\x1b[1;97m"), + "agent should be bright white: {}", + output + ); + assert!( + output.contains("\x1b[36m"), + "model should be cyan: {}", + output + ); assert!(output.contains("1.5k")); } @@ -179,7 +190,11 @@ mod tests { let output = prompt.to_string(); assert!(output.contains("$0.01")); - assert!(output.contains("\x1b[1;32m"), "cost should be green: {}", output); + assert!( + output.contains("\x1b[1;32m"), + "cost should be green: {}", + output + ); } #[test] diff --git a/crates/forge_main/src/shell/prompt.rs b/crates/forge_main/src/shell/prompt.rs index f0c3b37480..da3642a384 100644 --- a/crates/forge_main/src/shell/prompt.rs +++ b/crates/forge_main/src/shell/prompt.rs @@ -11,7 +11,8 @@ use forge_api::{API, AgentId, Conversation, ConversationId, ModelId}; use forge_domain::TokenCount; use futures::future; -/// Shell-agnostic prompt data, collected once and passed to any shell formatter. +/// Shell-agnostic prompt data, collected once and passed to any shell +/// formatter. pub struct ShellPromptData { pub agent: Option, pub model: Option, @@ -45,7 +46,10 @@ pub async fn fetch_prompt_data(api: &(dyn API + Send + Sync)) -> ShellPromptData // Calculate total cost including related conversations let cost = if let Some(ref conv) = conversation { let related = fetch_related_conversations(api, conv).await; - let all: Vec<_> = std::iter::once(conv).chain(related.iter()).cloned().collect(); + let all: Vec<_> = std::iter::once(conv) + .chain(related.iter()) + .cloned() + .collect(); Conversation::total_cost(&all) } else { None diff --git a/crates/forge_main/src/shell/setup.rs b/crates/forge_main/src/shell/setup.rs index 48dc0f9066..495d8bc3dd 100644 --- a/crates/forge_main/src/shell/setup.rs +++ b/crates/forge_main/src/shell/setup.rs @@ -102,9 +102,7 @@ pub fn setup_shell_integration(config: &ShellSetupConfig<'_>) -> Result) -> Result = vec!["# >>> start >>>".into(), "content".into()]; assert!(matches!( parse_markers(&lines, "# >>> start >>>", "# <<< end <<<"), - MarkerState::Invalid { - start: Some(0), - end: None - } + MarkerState::Invalid { start: Some(0), end: None } )); } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index f0c32ce433..1ee9e3cf66 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3659,9 +3659,7 @@ impl A + Send + Sync> UI { if let Some(backup) = result.backup_path { println!("Backup created at: {}", backup.display()); } - println!( - "\nRestart PowerShell or run: . $PROFILE" - ); + println!("\nRestart PowerShell or run: . $PROFILE"); Ok(()) } diff --git a/crates/forge_main/src/zsh/mod.rs b/crates/forge_main/src/zsh/mod.rs index c7ef873408..43cdff6e91 100644 --- a/crates/forge_main/src/zsh/mod.rs +++ b/crates/forge_main/src/zsh/mod.rs @@ -11,11 +11,11 @@ mod plugin; mod rprompt; mod style; -/// Re-export from shared shell module for backward compatibility. -pub(crate) use crate::shell::normalize_script; - pub use plugin::{ generate_zsh_plugin, generate_zsh_theme, run_zsh_doctor, run_zsh_keyboard, setup_zsh_integration, }; pub use rprompt::ZshRPrompt; + +/// Re-export from shared shell module for backward compatibility. +pub(crate) use crate::shell::normalize_script; diff --git a/crates/forge_main/src/zsh/plugin.rs b/crates/forge_main/src/zsh/plugin.rs index d9541b5b95..f5b98316d6 100644 --- a/crates/forge_main/src/zsh/plugin.rs +++ b/crates/forge_main/src/zsh/plugin.rs @@ -243,10 +243,7 @@ pub fn setup_zsh_integration( let result = crate::shell::setup::setup_shell_integration(&config)?; - Ok(ZshSetupResult { - message: result.message, - backup_path: result.backup_path, - }) + Ok(ZshSetupResult { message: result.message, backup_path: result.backup_path }) } #[cfg(test)] diff --git a/crates/forge_main/src/zsh/rprompt.rs b/crates/forge_main/src/zsh/rprompt.rs index b6598a19b1..759dd388ad 100644 --- a/crates/forge_main/src/zsh/rprompt.rs +++ b/crates/forge_main/src/zsh/rprompt.rs @@ -81,7 +81,8 @@ const CURRENCY_GLYPH: char = '\u{f155}'; // Nerd Font glyphs wrapped with %{…%WG%} so zsh counts each as the correct // number of visible columns. These Private Use Area codepoints have // terminal-dependent rendering width (1 or 2 columns). Use the -// NERD_FONT_GLYPH_WIDTH=1 or =2 environment variable to override (default: 2 for Windows 11). +// NERD_FONT_GLYPH_WIDTH=1 or =2 environment variable to override (default: 2 +// for Windows 11). fn nerd_glyph_width() -> u8 { std::env::var("NERD_FONT_GLYPH_WIDTH") .ok() @@ -184,7 +185,8 @@ mod tests { .token_count(Some(TokenCount::Actual(1500))) .to_string(); - let expected = " %B%F{15}%{\u{f167a}%2G%} FORGE%f%b %B%F{15}1.5k%f%b %F{134}%{\u{ec19}%2G%} gpt-4%f"; + let expected = + " %B%F{15}%{\u{f167a}%2G%} FORGE%f%b %B%F{15}1.5k%f%b %F{134}%{\u{ec19}%2G%} gpt-4%f"; assert_eq!(actual, expected); } From 8e832a4d171a8d835a5c945619a03520436cb1c2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 08:37:44 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes (attempt 2/3) --- crates/forge_main/src/powershell/plugin.rs | 47 +++++++++++---------- crates/forge_main/src/powershell/rprompt.rs | 21 ++++----- crates/forge_main/src/shell/setup.rs | 9 ++-- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/crates/forge_main/src/powershell/plugin.rs b/crates/forge_main/src/powershell/plugin.rs index db7dd56de3..b3d4eb2fc5 100644 --- a/crates/forge_main/src/powershell/plugin.rs +++ b/crates/forge_main/src/powershell/plugin.rs @@ -36,18 +36,19 @@ fn collect_ps1_files(dir: &Dir<'_>, output: &mut String) { for file in dir.files() { if let Some(ext) = file.path().extension() && ext == "ps1" - && let Some(contents) = file.contents_utf8() { - output.push_str(&format!("\n# --- {} ---\n", file.path().display())); - // Strip comment-only lines to reduce size - for line in contents.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - output.push_str(line); - output.push('\n'); - } + && let Some(contents) = file.contents_utf8() + { + output.push_str(&format!("\n# --- {} ---\n", file.path().display())); + // Strip comment-only lines to reduce size + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; } + output.push_str(line); + output.push('\n'); + } + } } // Recurse into subdirectories @@ -108,24 +109,26 @@ fn find_powershell_profile() -> Result { if let Ok(output) = std::process::Command::new("pwsh") .args(["-NoProfile", "-Command", "$PROFILE"]) .output() - && output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } + && output.status.success() + { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); } + } // Fall back to Windows PowerShell if cfg!(target_os = "windows") && let Ok(output) = std::process::Command::new("powershell") .args(["-NoProfile", "-Command", "$PROFILE"]) .output() - && output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } - } + && output.status.success() + { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } // Final fallback: construct the standard path let home = if cfg!(target_os = "windows") { diff --git a/crates/forge_main/src/powershell/rprompt.rs b/crates/forge_main/src/powershell/rprompt.rs index 1db8eca2bb..6a177fa10a 100644 --- a/crates/forge_main/src/powershell/rprompt.rs +++ b/crates/forge_main/src/powershell/rprompt.rs @@ -85,16 +85,17 @@ impl Display for PowerShellRPrompt { // Cost if let Some(cost) = self.data.cost - && active { - let converted = cost * self.data.conversion_ratio; - let currency = if self.data.use_nerd_font && self.data.currency_symbol == "$" { - CURRENCY_GLYPH.to_string() - } else { - self.data.currency_symbol.clone() - }; - let cost_str = format!("{}{:.2}", currency, converted); - write!(f, " {}", cost_str.ansi().bold().fg(AnsiColor::GREEN))?; - } + && active + { + let converted = cost * self.data.conversion_ratio; + let currency = if self.data.use_nerd_font && self.data.currency_symbol == "$" { + CURRENCY_GLYPH.to_string() + } else { + self.data.currency_symbol.clone() + }; + let cost_str = format!("{}{:.2}", currency, converted); + write!(f, " {}", cost_str.ansi().bold().fg(AnsiColor::GREEN))?; + } // Model if let Some(ref model_id) = self.data.model { diff --git a/crates/forge_main/src/shell/setup.rs b/crates/forge_main/src/shell/setup.rs index 495d8bc3dd..610bf6e1ba 100644 --- a/crates/forge_main/src/shell/setup.rs +++ b/crates/forge_main/src/shell/setup.rs @@ -160,10 +160,11 @@ pub fn setup_shell_integration(config: &ShellSetupConfig<'_>) -> Result