Skip to content
Draft
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
16 changes: 9 additions & 7 deletions crates/code_assistant/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ edition = "2021"
[features]
default = ["document-conversion"]
document-conversion = ["transmutation", "fs_explorer/document-conversion"]
# Enable the GPUI desktop UI. Requires Xcode / Metal on macOS.
# Without this flag the binary is TUI-only and compiles without Metal tools.
gpui-ui = ["dep:gpui", "dep:gpui_platform", "dep:gpui-component", "dep:terminal", "dep:terminal_view"]

[dependencies]
command_executor = { path = "../command_executor" }
fs_explorer = { path = "../fs_explorer" }
git = { path = "../git" }
llm = { path = "../llm" }
sandbox = { path = "../sandbox" }
terminal = { path = "../terminal" }
terminal_view = { path = "../terminal_view" }
terminal = { path = "../terminal", optional = true }
terminal_view = { path = "../terminal_view", optional = true }
web = { path = "../web" }

glob = "0.3"
Expand Down Expand Up @@ -42,10 +45,10 @@ textwrap = "0.16"
tui-markdown = "=0.3.6"
derive_more = { version = "2", features = ["is_variant"] }

# GPUI related
gpui = "0.2.2"
gpui_platform = { version = "0.1", features = ["font-kit"] }
gpui-component = "0.5.2"
# GPUI related — only needed when the "gpui" feature is enabled
gpui = { version = "0.2.2", optional = true }
gpui_platform = { version = "0.1", features = ["font-kit"], optional = true }
gpui-component = { version = "0.5.2", optional = true }
smallvec = "1.15"
rust-embed = { version = "8.9", features = ["include-exclude"] }

Expand Down Expand Up @@ -110,6 +113,5 @@ tokio-util = { version = "0.7", features = ["compat"] }
core-text = "=21.0.0"

[dev-dependencies]
gpui = { version = "0.2.2", features = ["test-support"] }
axum = "0.7"
bytes = "1.10"
1 change: 1 addition & 0 deletions crates/code_assistant/src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod acp;
#[cfg(feature = "gpui-ui")]
pub mod gpui;
pub mod server;
pub mod terminal;
Expand Down
13 changes: 8 additions & 5 deletions crates/code_assistant/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,14 @@ impl Args {
);
}

// Check persisted default model from settings
let settings = crate::ui::gpui::shared::settings::UiSettings::load();
if let Some(ref default_model) = settings.default_model {
if config.get_model(default_model).is_some() {
return Ok(default_model.clone());
// Check persisted default model from settings (only available in GPUI builds)
#[cfg(feature = "gpui-ui")]
{
let settings = crate::ui::gpui::shared::settings::UiSettings::load();
if let Some(ref default_model) = settings.default_model {
if config.get_model(default_model).is_some() {
return Ok(default_model.clone());
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions crates/code_assistant/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ async fn main() -> Result<()> {
None => {
if args.ui {
// GPUI mode - use stderr to keep stdout clean
#[cfg(not(feature = "gpui-ui"))]
anyhow::bail!(
"This binary was compiled without the 'gpui' feature. \
Rebuild with `cargo build --features gpui` to enable the desktop UI."
);
#[cfg(feature = "gpui-ui")]
setup_logging(args.verbose, false);
} else {
// Terminal UI mode - log to file to prevent UI interference
Expand All @@ -96,6 +102,9 @@ async fn main() -> Result<()> {
// In GUI mode, allow starting without a valid model config
// (the settings screen will guide the user through setup).
let model_name = if args.ui {
#[cfg(not(feature = "gpui-ui"))]
unreachable!("--ui requires the gpui feature");
#[cfg(feature = "gpui-ui")]
args.get_model_name().unwrap_or_default()
} else {
args.get_model_name()?
Expand All @@ -116,6 +125,9 @@ async fn main() -> Result<()> {
};

if args.ui {
#[cfg(not(feature = "gpui-ui"))]
unreachable!("--ui requires the gpui feature");
#[cfg(feature = "gpui-ui")]
app::gpui::run(config)
} else {
app::terminal::run(config).await
Expand Down
45 changes: 45 additions & 0 deletions crates/code_assistant/src/ui/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ use crate::config::{save_project, DefaultProjectManager};
use crate::persistence::{ChatMetadata, DraftAttachment, SessionModelConfig};
use crate::session::SessionManager;
use crate::types::Project;
#[cfg(feature = "gpui-ui")]
use crate::ui::gpui::terminal::executor::GpuiTerminalCommandExecutor;
use crate::ui::UserInterface;
use crate::utils::content::content_blocks_from;
use command_executor::DefaultCommandExecutor;
use llm::factory::create_llm_client_from_model;
use llm::provider_config::ConfigurationSystem;
use sandbox::SandboxPolicy;
Expand Down Expand Up @@ -110,6 +112,21 @@ pub enum BackendEvent {
session_id: String,
},

/// Clear the conversation context (messages) for a session.
///
/// The session itself is kept alive; only the message history is wiped.
/// The UI transcript is cleared via `UiEvent::ClearMessages`.
ClearContext {
session_id: String,
},

/// Compact (summarise) conversation context for a session.
///
/// Not yet implemented — emits an informational message instead.
CompactContext {
session_id: String,
},

/// Incremental session refresh triggered by the file watcher.
/// Compares the on-disk state with the in-memory state and emits only
/// the delta (new messages appended by an external process).
Expand Down Expand Up @@ -391,6 +408,31 @@ pub async fn handle_backend_events(
handle_refresh_session(&multi_session_manager, &session_id, &ui).await
}

BackendEvent::ClearContext { session_id } => {
let mut manager = multi_session_manager.lock().await;
if let Some(session) = manager.get_session_mut(&session_id) {
let chat = &mut session.session;
chat.message_nodes.clear();
chat.active_path.clear();
chat.next_node_id = 1;
chat.messages.clear();
chat.plan = Default::default();
}
let _ = ui.send_event(crate::ui::UiEvent::ClearMessages).await;
None
}

BackendEvent::CompactContext { session_id: _ } => {
let _ = ui
.send_event(crate::ui::UiEvent::DisplayError {
message:
"Compact is not yet implemented. Use /clear to reset context."
.to_string(),
})
.await;
None
}

BackendEvent::UpdateDefaultModel { model_name } => {
debug!("Updating default model name to: {}", model_name);
let mut manager = multi_session_manager.lock().await;
Expand Down Expand Up @@ -649,7 +691,10 @@ async fn handle_send_user_message(
// Start the agent (message already added)
let result = {
let project_manager = Box::new(DefaultProjectManager::new());
#[cfg(feature = "gpui-ui")]
let command_executor = Box::new(GpuiTerminalCommandExecutor::new(session_id.to_string()));
#[cfg(not(feature = "gpui-ui"))]
let command_executor = Box::new(DefaultCommandExecutor);
let user_interface = ui.clone();

// Check if session has stored model config, otherwise use global config
Expand Down
1 change: 1 addition & 0 deletions crates/code_assistant/src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod backend;
#[cfg(feature = "gpui-ui")]
pub mod gpui;
pub mod streaming;
pub mod terminal;
Expand Down
170 changes: 170 additions & 0 deletions crates/code_assistant/src/ui/terminal/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::ui::backend::{
handle_backend_events, BackendEvent, BackendResponse, BackendRuntimeOptions,
};
use crate::ui::terminal::{
commands::all_commands,
input::{InputManager, KeyEventResult},
renderer::ProductionTerminalRenderer,
state::AppState,
Expand Down Expand Up @@ -270,6 +271,136 @@ async fn event_loop(
renderer_guard.set_plan_expanded(expanded);
renderer_guard.set_overlay_active(overlay_active);
}
KeyEventResult::ClearContext => {
let current_session_id = {
let state = app_state.lock().await;
state.current_session_id.clone()
};
if let Some(session_id) = current_session_id {
let _ = backend_event_tx
.send(BackendEvent::ClearContext { session_id })
.await;
}
}
KeyEventResult::CompactContext => {
let current_session_id = {
let state = app_state.lock().await;
state.current_session_id.clone()
};
if let Some(session_id) = current_session_id {
let _ = backend_event_tx
.send(BackendEvent::CompactContext { session_id })
.await;
}
}
KeyEventResult::UpdateAutocomplete { query } => {
match query {
None => {
// Current line no longer starts with '/'; close popup.
let mut state = app_state.lock().await;
state.close_autocomplete();
input_manager.autocomplete_active = false;
drop(state);
renderer.lock().await.clear_autocomplete();
}
Some(q) => {
let filtered: Vec<_> = all_commands()
.iter()
.filter(|cmd| {
cmd.name.starts_with(q.as_str())
|| cmd.aliases.iter().any(|a| a.starts_with(q.as_str()))
})
.collect();
let mut state = app_state.lock().await;
if filtered.is_empty() {
state.close_autocomplete();
input_manager.autocomplete_active = false;
drop(state);
renderer.lock().await.clear_autocomplete();
} else {
// Clamp selected index to new list length.
let selected = state
.autocomplete_selected
.min(filtered.len().saturating_sub(1));
state.open_autocomplete(q);
state.autocomplete_selected = selected;
input_manager.autocomplete_active = true;
drop(state);
let items: Vec<_> = filtered
.iter()
.map(|cmd| (cmd.name, cmd.description))
.collect();
renderer.lock().await.set_autocomplete(items, selected);
}
}
}
}
KeyEventResult::MoveAutocomplete(delta) => {
let (item_count, items) = {
let state = app_state.lock().await;
let q = state.autocomplete_query.clone();
let filtered: Vec<_> = all_commands()
.iter()
.filter(|cmd| {
cmd.name.starts_with(q.as_str())
|| cmd.aliases.iter().any(|a| a.starts_with(q.as_str()))
})
.collect();
let count = filtered.len();
let items: Vec<_> = filtered
.iter()
.map(|cmd| (cmd.name, cmd.description))
.collect();
(count, items)
};
let selected = {
let mut state = app_state.lock().await;
state.move_autocomplete_selection(delta, item_count);
state.autocomplete_selected
};
renderer.lock().await.set_autocomplete(items, selected);
}
KeyEventResult::SelectAutocomplete => {
// Find the selected command and expand its name into the textarea.
let (selected_name, query) = {
let state = app_state.lock().await;
let q = state.autocomplete_query.clone();
let filtered: Vec<_> = all_commands()
.iter()
.filter(|cmd| {
cmd.name.starts_with(q.as_str())
|| cmd.aliases.iter().any(|a| a.starts_with(q.as_str()))
})
.collect();
let name = filtered
.get(state.autocomplete_selected)
.map(|cmd| cmd.name.to_string());
(name, q)
};

if let Some(name) = selected_name {
// Replace "/<query>" on the current line with "/<name> ".
// Compute the byte range: from start-of-line up to cursor.
let cursor = input_manager.textarea.cursor();
let text = input_manager.textarea.text().to_string();
let line_start = text[..cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
// Replace exactly the portion "/<query>" the user typed.
let typed_end = line_start + 1 + query.len(); // '/' + query
let replace_end = typed_end.min(cursor);
input_manager.textarea.replace_range(
line_start..replace_end,
&format!("/{name} "),
);
let mut state = app_state.lock().await;
state.close_autocomplete();
input_manager.autocomplete_active = false;
drop(state);
renderer.lock().await.clear_autocomplete();
}
}
}
needs_redraw = true;
}
Expand All @@ -278,6 +409,45 @@ async fn event_loop(
// normalize before processing.
let pasted = pasted.replace('\r', "\n");
input_manager.handle_paste(pasted);
// Update autocomplete: pasting may introduce or clear a slash prefix.
match input_manager.slash_prefix() {
None => {
let mut state = app_state.lock().await;
state.close_autocomplete();
input_manager.autocomplete_active = false;
drop(state);
renderer.lock().await.clear_autocomplete();
}
Some(q) => {
let filtered: Vec<_> = all_commands()
.iter()
.filter(|cmd| {
cmd.name.starts_with(q.as_str())
|| cmd.aliases.iter().any(|a| a.starts_with(q.as_str()))
})
.collect();
let mut state = app_state.lock().await;
if filtered.is_empty() {
state.close_autocomplete();
input_manager.autocomplete_active = false;
drop(state);
renderer.lock().await.clear_autocomplete();
} else {
let selected = state
.autocomplete_selected
.min(filtered.len().saturating_sub(1));
state.open_autocomplete(q);
state.autocomplete_selected = selected;
input_manager.autocomplete_active = true;
drop(state);
let items: Vec<_> = filtered
.iter()
.map(|cmd| (cmd.name, cmd.description))
.collect();
renderer.lock().await.set_autocomplete(items, selected);
}
}
}
needs_redraw = true;
}
Event::Resize(_, _) => {
Expand Down
Loading