From b46012d5ce0d80b3aacc81db6c357223928c85ee Mon Sep 17 00:00:00 2001 From: pmqueiroz Date: Wed, 13 May 2026 19:21:47 -0300 Subject: [PATCH 1/2] feat: show exit code banner with AI explain suggestion for failed commands --- src/sys/pty.rs | 93 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/src/sys/pty.rs b/src/sys/pty.rs index 5a13503..b9f5d94 100644 --- a/src/sys/pty.rs +++ b/src/sys/pty.rs @@ -121,13 +121,15 @@ fn build_shell_command(shell: &str, initial_cwd: Option<&str>) -> CommandBuilder let ps_prompt_script = format!( r#" Set-Item function:prompt {{ + $exitCode = $global:LASTEXITCODE $p = $PWD.ProviderPath; $h = [regex]::Escape($env:USERPROFILE); $d = $p -replace ('^' + $h), '~'; $uri = 'file://localhost/' + ($p -replace '\\', '/'); $ESC = [char]27; Write-Host -NoNewline ('{{0}}]7;{{1}}{{0}}{{2}}' -f [char]27, $uri, [char]92); - return ($ESC + '[38;2;128;128;128m' + $d + $ESC + '[0m ' + $ESC + '[38;2;{ar};{ag};{ab}mλ' + $ESC + '[0m ') + $banner = if ($exitCode -ne 0) {{ "`r`n$ESC[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $exitCode)$ESC[0m`r`n$ESC[38;2;{ar};{ag};{ab}m[>] Press Ctrl+T, search 'Explain Error'$ESC[0m`r`n" }} else {{ "" }} + return ($banner + $ESC + '[38;2;128;128;128m' + $d + $ESC + '[0m ' + $ESC + '[38;2;{ar};{ag};{ab}mλ' + $ESC + '[0m ') }} if (-not (Get-Command ssh -CommandType Function -ErrorAction SilentlyContinue)) {{ function global:ssh {{ @@ -142,7 +144,7 @@ fn build_shell_command(shell: &str, initial_cwd: Option<&str>) -> CommandBuilder $ssh_exe = (Get-Command -Name ssh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1).Source if ($ssh_exe) {{ & $ssh_exe @args }} else {{ Write-Error 'ssh not found' }} $p2 = $PWD.ProviderPath; $u2 = 'file://localhost/' + ($p2 -replace '\\', '/') - Write-Host -NoNewline ('{{0}}]7;{{1}}{{0}}{{2}}' -f [char]27, $u2, [char]92) + Write-Host -NoNewline ('{{0}}]7;ssh://{{1}}{{0}}{{2}}' -f [char]27, $u2, [char]92) }} }} "# @@ -181,14 +183,24 @@ fn build_shell_command(shell: &str, initial_cwd: Option<&str>) -> CommandBuilder ); c.env( "PROMPT_COMMAND", - r#"if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } - ssh() { __nova_ssh "$@"; } + format!( + r#"__nova_exit_code=$? +if ! declare -f __nova_ssh > /dev/null 2>&1; then + __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + ssh() {{ __nova_ssh "$@"; }} fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} fi -__nova_osc7"#, +__nova_osc7 +if [ "$__nova_exit_code" -ne 0 ]; then + printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" + printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette Ctrl+T, search 'Explain Error'\033[0m\n" +fi"#, + ar = ar, + ag = ag, + ab = ab, + ), ); c } else if is_git_bash { @@ -211,14 +223,24 @@ __nova_osc7"#, ); c.env( "PROMPT_COMMAND", - r#"if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } - ssh() { __nova_ssh "$@"; } + format!( + r#"__nova_exit_code=$? +if ! declare -f __nova_ssh > /dev/null 2>&1; then + __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + ssh() {{ __nova_ssh "$@"; }} fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} fi -__nova_osc7"#, +__nova_osc7 +if [ "$__nova_exit_code" -ne 0 ]; then + printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" + printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette Ctrl+T, search 'Explain Error'\033[0m\n" +fi"#, + ar = ar, + ag = ag, + ab = ab, + ), ); c.args(["--login", "-i"]); c @@ -268,16 +290,32 @@ __nova_osc7"#, "\\[\\e[38;2;128;128;128m\\]\\w\\[\\e[0m\\] \\[\\e[38;2;{ar};{ag};{ab}m\\]λ\\[\\e[0m\\] " ), ); + let palette_key = if cfg!(target_os = "macos") { + "⌘T" + } else { + "Ctrl+T" + }; c.env( "PROMPT_COMMAND", - r#"if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } - ssh() { __nova_ssh "$@"; } + format!( + r#"__nova_exit_code=$? +if ! declare -f __nova_ssh > /dev/null 2>&1; then + __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + ssh() {{ __nova_ssh "$@"; }} fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} fi -__nova_osc7"#, +__nova_osc7 +if [ "$__nova_exit_code" -ne 0 ]; then + printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" + printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette {palette_key}, search 'Explain Error'\033[0m\n" +fi"#, + ar = ar, + ag = ag, + ab = ab, + palette_key = palette_key, + ), ); c.env( @@ -288,14 +326,25 @@ __nova_osc7"#, ); c.env( "NOVA_PROMPT_COMMAND", - r#"if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } - ssh() { __nova_ssh "$@"; } + format!( + r#"__nova_exit_code=$? +if ! declare -f __nova_ssh > /dev/null 2>&1; then + __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + ssh() {{ __nova_ssh "$@"; }} fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} fi -__nova_osc7"#, +__nova_osc7 +if [ "$__nova_exit_code" -ne 0 ]; then + printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" + printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette {palette_key}, search 'Explain Error'\033[0m\n" +fi"#, + ar = ar, + ag = ag, + ab = ab, + palette_key = palette_key, + ), ); } c From e608301712db5e8c3472a4b996ff8052a7e88f4d Mon Sep 17 00:00:00 2001 From: pmqueiroz Date: Wed, 13 May 2026 21:52:25 -0300 Subject: [PATCH 2/2] feat(ai): implement diagnostic banner --- src/core/config.rs | 7 ++ src/core/grid.rs | 1 + src/sys/parser.rs | 12 +++ src/sys/pty.rs | 77 +++++---------- src/ui/app_state.rs | 157 ++++++++++++++++++++++++++++++- src/ui/components/settings_ai.rs | 52 +++++++++- src/ui/components/term.rs | 14 +-- 7 files changed, 248 insertions(+), 72 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 23f66db..31f2f38 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -44,6 +44,12 @@ pub struct AiConfig { pub api_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_url: Option, + #[serde(default = "default_true")] + pub diagnostic_banner: bool, +} + +fn default_true() -> bool { + true } impl Default for AiConfig { @@ -53,6 +59,7 @@ impl Default for AiConfig { model: "claude-haiku-4-5-20251001".into(), api_key: String::new(), base_url: None, + diagnostic_banner: true, } } } diff --git a/src/core/grid.rs b/src/core/grid.rs index 91e7ca2..cb6158f 100644 --- a/src/core/grid.rs +++ b/src/core/grid.rs @@ -91,6 +91,7 @@ pub struct Grid { pub enum ControlCommand { OpenAskAi { preset: Option> }, OpenExplainAi { preset: Option> }, + CommandFailure(u8), } impl Grid { diff --git a/src/sys/parser.rs b/src/sys/parser.rs index 18fac81..34ef13e 100644 --- a/src/sys/parser.rs +++ b/src/sys/parser.rs @@ -585,6 +585,18 @@ impl<'a> Perform for AnsiExecutor<'a> { .control_queue .push(ControlCommand::OpenAskAi { preset }); } + cli::constants::PRIVATE_NOVA_OSC_CODE_BYTES + if params.len() >= 2 && params[1] == b"command_failure" => + { + let code = params + .get(2) + .and_then(|c| std::str::from_utf8(c).ok()?.parse().ok()) + .unwrap_or(1); + self + .grid + .control_queue + .push(ControlCommand::CommandFailure(code)); + } cli::constants::PRIVATE_NOVA_OSC_CODE_BYTES if params.len() >= 2 && params[1] == b"explain_ai" => { diff --git a/src/sys/pty.rs b/src/sys/pty.rs index b9f5d94..efe3006 100644 --- a/src/sys/pty.rs +++ b/src/sys/pty.rs @@ -121,15 +121,15 @@ fn build_shell_command(shell: &str, initial_cwd: Option<&str>) -> CommandBuilder let ps_prompt_script = format!( r#" Set-Item function:prompt {{ - $exitCode = $global:LASTEXITCODE + if (-not $global:__nova_prompt_count) {{ $global:__nova_prompt_count = $true; $global:LASTEXITCODE = 0; $exitCode = 0 }} else {{ $exitCode = $global:LASTEXITCODE; $global:LASTEXITCODE = 0 }} $p = $PWD.ProviderPath; $h = [regex]::Escape($env:USERPROFILE); $d = $p -replace ('^' + $h), '~'; $uri = 'file://localhost/' + ($p -replace '\\', '/'); $ESC = [char]27; Write-Host -NoNewline ('{{0}}]7;{{1}}{{0}}{{2}}' -f [char]27, $uri, [char]92); - $banner = if ($exitCode -ne 0) {{ "`r`n$ESC[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $exitCode)$ESC[0m`r`n$ESC[38;2;{ar};{ag};{ab}m[>] Press Ctrl+T, search 'Explain Error'$ESC[0m`r`n" }} else {{ "" }} - return ($banner + $ESC + '[38;2;128;128;128m' + $d + $ESC + '[0m ' + $ESC + '[38;2;{ar};{ag};{ab}mλ' + $ESC + '[0m ') + $diag = if ($exitCode -ne 0) {{ "$ESC]777;command_failure;$exitCode$([char]7)" }} else {{ "" }} + return ($diag + $ESC + '[38;2;128;128;128m' + $d + $ESC + '[0m ' + $ESC + '[38;2;{ar};{ag};{ab}mλ' + $ESC + '[0m ') }} if (-not (Get-Command ssh -CommandType Function -ErrorAction SilentlyContinue)) {{ function global:ssh {{ @@ -183,24 +183,18 @@ fn build_shell_command(shell: &str, initial_cwd: Option<&str>) -> CommandBuilder ); c.env( "PROMPT_COMMAND", - format!( - r#"__nova_exit_code=$? + r#"__nova_exit_code=$? if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} - ssh() {{ __nova_ssh "$@"; }} + __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + ssh() { __nova_ssh "$@"; } fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } fi __nova_osc7 if [ "$__nova_exit_code" -ne 0 ]; then - printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" - printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette Ctrl+T, search 'Explain Error'\033[0m\n" + printf "\033]777;command_failure;$__nova_exit_code\a" fi"#, - ar = ar, - ag = ag, - ab = ab, - ), ); c } else if is_git_bash { @@ -223,24 +217,18 @@ fi"#, ); c.env( "PROMPT_COMMAND", - format!( - r#"__nova_exit_code=$? + r#"__nova_exit_code=$?` if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} - ssh() {{ __nova_ssh "$@"; }} + __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + ssh() { __nova_ssh "$@"; } fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } fi __nova_osc7 if [ "$__nova_exit_code" -ne 0 ]; then - printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" - printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette Ctrl+T, search 'Explain Error'\033[0m\n" + printf "\033]777;command_failure;$__nova_exit_code\a" fi"#, - ar = ar, - ag = ag, - ab = ab, - ), ); c.args(["--login", "-i"]); c @@ -290,32 +278,20 @@ fi"#, "\\[\\e[38;2;128;128;128m\\]\\w\\[\\e[0m\\] \\[\\e[38;2;{ar};{ag};{ab}m\\]λ\\[\\e[0m\\] " ), ); - let palette_key = if cfg!(target_os = "macos") { - "⌘T" - } else { - "Ctrl+T" - }; c.env( "PROMPT_COMMAND", - format!( - r#"__nova_exit_code=$? + r#"__nova_exit_code=$? if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} - ssh() {{ __nova_ssh "$@"; }} + __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + ssh() { __nova_ssh "$@"; } fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } fi __nova_osc7 if [ "$__nova_exit_code" -ne 0 ]; then - printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" - printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette {palette_key}, search 'Explain Error'\033[0m\n" + printf "\033]777;command_failure;$__nova_exit_code\a" fi"#, - ar = ar, - ag = ag, - ab = ab, - palette_key = palette_key, - ), ); c.env( @@ -326,25 +302,18 @@ fi"#, ); c.env( "NOVA_PROMPT_COMMAND", - format!( - r#"__nova_exit_code=$? + r#"__nova_exit_code=$? if ! declare -f __nova_ssh > /dev/null 2>&1; then - __nova_ssh() {{ local h="" s=false; for a in "$@"; do $s && {{ s=false; continue; }}; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} - ssh() {{ __nova_ssh "$@"; }} + __nova_ssh() { local h="" s=false; for a in "$@"; do $s && { s=false; continue; }; case "$a" in -b|-c|-D|-E|-e|-F|-I|-i|-J|-L|-l|-m|-o|-p|-Q|-R|-S|-W|-w) s=true;; -*) ;; *) h="$a"; break;; esac; done; [ -n "$h" ] && printf "\033]7;ssh://%s\033\\" "$h"; command ssh "$@"; printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } + ssh() { __nova_ssh "$@"; } fi if ! declare -f __nova_osc7 > /dev/null 2>&1; then - __nova_osc7() {{ printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; }} + __nova_osc7() { printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"; } fi __nova_osc7 if [ "$__nova_exit_code" -ne 0 ]; then - printf "\r\n\033[38;2;{ar};{ag};{ab}m[!] Command failed (exit code $__nova_exit_code)\033[0m\n" - printf "\033[38;2;{ar};{ag};{ab}m[>] Open palette {palette_key}, search 'Explain Error'\033[0m\n" + printf "\033]777;command_failure;$__nova_exit_code\a" fi"#, - ar = ar, - ag = ag, - ab = ab, - palette_key = palette_key, - ), ); } c diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 37d8281..769a64c 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -1,8 +1,11 @@ use async_channel::Sender; use iced::keyboard::Key; use iced::keyboard::key::Named; -use iced::widget::{column, mouse_area, stack}; -use iced::{Element, Point, Size, Subscription, Theme, time, window}; +use iced::widget::{column, container, mouse_area, row, stack, text}; +use iced::{ + Border, Color, Element, Length, Padding, Point, Size, Subscription, Theme, border::Radius, time, + window, +}; use iced::{Event, event, keyboard, mouse, stream}; use std::io::Write; use std::path::PathBuf; @@ -15,6 +18,7 @@ use crate::sys::parser::AnsiExecutor; use crate::sys::pty::{PtyBridge, PtyCommand}; use crate::ui::components; use crate::ui::tab::Tab; +use crate::ui::theme; pub static SETTINGS_OPEN: AtomicBool = AtomicBool::new(false); pub static KB_RECORDING: AtomicBool = AtomicBool::new(false); @@ -79,6 +83,8 @@ pub struct Nova { ai_loading: bool, ai_response: Option, ai_is_error: bool, + diagnostic_banner: Option<(u8, String)>, + ai_pending_diagnostic: Option, bell_blink_visible: bool, bell_blink_remaining: u8, } @@ -161,6 +167,8 @@ pub enum Message { SettingsAiApiKeyChanged(String), SettingsAiBaseUrlChanged(String), SettingsWindowControlsChanged(config::WindowControls), + DiagnosticBannerResponse(Result), + SettingsDiagnosticBannerToggled(bool), NoOp, } @@ -292,10 +300,17 @@ fn extract_selection( result.trim_end().to_string() } -fn calc_grid(width: f32, height: f32, font_size: f32, status_bar_visible: bool) -> (usize, usize) { +fn calc_grid( + width: f32, + height: f32, + font_size: f32, + status_bar_visible: bool, + banner_visible: bool, +) -> (usize, usize) { let char_width = font_size * 0.62; let char_height = font_size * 1.29; - let padding_y = if status_bar_visible { 118.0 } else { 96.0 }; + let banner_extra = if banner_visible { font_size * 2.5 } else { 0.0 }; + let padding_y = if status_bar_visible { 118.0 } else { 96.0 } + banner_extra; let cols = ((width - 40.0) / char_width).floor() as usize; let rows = ((height - padding_y) / char_height).floor() as usize; (cols.max(10), rows.max(5)) @@ -390,7 +405,13 @@ impl Default for Nova { } }); let cfg = config::get(); - let (cols, rows) = calc_grid(1024.0, 768.0, cfg.theme.font.size, cfg.status_bar.visible); + let (cols, rows) = calc_grid( + 1024.0, + 768.0, + cfg.theme.font.size, + cfg.status_bar.visible, + false, + ); let mut nova = Self { tabs: vec![Tab::new(0, cols, rows, default_shell, String::new())], active_index: 0, @@ -429,6 +450,8 @@ impl Default for Nova { ai_loading: false, ai_response: None, ai_is_error: false, + diagnostic_banner: None, + ai_pending_diagnostic: None, bell_blink_visible: true, bell_blink_remaining: 0, }; @@ -589,6 +612,7 @@ impl Nova { self.window_size.height, self.settings.theme.font.size, self.settings.status_bar.visible, + self.diagnostic_banner.is_some(), ); for tab in self.tabs.iter_mut() { tab.grid.resize(cols, rows); @@ -629,6 +653,8 @@ impl Nova { self.selection_start = None; self.selection_end = None; self.click_count = 0; + self.diagnostic_banner = None; + self.ai_pending_diagnostic = None; let entered = bytes == b"\r"; if let Some(active_tab) = self.tabs.get_mut(self.active_index) { active_tab.scroll_offset = 0; @@ -700,6 +726,7 @@ impl Nova { self.window_size.height, self.settings.theme.font.size, self.settings.status_bar.visible, + self.diagnostic_banner.is_some(), ); let parent_pwd = self .tabs @@ -725,6 +752,7 @@ impl Nova { self.window_size.height, self.settings.theme.font.size, self.settings.status_bar.visible, + self.diagnostic_banner.is_some(), ); self .tabs @@ -949,6 +977,15 @@ impl Nova { ai_preset = Some(p); } } + ControlCommand::CommandFailure(code) => { + if self.settings.ai.diagnostic_banner + && code != 0 + && !self.settings.ai.api_key.is_empty() + { + self.diagnostic_banner = Some((code, "Loading...".into())); + self.ai_pending_diagnostic = Some(code); + } + } } } @@ -995,6 +1032,29 @@ impl Nova { return focus_task; } + if let Some(code) = self.ai_pending_diagnostic.take() { + let context = crate::core::ai::extract_last_output(&tab.grid); + let ai_cfg = self.settings.ai.clone(); + let question = format!( + "The last command exited with code {}. Output:\n{}\n\nExplain in one short sentence what went wrong and how to fix it. Use no formatting, keep it under 80 characters.", + code, context, + ); + let q = crate::core::ai::AiQuery { + question, + context, + provider: ai_cfg.provider, + model: ai_cfg.model, + api_key: ai_cfg.api_key, + base_url: ai_cfg.base_url, + shell: tab.shell.clone(), + os: os_name(), + }; + return iced::Task::perform( + crate::core::ai::query(q), + Message::DiagnosticBannerResponse, + ); + } + if bell_fired { match self.settings.general.bell { config::BellType::Blink => { @@ -1052,6 +1112,7 @@ impl Nova { height, self.settings.theme.font.size, self.settings.status_bar.visible, + self.diagnostic_banner.is_some(), ); for tab in self.tabs.iter_mut() { @@ -1432,6 +1493,21 @@ impl Nova { } } } + Message::DiagnosticBannerResponse(result) => { + let (code, text) = match (&result, &self.diagnostic_banner) { + (Ok(text), Some((code, _))) => (*code, text.clone()), + (Err(e), Some((code, _))) => (*code, format!("AI error: {}", e)), + _ => (0, String::new()), + }; + self.diagnostic_banner = Some((code, text)); + } + Message::SettingsDiagnosticBannerToggled(enabled) => { + if !enabled { + self.diagnostic_banner = None; + } + self.settings.ai.diagnostic_banner = enabled; + let _ = config::save(&self.settings); + } Message::ExplainError => { let (context, shell) = self .tabs @@ -1560,6 +1636,62 @@ impl Nova { term, ]; + if let Some((code, ref explanation)) = self.diagnostic_banner { + let rt = theme::color::runtime(); + let bg = rt.background; + let accent = rt.accent; + let fg = rt.foreground; + drop(rt); + let clean = strip_markdown(explanation); + col = col.push( + container( + container( + row![ + container( + text(format!(" Exit {} ", code)) + .font(theme::font::BOLD) + .size(12) + .color(accent), + ) + .align_y(iced::alignment::Vertical::Center) + .padding(Padding::from([3, 8])) + .style(move |_| container::Style { + background: Some(Color { a: 0.08, ..accent }.into()), + border: Border { + color: accent, + radius: Radius::new(4.0), + width: 0.0, + }, + ..Default::default() + }), + text(format!(" {}", clean)) + .font(theme::font::REGULAR) + .size(12) + .color(fg), + ] + .spacing(10) + .align_y(iced::alignment::Vertical::Center), + ) + .padding(Padding::from([8, 16])) + .style(move |_| container::Style { + background: Some(Color { a: 0.08, ..accent }.into()), + border: Border { + color: accent, + radius: Radius::new(8.0), + width: 1.0, + }, + ..Default::default() + }) + .width(Length::Fill), + ) + .padding(Padding::from([8, 8])) + .style(move |_| container::Style { + background: Some(bg.into()), + ..Default::default() + }) + .width(Length::Fill), + ); + } if self.settings.status_bar.visible { col = col.push(components::status_bar( active_tab, @@ -1839,6 +1971,21 @@ impl Nova { } } +fn strip_markdown(text: &str) -> String { + text + .replace("**", "") + .replace("__", "") + .replace("```", "") + .replace("`", "") + .lines() + .map(|l| l.trim().to_string()) + .collect::>() + .join(" ") + .replace(" ", " ") + .trim() + .to_string() +} + fn os_name() -> String { match std::env::consts::OS { "macos" => "macOS".to_string(), diff --git a/src/ui/components/settings_ai.rs b/src/ui/components/settings_ai.rs index 380ae49..ca0b793 100644 --- a/src/ui/components/settings_ai.rs +++ b/src/ui/components/settings_ai.rs @@ -1,6 +1,6 @@ use iced::{ - Element, Padding, - widget::{column, container, pick_list, text, text_input}, + Color, Element, Padding, + widget::{column, container, pick_list, row, space::horizontal, text, text_input, toggler}, }; use super::{input_style, setting_row}; @@ -77,6 +77,54 @@ pub(super) fn ai_tab<'a>(settings: &'a config::Config) -> Element<'a, Message> { ("AI features enabled.", theme::color::runtime().accent) }; + col = col.push({ + let rt = theme::color::runtime(); + row![ + column![ + text("Error diagnostics") + .font(theme::font::BOLD) + .size(12) + .color(rt.foreground), + text("Show a banner with AI explanation when a command fails") + .font(theme::font::REGULAR) + .size(11) + .color(rt.foreground_muted), + ] + .spacing(2), + horizontal(), + toggler(settings.ai.diagnostic_banner) + .on_toggle(Message::SettingsDiagnosticBannerToggled) + .size(20) + .style(|_t, status| { + let is_toggled = match status { + toggler::Status::Active { is_toggled } + | toggler::Status::Hovered { is_toggled } + | toggler::Status::Disabled { is_toggled } => is_toggled, + }; + let rt = theme::color::runtime(); + let (accent, border_c) = (rt.accent, rt.border); + drop(rt); + toggler::Style { + background: if is_toggled { + accent.into() + } else { + theme::color::BG_HIGH.as_color().into() + }, + background_border_width: 1.0, + background_border_color: if is_toggled { accent } else { border_c }, + foreground: Color::WHITE.into(), + foreground_border_width: 0.0, + foreground_border_color: Color::TRANSPARENT, + text_color: None, + border_radius: None, + padding_ratio: 0.15, + } + }), + ] + .spacing(12) + .align_y(iced::alignment::Vertical::Center) + }); + col = col.push( container( text(status_text) diff --git a/src/ui/components/term.rs b/src/ui/components/term.rs index 50f349a..68aa99a 100644 --- a/src/ui/components/term.rs +++ b/src/ui/components/term.rs @@ -1,6 +1,5 @@ use iced::{ - Background, Border, Color, Element, Length, Padding, - border::Radius, + Background, Color, Element, Length, Padding, widget::{column, container, rich_text, text::Span}, }; @@ -251,7 +250,7 @@ pub fn term<'a>( viewport_y += 1; } - let (term_bg, term_border) = { + let (term_bg, _term_border) = { let rt = theme::color::runtime(); (rt.background, rt.border) }; @@ -259,14 +258,7 @@ pub fn term<'a>( container(grid_ui) .style(move |_| container::Style { background: Some(term_bg.into()), - border: Border { - color: term_border, - radius: Radius { - ..Default::default() - }, - width: 0.5, - }, - ..container::Style::default() + ..Default::default() }) .padding(Padding { top: 12.0,