diff --git a/Cargo.lock b/Cargo.lock index 9e6d862ee4..6c016d9942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,7 +2025,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e0083d7742..29705631ff 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -13,6 +13,9 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; +use crate::commands::term::{ + handle_term_info, handle_term_init, handle_term_log, handle_term_run, Shell, +}; use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -829,6 +832,84 @@ enum Command { #[arg(long, help = "Authentication token to secure the web interface")] auth_token: Option, }, + + /// Terminal-integrated session (one session per terminal) + #[command( + about = "Terminal-integrated goose session", + long_about = "Runs a goose session tied to your terminal window.\n\ + Each terminal maintains its own persistent session that resumes automatically.\n\n\ + Setup:\n \ + eval \"$(goose term init zsh)\" # Add to ~/.zshrc\n\n\ + Usage:\n \ + goose term run \"list files in this directory\"\n \ + @goose \"create a python script\" # using alias\n \ + @g \"quick question\" # short alias" + )] + Term { + #[command(subcommand)] + command: TermCommand, + }, +} + +#[derive(Subcommand)] +enum TermCommand { + /// Print shell initialization script + #[command( + about = "Print shell initialization script", + long_about = "Prints shell configuration to set up terminal-integrated sessions.\n\ + Each terminal gets a persistent goose session that automatically resumes.\n\n\ + Setup:\n \ + echo 'eval \"$(goose term init zsh)\"' >> ~/.zshrc\n \ + source ~/.zshrc\n\n\ + With --default (anything typed that isn't a command goes to goose):\n \ + echo 'eval \"$(goose term init zsh --default)\"' >> ~/.zshrc" + )] + Init { + /// Shell type (bash, zsh, fish, powershell) + #[arg(value_enum)] + shell: Shell, + + #[arg(short, long, help = "Name for the terminal session")] + name: Option, + + /// Make goose the default handler for unknown commands + #[arg( + long = "default", + help = "Make goose the default handler for unknown commands", + long_help = "When enabled, anything you type that isn't a valid command will be sent to goose. Only supported for zsh and bash." + )] + default: bool, + }, + + /// Log a shell command (called by shell hook) + #[command(about = "Log a shell command to the session", hide = true)] + Log { + /// The command that was executed + command: String, + }, + + /// Run a prompt in the terminal session + #[command( + about = "Run a prompt in the terminal session", + long_about = "Run a prompt in the terminal-integrated session.\n\n\ + Examples:\n \ + goose term run list files in this directory\n \ + @goose list files # using alias\n \ + @g why did that fail # short alias" + )] + Run { + /// The prompt to send to goose (multiple words allowed without quotes) + #[arg(required = true, num_args = 1..)] + prompt: Vec, + }, + + /// Print session info for prompt integration + #[command( + about = "Print session info for prompt integration", + long_about = "Prints compact session info (token usage, model) for shell prompt integration.\n\ + Example output: ●○○○○ sonnet" + )] + Info, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -874,6 +955,7 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Bench { .. }) => "bench", Some(Command::Recipe { .. }) => "recipe", Some(Command::Web { .. }) => "web", + Some(Command::Term { .. }) => "term", None => "default_session", }; @@ -1387,6 +1469,27 @@ pub async fn cli() -> anyhow::Result<()> { crate::commands::web::handle_web(port, host, open, auth_token).await?; return Ok(()); } + Some(Command::Term { command }) => { + match command { + TermCommand::Init { + shell, + name, + default, + } => { + handle_term_init(shell, name, default).await?; + } + TermCommand::Log { command } => { + handle_term_log(command).await?; + } + TermCommand::Run { prompt } => { + handle_term_run(prompt).await?; + } + TermCommand::Info => { + handle_term_info().await?; + } + } + return Ok(()); + } None => { return if !Config::global().exists() { handle_configure().await?; diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 698fd02fdf..e0d54e9678 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod project; pub mod recipe; pub mod schedule; pub mod session; +pub mod term; pub mod update; pub mod web; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs new file mode 100644 index 0000000000..cd674748d6 --- /dev/null +++ b/crates/goose-cli/src/commands/term.rs @@ -0,0 +1,302 @@ +use anyhow::{anyhow, Result}; +use chrono; +use goose::conversation::message::{Message, MessageContent, MessageMetadata}; +use goose::session::SessionManager; +use goose::session::SessionType; +use rmcp::model::Role; + +use crate::session::{build_session, SessionBuilderConfig}; + +use clap::ValueEnum; + +#[derive(ValueEnum, Clone, Debug)] +pub enum Shell { + Bash, + Zsh, + Fish, + #[value(alias = "pwsh")] + Powershell, +} + +struct ShellConfig { + script_template: &'static str, + command_not_found: Option<&'static str>, +} + +impl Shell { + fn config(&self) -> &'static ShellConfig { + match self { + Shell::Bash => &BASH_CONFIG, + Shell::Zsh => &ZSH_CONFIG, + Shell::Fish => &FISH_CONFIG, + Shell::Powershell => &POWERSHELL_CONFIG, + } + } +} + +static BASH_CONFIG: ShellConfig = ShellConfig { + script_template: r#"export GOOSE_SESSION_ID="{session_id}" +alias @goose='{goose_bin} term run' +alias @g='{goose_bin} term run' + +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^(@goose|@g)($|[[:space:]]) ]] && return + ('{goose_bin}' term log "$1" &) 2>/dev/null +}} + +if [[ -z "$goose_preexec_installed" ]]; then + goose_preexec_installed=1 + trap 'goose_preexec "$BASH_COMMAND"' DEBUG +fi{command_not_found_handler}"#, + command_not_found: Some( + r#" + +command_not_found_handle() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"#, + ), +}; + +static ZSH_CONFIG: ShellConfig = ShellConfig { + script_template: r#"export GOOSE_SESSION_ID="{session_id}" +alias @goose='{goose_bin} term run' +alias @g='{goose_bin} term run' + +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^(@goose|@g)($|[[:space:]]) ]] && return + ('{goose_bin}' term log "$1" &) 2>/dev/null +}} + +autoload -Uz add-zsh-hook +add-zsh-hook preexec goose_preexec + +if [[ -z "$GOOSE_PROMPT_INSTALLED" ]]; then + export GOOSE_PROMPT_INSTALLED=1 + PROMPT='%F{{cyan}}🪿%f '$PROMPT +fi{command_not_found_handler}"#, + command_not_found: Some( + r#" + +command_not_found_handler() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"#, + ), +}; + +static FISH_CONFIG: ShellConfig = ShellConfig { + script_template: r#"set -gx GOOSE_SESSION_ID "{session_id}" +function @goose; {goose_bin} term run $argv; end +function @g; {goose_bin} term run $argv; end + +function goose_preexec --on-event fish_preexec + string match -q -r '^goose term' -- $argv[1]; and return + string match -q -r '^(@goose|@g)($|\s)' -- $argv[1]; and return + {goose_bin} term log "$argv[1]" 2>/dev/null & +end"#, + command_not_found: None, +}; + +static POWERSHELL_CONFIG: ShellConfig = ShellConfig { + script_template: r#"$env:GOOSE_SESSION_ID = "{session_id}" +function @goose {{ & '{goose_bin}' term run @args }} +function @g {{ & '{goose_bin}' term run @args }} + +Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ + $line = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + if ($line -notmatch '^goose term' -and $line -notmatch '^(@goose|@g)($|\s)') {{ + Start-Job -ScriptBlock {{ & '{goose_bin}' term log $using:line }} | Out-Null + }} + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() +}}"#, + command_not_found: None, +}; + +pub async fn handle_term_init( + shell: Shell, + name: Option, + with_command_not_found: bool, +) -> Result<()> { + let config = shell.config(); + + let working_dir = std::env::current_dir()?; + let named_session = if let Some(ref name) = name { + let sessions = SessionManager::list_sessions_by_types(&[SessionType::Terminal]).await?; + sessions.into_iter().find(|s| s.name == *name) + } else { + None + }; + + let session = match named_session { + Some(s) => s, + None => { + let session = SessionManager::create_session( + working_dir, + "Goose Term Session".to_string(), + SessionType::Terminal, + ) + .await?; + + if let Some(name) = name { + SessionManager::update_session(&session.id) + .user_provided_name(name) + .apply() + .await?; + } + + session + } + }; + + let goose_bin = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "goose".to_string()); + + let command_not_found_handler = if with_command_not_found { + config + .command_not_found + .map(|s| s.replace("{goose_bin}", &goose_bin)) + .unwrap_or_default() + } else { + String::new() + }; + + let script = config + .script_template + .replace("{session_id}", &session.id) + .replace("{goose_bin}", &goose_bin) + .replace("{command_not_found_handler}", &command_not_found_handler); + + println!("{}", script); + Ok(()) +} + +pub async fn handle_term_log(command: String) -> Result<()> { + let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { + anyhow!("GOOSE_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") + })?; + + let message = Message::new( + Role::User, + chrono::Utc::now().timestamp_millis(), + vec![MessageContent::text(command)], + ) + .with_metadata(MessageMetadata::user_only()); + + SessionManager::add_message(&session_id, &message).await?; + + Ok(()) +} + +pub async fn handle_term_run(prompt: Vec) -> Result<()> { + let prompt = prompt.join(" "); + let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { + anyhow!( + "GOOSE_SESSION_ID not set.\n\n\ + Add to your shell config (~/.zshrc or ~/.bashrc):\n \ + eval \"$(goose term init zsh)\"\n\n\ + Then restart your terminal or run: source ~/.zshrc" + ) + })?; + + let working_dir = std::env::current_dir()?; + + SessionManager::update_session(&session_id) + .working_dir(working_dir) + .apply() + .await?; + + let session = SessionManager::get_session(&session_id, true).await?; + let user_messages_after_last_assistant: Vec<&Message> = + if let Some(conv) = &session.conversation { + conv.messages() + .iter() + .rev() + .take_while(|m| m.role != Role::Assistant) + .collect() + } else { + Vec::new() + }; + + if let Some(oldest_user) = user_messages_after_last_assistant.last() { + SessionManager::truncate_conversation(&session_id, oldest_user.created).await?; + } + + let prompt_with_context = if user_messages_after_last_assistant.is_empty() { + prompt + } else { + let history = user_messages_after_last_assistant + .iter() + .rev() // back to chronological order + .map(|m| m.as_concat_text()) + .collect::>() + .join("\n"); + + format!( + "\n{}\n\n\n{}", + history, prompt + ) + }; + + let config = SessionBuilderConfig { + session_id: Some(session_id), + resume: true, + interactive: false, + quiet: true, + ..Default::default() + }; + + let mut session = build_session(config).await; + session.headless(prompt_with_context).await?; + + Ok(()) +} + +/// Handle `goose term info` - print compact session info for prompt integration +pub async fn handle_term_info() -> Result<()> { + let session_id = match std::env::var("GOOSE_SESSION_ID") { + Ok(id) => id, + Err(_) => return Ok(()), + }; + + let session = SessionManager::get_session(&session_id, false).await.ok(); + let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; + + let model_name = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone())) + .map(|name| { + let short = name.rsplit('/').next().unwrap_or(&name); + if let Some(stripped) = short.strip_prefix("goose-") { + stripped.to_string() + } else { + short.to_string() + } + }) + .unwrap_or_else(|| "?".to_string()); + + let context_limit = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.context_limit())) + .unwrap_or(128_000); + + let percentage = if context_limit > 0 { + ((total_tokens as f64 / context_limit as f64) * 100.0).round() as usize + } else { + 0 + }; + + let filled = (percentage / 20).min(5); + let empty = 5 - filled; + let dots = format!("{}{}", "●".repeat(filled), "○".repeat(empty)); + + println!("{} {}", dots, model_name); + + Ok(()) +} diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b4..427dc0c409 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -30,6 +30,7 @@ pub enum SessionType { Scheduled, SubAgent, Hidden, + Terminal, } impl Default for SessionType { @@ -45,6 +46,7 @@ impl std::fmt::Display for SessionType { SessionType::SubAgent => write!(f, "sub_agent"), SessionType::Hidden => write!(f, "hidden"), SessionType::Scheduled => write!(f, "scheduled"), + SessionType::Terminal => write!(f, "terminal"), } } } @@ -58,6 +60,7 @@ impl std::str::FromStr for SessionType { "sub_agent" => Ok(SessionType::SubAgent), "hidden" => Ok(SessionType::Hidden), "scheduled" => Ok(SessionType::Scheduled), + "terminal" => Ok(SessionType::Terminal), _ => Err(anyhow::anyhow!("Invalid session type: {}", s)), } } @@ -291,6 +294,10 @@ impl SessionManager { Self::instance().await?.list_sessions().await } + pub async fn list_sessions_by_types(types: &[SessionType]) -> Result> { + Self::instance().await?.list_sessions_by_types(types).await + } + pub async fn delete_session(id: &str) -> Result<()> { Self::instance().await?.delete_session(id).await } @@ -1121,25 +1128,40 @@ impl SessionStorage { Ok(()) } - async fn list_sessions(&self) -> Result> { - sqlx::query_as::<_, Session>( + async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result> { + if types.is_empty() { + return Ok(Vec::new()); + } + + let placeholders: String = types.iter().map(|_| "?").collect::>().join(", "); + let query = format!( r#" - SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, - s.total_tokens, s.input_tokens, s.output_tokens, - s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, - s.schedule_id, s.recipe_json, s.user_recipe_values_json, - s.provider_name, s.model_config_json, - COUNT(m.id) as message_count - FROM sessions s - INNER JOIN messages m ON s.id = m.session_id - WHERE s.session_type = 'user' OR s.session_type = 'scheduled' - GROUP BY s.id - ORDER BY s.updated_at DESC - "#, - ) - .fetch_all(&self.pool) + SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, + s.total_tokens, s.input_tokens, s.output_tokens, + s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, + s.schedule_id, s.recipe_json, s.user_recipe_values_json, + s.provider_name, s.model_config_json, + COUNT(m.id) as message_count + FROM sessions s + INNER JOIN messages m ON s.id = m.session_id + WHERE s.session_type IN ({}) + GROUP BY s.id + ORDER BY s.updated_at DESC + "#, + placeholders + ); + + let mut q = sqlx::query_as::<_, Session>(&query); + for t in types { + q = q.bind(t.to_string()); + } + + q.fetch_all(&self.pool).await.map_err(Into::into) + } + + async fn list_sessions(&self) -> Result> { + self.list_sessions_by_types(&[SessionType::User, SessionType::Scheduled]) .await - .map_err(Into::into) } async fn delete_session(&self, session_id: &str) -> Result<()> { diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md new file mode 100644 index 0000000000..656b8cb369 --- /dev/null +++ b/documentation/docs/guides/terminal-integration.md @@ -0,0 +1,122 @@ +--- +unlisted: true +--- +# Terminal Integration + +The `goose term` commands let you talk to goose directly from your shell prompt. Instead of switching to a separate REPL session, you stay in your terminal and call goose when you need it. + +```bash +@goose "what does this error mean?" +``` + +Goose responds, you read the answer, and you're back at your prompt. The conversation lives alongside your work, not in a separate window you have to manage. + +## Command History Awareness + +The real power comes from shell integration. Once set up, goose tracks the commands you run, so when you ask a question, it already knows what you've been doing. + +No more copy-pasting error messages or explaining "I ran these commands...". Just work normally, then ask goose for help. + +## Setup + +Add one line to your shell config: + +**zsh** (`~/.zshrc`) +```bash +eval "$(goose term init zsh)" +``` + +**bash** (`~/.bashrc`) +```bash +eval "$(goose term init bash)" +``` + +**fish** (`~/.config/fish/config.fish`) +```fish +goose term init fish | source +``` + +**PowerShell** (`$PROFILE`) +```powershell +Invoke-Expression (goose term init powershell) +``` + +Then restart your terminal or source the config. + +### Default Mode + +For **bash** and **zsh**, you can make goose the default handler for anything that isn't a valid command: + +```bash +# zsh +eval "$(goose term init zsh --default)" + +# bash +eval "$(goose term init bash --default)" +``` + +With this enabled, anything you type that isn't a command will be sent to goose: + +```bash +$ what files are in this directory? +🪿 Command 'what' not found. Asking goose... +``` + +Goose will interpret what you typed and help you accomplish the task. + +## Usage + +Once set up, your terminal session is linked to a goose session. All commands you run are logged to that session. + +To talk to goose about what you've been doing: + +```bash +@goose "why did that fail?" +``` + +You can also use `@g` as a shorter alias: + +```bash +@g "explain this error" +``` + +Both `@goose` and `@g` are aliases for `goose term run`. They open goose with your command history already loaded. + +## What Gets Logged + +Every command you type gets stored. Goose sees commands you ran since your last message to it. + +Commands starting with `goose term`, `@goose`, or `@g` are not logged (to avoid noise). + +## Performance + +- **Shell startup**: adds ~10ms +- **Per command**: ~10ms, runs in background (non-blocking) + +You won't notice any delay. The logging happens asynchronously after your command starts executing. + +## How It Works + +`goose term init` outputs shell code that: +1. Sets a `GOOSE_SESSION_ID` environment variable linking your terminal to a goose session +2. Creates `@goose` and `@g` aliases for quick access +3. Installs a preexec hook that calls `goose term log` for each command +4. Optionally installs a command-not-found handler (with `--default`) + +The hook runs `goose term log &` in the background, which appends to the goose session. +When you run `@goose`, goose reads from the goose session any commands that happened since it +was last called and incorporates them in the next call. + +## Session Management + +By default a new goose session is created each time you run init and +that session lasts as long as you keep that terminal open. + +You can create a named session by passing --name: + +```bash +eval "$(goose term init zsh --name my_project)" +``` + +which will create a session with the name `my_project` if it doesn't exist yet or continues +that session if it does. \ No newline at end of file diff --git a/scripts/clippy-lint.sh b/scripts/clippy-lint.sh index afc9296deb..0befc5b5a4 100755 --- a/scripts/clippy-lint.sh +++ b/scripts/clippy-lint.sh @@ -12,13 +12,28 @@ source "$SCRIPT_DIR/clippy-baseline.sh" echo "🔍 Running all clippy checks..." -# Run standard clippy with strict warnings -echo " → Standard clippy rules (strict)" -cargo clippy --all-targets --jobs 2 -- -D warnings +FIX_MODE=0 +[[ "$1" == "--fix" ]] && FIX_MODE=1 -# Run baseline rules check +run_clippy() { + if [[ "$FIX_MODE" -eq 1 ]]; then + cargo fmt + cargo clippy --all-targets --jobs 2 \ + --fix --allow-dirty --allow-staged \ + -- -D warnings + else + cargo clippy --all-targets --jobs 2 -- -D warnings + fi +} + +if [[ "$FIX_MODE" -eq 1 ]]; then + echo "🛠 Applying fixes..." +else + echo "🔍 Running clippy..." +fi + +run_clippy echo "" check_all_baseline_rules - echo "" -echo "✅ All lint checks passed!" +echo "✅ Done" diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a7bc0d15ec..769f2fa747 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4756,7 +4756,8 @@ "user", "scheduled", "sub_agent", - "hidden" + "hidden", + "terminal" ] }, "SessionsQuery": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 00f29ea9a5..ac44b284c5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -761,7 +761,7 @@ export type SessionListResponse = { sessions: Array; }; -export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden'; +export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal'; export type SessionsQuery = { limit: number;