feat: @goose in terminal (native terminal support) (#5887)

Co-authored-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Douwe Osinga
2025-12-01 07:40:17 +01:00
committed by GitHub
parent fa7ce8ec94
commit 5f50198318
9 changed files with 592 additions and 26 deletions
+103
View File
@@ -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<String>,
},
/// 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<String>,
/// 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<String>,
},
/// 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?;
+1
View File
@@ -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;
+302
View File
@@ -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<String>,
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 <shell>)\"' 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<String>) -> 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::<Vec<_>>()
.join("\n");
format!(
"<shell_history>\n{}\n</shell_history>\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(())
}