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:
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user