Skip to main content

aster_cli/commands/
term.rs

1use anyhow::{anyhow, Result};
2use aster::conversation::message::{Message, MessageContent, MessageMetadata};
3use aster::session::SessionManager;
4use aster::session::SessionType;
5use chrono;
6use rmcp::model::Role;
7
8use crate::session::{build_session, SessionBuilderConfig};
9
10use clap::ValueEnum;
11
12#[derive(ValueEnum, Clone, Debug)]
13pub enum Shell {
14    Bash,
15    Zsh,
16    Fish,
17    #[value(alias = "pwsh")]
18    Powershell,
19}
20
21struct ShellConfig {
22    script_template: &'static str,
23    command_not_found: Option<&'static str>,
24}
25
26impl Shell {
27    fn config(&self) -> &'static ShellConfig {
28        match self {
29            Shell::Bash => &BASH_CONFIG,
30            Shell::Zsh => &ZSH_CONFIG,
31            Shell::Fish => &FISH_CONFIG,
32            Shell::Powershell => &POWERSHELL_CONFIG,
33        }
34    }
35}
36
37static BASH_CONFIG: ShellConfig = ShellConfig {
38    script_template: r#"export ASTER_SESSION_ID="{session_id}"
39alias @aster='{aster_bin} term run'
40alias @g='{aster_bin} term run'
41
42aster_preexec() {
43    [[ "$1" =~ ^aster\ term ]] && return
44    [[ "$1" =~ ^(@aster|@g)($|[[:space:]]) ]] && return
45    ('{aster_bin}' term log "$1" &) 2>/dev/null
46}
47
48if [[ -z "$aster_preexec_installed" ]]; then
49    aster_preexec_installed=1
50    trap 'aster_preexec "$BASH_COMMAND"' DEBUG
51fi{command_not_found_handler}"#,
52    command_not_found: Some(
53        r#"
54
55command_not_found_handle() {
56    echo "🪿 Command '$1' not found. Asking aster..."
57    '{aster_bin}' term run "$@"
58    return 0
59}"#,
60    ),
61};
62
63static ZSH_CONFIG: ShellConfig = ShellConfig {
64    script_template: r#"export ASTER_SESSION_ID="{session_id}"
65alias @aster='{aster_bin} term run'
66alias @g='{aster_bin} term run'
67
68aster_preexec() {
69    [[ "$1" =~ ^aster\ term ]] && return
70    [[ "$1" =~ ^(@aster|@g)($|[[:space:]]) ]] && return
71    ('{aster_bin}' term log "$1" &) 2>/dev/null
72}
73
74autoload -Uz add-zsh-hook
75add-zsh-hook preexec aster_preexec{command_not_found_handler}"#,
76    command_not_found: Some(
77        r#"
78
79command_not_found_handler() {
80    echo "🪿 Command '$1' not found. Asking aster..."
81    '{aster_bin}' term run "$@"
82    return 0
83}"#,
84    ),
85};
86
87static FISH_CONFIG: ShellConfig = ShellConfig {
88    script_template: r#"set -gx ASTER_SESSION_ID "{session_id}"
89function @aster; {aster_bin} term run $argv; end
90function @g; {aster_bin} term run $argv; end
91
92function aster_preexec --on-event fish_preexec
93    string match -q -r '^aster term' -- $argv[1]; and return
94    string match -q -r '^(@aster|@g)($|\s)' -- $argv[1]; and return
95    {aster_bin} term log "$argv[1]" 2>/dev/null &
96end"#,
97    command_not_found: None,
98};
99
100static POWERSHELL_CONFIG: ShellConfig = ShellConfig {
101    script_template: r#"$env:ASTER_SESSION_ID = "{session_id}"
102function @aster {{ & '{aster_bin}' term run @args }}
103function @g {{ & '{aster_bin}' term run @args }}
104
105Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{
106    $line = $null
107    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null)
108    if ($line -notmatch '^aster term' -and $line -notmatch '^(@aster|@g)($|\s)') {{
109        Start-Job -ScriptBlock {{ & '{aster_bin}' term log $using:line }} | Out-Null
110    }}
111    [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
112}}"#,
113    command_not_found: None,
114};
115
116pub async fn handle_term_init(
117    shell: Shell,
118    name: Option<String>,
119    with_command_not_found: bool,
120) -> Result<()> {
121    let config = shell.config();
122
123    let working_dir = std::env::current_dir()?;
124    let named_session = if let Some(ref name) = name {
125        let sessions = SessionManager::list_sessions_by_types(&[SessionType::Terminal]).await?;
126        sessions.into_iter().find(|s| s.name == *name)
127    } else {
128        None
129    };
130
131    let session = match named_session {
132        Some(s) => s,
133        None => {
134            let session = SessionManager::create_session(
135                working_dir,
136                "Aster Term Session".to_string(),
137                SessionType::Terminal,
138            )
139            .await?;
140
141            if let Some(name) = name {
142                SessionManager::update_session(&session.id)
143                    .user_provided_name(name)
144                    .apply()
145                    .await?;
146            }
147
148            session
149        }
150    };
151
152    let aster_bin = std::env::current_exe()
153        .map(|p| p.to_string_lossy().into_owned())
154        .unwrap_or_else(|_| "aster".to_string());
155
156    let command_not_found_handler = if with_command_not_found {
157        config
158            .command_not_found
159            .map(|s| s.replace("{aster_bin}", &aster_bin))
160            .unwrap_or_default()
161    } else {
162        String::new()
163    };
164
165    let script = config
166        .script_template
167        .replace("{session_id}", &session.id)
168        .replace("{aster_bin}", &aster_bin)
169        .replace("{command_not_found_handler}", &command_not_found_handler);
170
171    println!("{}", script);
172    Ok(())
173}
174
175pub async fn handle_term_log(command: String) -> Result<()> {
176    let session_id = std::env::var("ASTER_SESSION_ID").map_err(|_| {
177        anyhow!("ASTER_SESSION_ID not set. Run 'eval \"$(aster term init <shell>)\"' first.")
178    })?;
179
180    let message = Message::new(
181        Role::User,
182        chrono::Utc::now().timestamp_millis(),
183        vec![MessageContent::text(command)],
184    )
185    .with_metadata(MessageMetadata::user_only());
186
187    SessionManager::add_message(&session_id, &message).await?;
188
189    Ok(())
190}
191
192pub async fn handle_term_run(prompt: Vec<String>) -> Result<()> {
193    let prompt = prompt.join(" ");
194    let session_id = std::env::var("ASTER_SESSION_ID").map_err(|_| {
195        anyhow!(
196            "ASTER_SESSION_ID not set.\n\n\
197             Add to your shell config (~/.zshrc or ~/.bashrc):\n    \
198             eval \"$(aster term init zsh)\"\n\n\
199             Then restart your terminal or run: source ~/.zshrc"
200        )
201    })?;
202
203    let working_dir = std::env::current_dir()?;
204
205    SessionManager::update_session(&session_id)
206        .working_dir(working_dir)
207        .apply()
208        .await?;
209
210    let session = SessionManager::get_session(&session_id, true).await?;
211    let user_messages_after_last_assistant: Vec<&Message> =
212        if let Some(conv) = &session.conversation {
213            conv.messages()
214                .iter()
215                .rev()
216                .take_while(|m| m.role != Role::Assistant)
217                .collect()
218        } else {
219            Vec::new()
220        };
221
222    if let Some(oldest_user) = user_messages_after_last_assistant.last() {
223        SessionManager::truncate_conversation(&session_id, oldest_user.created).await?;
224    }
225
226    let prompt_with_context = if user_messages_after_last_assistant.is_empty() {
227        prompt
228    } else {
229        let history = user_messages_after_last_assistant
230            .iter()
231            .rev() // back to chronological order
232            .map(|m| m.as_concat_text())
233            .collect::<Vec<_>>()
234            .join("\n");
235
236        format!(
237            "<shell_history>\n{}\n</shell_history>\n\n{}",
238            history, prompt
239        )
240    };
241
242    let config = SessionBuilderConfig {
243        session_id: Some(session_id),
244        resume: true,
245        interactive: false,
246        quiet: true,
247        ..Default::default()
248    };
249
250    let mut session = build_session(config).await;
251    session.headless(prompt_with_context).await?;
252
253    Ok(())
254}
255
256/// Handle `aster term info` - print compact session info for prompt integration
257pub async fn handle_term_info() -> Result<()> {
258    let session_id = match std::env::var("ASTER_SESSION_ID") {
259        Ok(id) => id,
260        Err(_) => return Ok(()),
261    };
262
263    let session = SessionManager::get_session(&session_id, false).await.ok();
264    let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize;
265
266    let config = aster::config::Config::global();
267    let model_name = config
268        .get_aster_model()
269        .ok()
270        .map(|name| {
271            let short = name.rsplit('/').next().unwrap_or(&name);
272            if let Some(stripped) = short.strip_prefix("aster-") {
273                stripped.to_string()
274            } else {
275                short.to_string()
276            }
277        })
278        .unwrap_or_else(|| "?".to_string());
279
280    let context_limit = config
281        .get_aster_model()
282        .ok()
283        .and_then(|model_name| aster::model::ModelConfig::new(&model_name).ok())
284        .map(|mc| mc.context_limit())
285        .unwrap_or(128_000);
286
287    let percentage = if context_limit > 0 {
288        ((total_tokens as f64 / context_limit as f64) * 100.0).round() as usize
289    } else {
290        0
291    };
292
293    let filled = (percentage / 20).min(5);
294    let empty = 5 - filled;
295    let dots = format!("{}{}", "●".repeat(filled), "○".repeat(empty));
296
297    println!("{} {}", dots, model_name);
298
299    Ok(())
300}