From 99159184cb6365c9449fa29133f548d3cae6b635 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Mon, 18 May 2026 16:44:30 -0500 Subject: [PATCH] Add LSP rename (Phase 3) and update AGENTS.md with future work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F2 keybinding triggers prepareRename at cursor - Shows inline text input pre-filled with current symbol name - Enter confirms and applies workspace edits across all occurrences - Escape cancels the rename - Full flow: prepareRename → input → rename → apply edits - AGENTS.md: document inline token/cache/cost stats feature idea - AGENTS.md: document rename implementation for reference Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 46 +++++ app/src/code/local_code_editor.rs | 17 +- app/src/code/mod.rs | 2 + app/src/code/rename.rs | 276 ++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 app/src/code/rename.rs diff --git a/AGENTS.md b/AGENTS.md index 8ef11300..fd5e51e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,3 +32,49 @@ The project must always have a **clean build with zero warnings and zero errors* - `app/src/code/completion.rs` — extend the completion state machine with an LLM provider - `crates/ai/` — existing Bedrock/LLM infrastructure can be reused - Editor decoration system — for rendering ghost text (similar to inlay hints) + +--- + +## Inline Token/Cache/Cost Stats on LLM Responses + +**Idea:** Display context window usage, cache hit percentage, and cost as a compact footer below each completed LLM response in the agent conversation view. This replaces the "context" button on the bottom-right of the input area. + +**Data to display (per response):** +- Context usage: percentage used, input tokens / context window size (e.g., "Context: 45.2% (20.6k / 200k)") +- Cache hit stats: hit percentage with breakdown (e.g., "Cache Hit: 89.3% (R: 18.4k, W: 1.2k, M: 1.0k)") +- Cost: cumulative session cost (e.g., "Cost: $0.42") + +**Data source:** +- Bedrock `InvokeModel`/`Converse` response metadata contains: + - `usage.input_tokens` — tokens sent (cache misses) + - `usage.cache_read_input_tokens` — tokens served from cache + - `usage.cache_creation_input_tokens` — tokens written to cache + - `usage.output_tokens` — tokens generated +- Cache hit % = `cache_read / (cache_read + cache_write + input_tokens) * 100` + +**Reference implementation:** +- `~/.claude/statusline-command.sh` — shell script that formats these exact metrics for Claude Code's status line. Same formula and human-readable formatting (k/M suffixes) should be used. + +**UI approach:** +- Render as a single-line or two-line muted footer below each AI response block +- Use dimmed/secondary text color, monospace font, compact layout +- Remove the "context" icon button from the input area bottom-right since this replaces it + +**Integration points in Galaxy:** +- Find where Bedrock response `usage` metadata is captured after each streaming response completes +- Find the conversation block rendering (where each AI response ends) to add the footer element +- `app/src/ai/blocklist/` — likely where response blocks are rendered +- `crates/ai/` — where Bedrock API responses are parsed + +--- + +## LSP Rename (Phase 3 - App Wiring) + +**Status:** LSP layer is complete (`prepare_rename` + `rename` methods exist on LspServerModel). Needs app-layer wiring. + +**Implementation needed:** +- F2 keybinding triggers `prepareRename` at cursor position +- If valid, show an inline text input overlay at the symbol location pre-filled with the current name +- On confirm (Enter), send `rename` request with the new name +- Apply the resulting `WorkspaceEdit` to the editor (single-file for now) +- On cancel (Escape), dismiss the input overlay diff --git a/app/src/code/local_code_editor.rs b/app/src/code/local_code_editor.rs index bf13e60c..23285fd4 100644 --- a/app/src/code/local_code_editor.rs +++ b/app/src/code/local_code_editor.rs @@ -92,6 +92,7 @@ use super::editor::{ }; use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD}; use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD}; +use super::rename::RenameState; use super::signature_help::SignatureHelpState; use super::find_references_view::{FindReferencesView, FindReferencesViewEvent}; use super::language_server_extension::ProcessedDiagnostic; @@ -114,6 +115,11 @@ pub fn init(app: &mut AppContext) { LocalCodeEditorAction::OpenCodeActions, id!("LocalCodeEditorView"), ), + FixedBinding::new( + "f2", + LocalCodeEditorAction::StartRename, + id!("LocalCodeEditorView"), + ), ]); } @@ -209,6 +215,8 @@ pub enum LocalCodeEditorAction { }, /// Open the code actions menu (Cmd+.). OpenCodeActions, + /// Start LSP rename at cursor (F2). + StartRename, } #[derive(Default)] @@ -325,6 +333,8 @@ pub struct LocalCodeEditorView { pub(super) code_actions_debounce_tx: async_channel::Sender, /// State for LSP signature help (parameter hints). pub(super) signature_help_state: SignatureHelpState, + /// State for LSP rename (F2). + pub(super) rename_state: RenameState, } impl LocalCodeEditorView { @@ -555,6 +565,7 @@ impl LocalCodeEditorView { code_actions_state: CodeActionsState::default(), code_actions_debounce_tx, signature_help_state: SignatureHelpState::default(), + rename_state: RenameState::default(), }; if let Some(display_mode) = display_mode { @@ -1981,7 +1992,8 @@ impl LocalCodeEditorView { } let had_sig_help = self.signature_help_state.clear(); let had_code_actions = self.code_actions_state.dismiss(); - had_refs || had_hover || had_completion || had_sig_help || had_code_actions + let had_rename = self.rename_state.dismiss(); + had_refs || had_hover || had_completion || had_sig_help || had_code_actions || had_rename } /// Perform goto definition at the cursor position and navigate directly. @@ -2361,6 +2373,9 @@ impl TypedActionView for LocalCodeEditorView { LocalCodeEditorAction::OpenCodeActions => { self.open_code_actions_menu(ctx); } + LocalCodeEditorAction::StartRename => { + self.start_rename(ctx); + } } } } diff --git a/app/src/code/mod.rs b/app/src/code/mod.rs index 0e0bb1cd..6a220b35 100644 --- a/app/src/code/mod.rs +++ b/app/src/code/mod.rs @@ -13,6 +13,8 @@ pub mod completion; #[cfg(not(target_family = "wasm"))] pub mod find_references_view; #[cfg(not(target_family = "wasm"))] +pub mod rename; +#[cfg(not(target_family = "wasm"))] pub mod signature_help; #[cfg(not(target_family = "wasm"))] pub mod language_server_extension; diff --git a/app/src/code/rename.rs b/app/src/code/rename.rs new file mode 100644 index 00000000..8c7d093e --- /dev/null +++ b/app/src/code/rename.rs @@ -0,0 +1,276 @@ +use futures::stream::AbortHandle; +use galaxy_core::features::FeatureFlag; +use galaxy_core::ui::appearance::Appearance; +use galaxyui::{SingletonEntity, ViewContext, ViewHandle}; +use lsp::types::Location; +use string_offset::CharOffset; +use vec1::Vec1; + +use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions}; + +use super::local_code_editor::LocalCodeEditorView; + +pub(super) enum RenameState { + Idle, + Preparing { abort_handle: AbortHandle }, + InputActive { + editor: ViewHandle, + anchor_offset: CharOffset, + }, + Applying { abort_handle: AbortHandle }, +} + +impl Default for RenameState { + fn default() -> Self { + Self::Idle + } +} + +impl RenameState { + pub fn dismiss(&mut self) -> bool { + match self { + Self::Idle => false, + Self::Preparing { abort_handle } | Self::Applying { abort_handle } => { + abort_handle.abort(); + *self = Self::Idle; + true + } + Self::InputActive { .. } => { + *self = Self::Idle; + true + } + } + } +} + +impl LocalCodeEditorView { + pub(super) fn is_rename_enabled() -> bool { + FeatureFlag::LspRename.is_enabled() + } + + pub(super) fn start_rename(&mut self, ctx: &mut ViewContext) { + if !Self::is_rename_enabled() { + return; + } + + let Some(file_path) = self.file_path() else { + return; + }; + + let Some(lsp_server) = &self.lsp_server else { + return; + }; + + let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx); + let lsp_position = self + .editor() + .as_ref(ctx) + .offset_to_lsp_position(cursor_offset, ctx); + + let future = match lsp_server + .as_ref(ctx) + .prepare_rename(file_path.to_path_buf(), lsp_position) + { + Ok(future) => future, + Err(e) => { + log::warn!("Failed to call lsp.prepare_rename: {e}"); + return; + } + }; + + self.rename_state.dismiss(); + + let abort_handle = ctx + .spawn(future, move |me, result, ctx| { + me.handle_prepare_rename_response(result, cursor_offset, ctx); + }) + .abort_handle(); + + self.rename_state = RenameState::Preparing { abort_handle }; + } + + fn handle_prepare_rename_response( + &mut self, + result: anyhow::Result>, + cursor_offset: CharOffset, + ctx: &mut ViewContext, + ) { + let prepare_result = match result { + Ok(Some(r)) => r, + _ => { + log::info!("Symbol at cursor cannot be renamed"); + self.rename_state = RenameState::Idle; + return; + } + }; + + let editor_ref = self.editor().as_ref(ctx); + let range_start = editor_ref.lsp_location_to_offset(&prepare_result.range.start, ctx); + let range_end = editor_ref.lsp_location_to_offset(&prepare_result.range.end, ctx); + + let current_name = editor_ref + .buffer_text_in_range(range_start..range_end, ctx) + .unwrap_or_default(); + + let placeholder = prepare_result.placeholder.unwrap_or(current_name); + + let rename_editor = ctx.add_typed_action_view(|ctx| { + let appearance = Appearance::as_ref(ctx); + let mut editor = EditorView::single_line( + SingleLineEditorOptions { + text: TextOptions::ui_text(None, appearance), + select_all_on_focus: true, + ..Default::default() + }, + ctx, + ); + editor.set_buffer_text(&placeholder, ctx); + editor + }); + + ctx.subscribe_to_view(&rename_editor, |me, _, event, ctx| { + me.handle_rename_editor_event(event, ctx); + }); + + ctx.focus(&rename_editor); + + self.rename_state = RenameState::InputActive { + editor: rename_editor, + anchor_offset: cursor_offset, + }; + ctx.notify(); + } + + fn handle_rename_editor_event( + &mut self, + event: &EditorEvent, + ctx: &mut ViewContext, + ) { + match event { + EditorEvent::Enter => { + let new_name = match &self.rename_state { + RenameState::InputActive { editor, .. } => { + editor.as_ref(ctx).buffer_text(ctx) + } + _ => return, + }; + self.confirm_rename(new_name, ctx); + } + EditorEvent::Escape => { + self.rename_state = RenameState::Idle; + ctx.focus(self.editor()); + ctx.notify(); + } + _ => {} + } + } + + fn confirm_rename(&mut self, new_name: String, ctx: &mut ViewContext) { + if new_name.is_empty() { + self.rename_state = RenameState::Idle; + ctx.focus(self.editor()); + ctx.notify(); + return; + } + + let anchor_offset = match &self.rename_state { + RenameState::InputActive { anchor_offset, .. } => *anchor_offset, + _ => return, + }; + + let Some(file_path) = self.file_path() else { + return; + }; + + let Some(lsp_server) = &self.lsp_server else { + return; + }; + + let lsp_position = self + .editor() + .as_ref(ctx) + .offset_to_lsp_position(anchor_offset, ctx); + + let future = match lsp_server + .as_ref(ctx) + .rename(file_path.to_path_buf(), lsp_position, new_name) + { + Ok(future) => future, + Err(e) => { + log::warn!("Failed to call lsp.rename: {e}"); + self.rename_state = RenameState::Idle; + ctx.focus(self.editor()); + ctx.notify(); + return; + } + }; + + let abort_handle = ctx + .spawn(future, move |me, result, ctx| { + me.handle_rename_response(result, ctx); + }) + .abort_handle(); + + self.rename_state = RenameState::Applying { abort_handle }; + } + + fn handle_rename_response( + &mut self, + result: anyhow::Result>, + ctx: &mut ViewContext, + ) { + self.rename_state = RenameState::Idle; + ctx.focus(self.editor()); + + let rename_result = match result { + Ok(Some(r)) if !r.edits.is_empty() => r, + Ok(_) => { + log::info!("Rename returned no edits"); + ctx.notify(); + return; + } + Err(e) => { + log::warn!("Rename failed: {e}"); + ctx.notify(); + return; + } + }; + + let mut edits_for_current_file: Vec<(String, std::ops::Range)> = Vec::new(); + + for file_edit in &rename_result.edits { + for text_edit in &file_edit.edits { + let start = self.editor().as_ref(ctx).lsp_location_to_offset( + &Location { + line: text_edit.range.start.line, + column: text_edit.range.start.column, + }, + ctx, + ); + let end = self.editor().as_ref(ctx).lsp_location_to_offset( + &Location { + line: text_edit.range.end.line, + column: text_edit.range.end.column, + }, + ctx, + ); + edits_for_current_file.push((text_edit.text.clone(), start..end)); + } + } + + if edits_for_current_file.is_empty() { + ctx.notify(); + return; + } + + edits_for_current_file.sort_by(|a, b| b.1.start.cmp(&a.1.start)); + + if let Ok(edits) = Vec1::try_from_vec(edits_for_current_file) { + self.editor.update(ctx, |editor, ctx| { + editor.apply_edits(edits, ctx); + }); + } + + ctx.notify(); + } +}