Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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};

use crate::commands::schedule::{
handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove,
Expand All @@ -34,6 +35,14 @@ use std::io::Read;
use std::path::PathBuf;
use tracing::warn;

fn non_empty_string(s: &str) -> Result<String, String> {
if s.trim().is_empty() {
Err("Prompt cannot be empty".to_string())
} else {
Ok(s.to_string())
}
}

#[derive(Parser)]
#[command(author, version, display_name = "", about, long_about = None)]
struct Cli {
Expand Down Expand Up @@ -829,6 +838,69 @@ 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 \
gt \"create a python script\" # using 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"
)]
Init {
/// Shell type (bash, zsh, fish, powershell)
#[arg(value_enum)]
shell: Shell,
},

/// 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 term run \"create a python script that prints hello world\""
)]
Run {
/// The prompt to send to goose
#[arg(value_parser = non_empty_string)]
prompt: 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)]
Expand All @@ -838,6 +910,14 @@ enum CliProviderVariant {
Ollama,
}

#[derive(clap::ValueEnum, Clone, Debug)]
enum Shell {
Bash,
Zsh,
Fish,
Powershell,
}

#[derive(Debug)]
pub struct InputConfig {
pub contents: Option<String>,
Expand Down Expand Up @@ -874,6 +954,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",
};

Expand Down Expand Up @@ -1387,6 +1468,29 @@ 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 } => {
let shell_str = match shell {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::Powershell => "powershell",
};
handle_term_init(shell_str)?;
}
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?;
Expand Down
1 change: 1 addition & 0 deletions crates/goose-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
243 changes: 243 additions & 0 deletions crates/goose-cli/src/commands/term.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
use anyhow::{anyhow, Result};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use uuid::Uuid;

use crate::session::{build_session, SessionBuilderConfig};

const TERMINAL_SESSION_PREFIX: &str = "term:";

/// Ensure a terminal session exists, creating it if necessary
async fn ensure_terminal_session(
session_name: String,
working_dir: std::path::PathBuf,
) -> Result<()> {
if SessionManager::get_session(&session_name, false)
.await
.is_err()
{
let session = SessionManager::create_session_with_id(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to create a session with a specific id, why not just use the name of the session here instead? or alternatively, we could create the session the first time and then write that session id to the config file; that way you wouldn't have to set the environment variable either

session_name.clone(),
working_dir,
session_name.clone(),
SessionType::User,
)
.await?;

SessionManager::update_session(&session.id)
.user_provided_name(session_name)
.apply()
.await?;
}
Ok(())
}

/// Handle `goose term init <shell>` - print shell initialization script
pub fn handle_term_init(shell: &str) -> Result<()> {
let terminal_id = Uuid::new_v4().to_string();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually you could just create the session here and then use the session id of that session as the terminal_id


// Get the path to the current goose binary
let goose_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "goose".to_string());

let script = match shell.to_lowercase().as_str() {
"bash" => {
format!(
r#"export GOOSE_TERMINAL_ID="{terminal_id}"
alias gt='{goose_bin} term run'

# Log commands to goose (runs silently in background)
goose_preexec() {{
[[ "$1" =~ ^goose\ term ]] && return
[[ "$1" =~ ^gt($|[[:space:]]) ]] && return
('{goose_bin}' term log "$1" &) 2>/dev/null
}}

# Install preexec hook for bash
if [[ -z "$goose_preexec_installed" ]]; then
goose_preexec_installed=1
trap 'goose_preexec "$BASH_COMMAND"' DEBUG
fi"#
)
}
"zsh" => {
format!(
r#"export GOOSE_TERMINAL_ID="{terminal_id}"
alias gt='{goose_bin} term run'

# Log commands to goose (runs silently in background)
goose_preexec() {{
[[ "$1" =~ ^goose\ term ]] && return
[[ "$1" =~ ^gt($|[[:space:]]) ]] && return
('{goose_bin}' term log "$1" &) 2>/dev/null
}}

# Install preexec hook for zsh
autoload -Uz add-zsh-hook
add-zsh-hook preexec goose_preexec"#
)
}
"fish" => {
format!(
r#"set -gx GOOSE_TERMINAL_ID "{terminal_id}"
function gt; {goose_bin} term run $argv; end

# Log commands to goose
function goose_preexec --on-event fish_preexec
string match -q -r '^goose term' -- $argv[1]; and return
string match -q -r '^gt($|\s)' -- $argv[1]; and return
{goose_bin} term log "$argv[1]" 2>/dev/null &
end"#
)
}
"powershell" | "pwsh" => {
format!(
r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}"
function gt {{ & '{goose_bin}' term run @args }}

# Log commands to goose
Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{
$line = $null
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null)
if ($line -notmatch '^goose term' -and $line -notmatch '^gt($|\s)') {{
Start-Job -ScriptBlock {{ & '{goose_bin}' term log $using:line }} | Out-Null
}}
[Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
}}"#
)
}
_ => {
return Err(anyhow!(
"Unsupported shell: {}. Supported shells: bash, zsh, fish, powershell",
shell
));
}
};

println!("{}", script);
Ok(())
}

/// Handle `goose term log <command>` - log a shell command to the database
pub async fn handle_term_log(command: String) -> Result<()> {
let terminal_id = std::env::var("GOOSE_TERMINAL_ID")
.map_err(|_| anyhow!("GOOSE_TERMINAL_ID not set. Run 'goose term init <shell>' first."))?;

let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id);
let working_dir = std::env::current_dir()?;

ensure_terminal_session(session_name.clone(), working_dir.clone()).await?;
SessionManager::add_shell_command(&session_name, &command, &working_dir).await?;

Ok(())
}

/// Handle `goose term run <prompt>` - run a prompt in the terminal session
pub async fn handle_term_run(prompt: String) -> Result<()> {
let terminal_id = std::env::var("GOOSE_TERMINAL_ID").map_err(|_| {
anyhow!(
"GOOSE_TERMINAL_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 session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id);
let working_dir = std::env::current_dir()?;

let session_id = match SessionManager::get_session(&session_name, false).await {
Ok(_) => {
SessionManager::update_session(&session_name)
.working_dir(working_dir)
.apply()
.await?;
session_name.clone()
}
Err(_) => {
ensure_terminal_session(session_name.clone(), working_dir).await?;
session_name.clone()
}
};

let commands = SessionManager::get_shell_commands_since_last_message(&session_id).await?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure I follow what we are fetching here. doesn't each session add at least one message to the conversation so isn't this then automatically empty?

in general though, adding shell commands to the session manager seems rather specific for this application. couldn't we just list the messages from session and exclude the <shell_history></shell_history> from that to get the same result? or if that doesn't work, maybe add a message content type?

let prompt_with_context = if commands.is_empty() {
prompt
} else {
format!(
"<shell_history>\n{}\n</shell_history>\n\n{}",
commands.join("\n"),
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<()> {
use goose::config::Config;

let terminal_id = match std::env::var("GOOSE_TERMINAL_ID") {
Ok(id) => id,
Err(_) => return Ok(()), // Silent exit if no terminal ID
};

let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id);

// Get tokens from session or 0 if none started yet in this terminal
let session = SessionManager::get_session(&session_name, false).await.ok();
let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize;

let model_name = Config::global()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we store the model and provider now in the session - should probably get it from there

.get_goose_model()
.ok()
.or_else(|| {
session
.as_ref()
.and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone()))
})
.map(|name| {
// Extract short name: after last / or after last - if it starts with "goose-"
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());

// Get context limit for the model
let context_limit = session
.as_ref()
.and_then(|s| s.model_config.as_ref().map(|mc| mc.context_limit()))
.unwrap_or(128_000);

// Calculate percentage and create dot visualization
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(())
}
Loading
Loading