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
Generated
+1 -1
View File
@@ -2025,7 +2025,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
+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(())
}
+39 -17
View File
@@ -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<Vec<Session>> {
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<Vec<Session>> {
sqlx::query_as::<_, Session>(
async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result<Vec<Session>> {
if types.is_empty() {
return Ok(Vec::new());
}
let placeholders: String = types.iter().map(|_| "?").collect::<Vec<_>>().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<Vec<Session>> {
self.list_sessions_by_types(&[SessionType::User, SessionType::Scheduled])
.await
.map_err(Into::into)
}
async fn delete_session(&self, session_id: &str) -> Result<()> {
@@ -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 <command> &` 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.
+21 -6
View File
@@ -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"
+2 -1
View File
@@ -4756,7 +4756,8 @@
"user",
"scheduled",
"sub_agent",
"hidden"
"hidden",
"terminal"
]
},
"SessionsQuery": {
+1 -1
View File
@@ -761,7 +761,7 @@ export type SessionListResponse = {
sessions: Array<Session>;
};
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden';
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal';
export type SessionsQuery = {
limit: number;