Add LSP rename (Phase 3) and update AGENTS.md with future work
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
940c3b5dff
commit
99159184cb
@@ -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
|
- `app/src/code/completion.rs` — extend the completion state machine with an LLM provider
|
||||||
- `crates/ai/` — existing Bedrock/LLM infrastructure can be reused
|
- `crates/ai/` — existing Bedrock/LLM infrastructure can be reused
|
||||||
- Editor decoration system — for rendering ghost text (similar to inlay hints)
|
- 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
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ use super::editor::{
|
|||||||
};
|
};
|
||||||
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
|
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
|
||||||
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
|
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
|
||||||
|
use super::rename::RenameState;
|
||||||
use super::signature_help::SignatureHelpState;
|
use super::signature_help::SignatureHelpState;
|
||||||
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
|
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
|
||||||
use super::language_server_extension::ProcessedDiagnostic;
|
use super::language_server_extension::ProcessedDiagnostic;
|
||||||
@@ -114,6 +115,11 @@ pub fn init(app: &mut AppContext) {
|
|||||||
LocalCodeEditorAction::OpenCodeActions,
|
LocalCodeEditorAction::OpenCodeActions,
|
||||||
id!("LocalCodeEditorView"),
|
id!("LocalCodeEditorView"),
|
||||||
),
|
),
|
||||||
|
FixedBinding::new(
|
||||||
|
"f2",
|
||||||
|
LocalCodeEditorAction::StartRename,
|
||||||
|
id!("LocalCodeEditorView"),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +215,8 @@ pub enum LocalCodeEditorAction {
|
|||||||
},
|
},
|
||||||
/// Open the code actions menu (Cmd+.).
|
/// Open the code actions menu (Cmd+.).
|
||||||
OpenCodeActions,
|
OpenCodeActions,
|
||||||
|
/// Start LSP rename at cursor (F2).
|
||||||
|
StartRename,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -325,6 +333,8 @@ pub struct LocalCodeEditorView {
|
|||||||
pub(super) code_actions_debounce_tx: async_channel::Sender<CharOffset>,
|
pub(super) code_actions_debounce_tx: async_channel::Sender<CharOffset>,
|
||||||
/// State for LSP signature help (parameter hints).
|
/// State for LSP signature help (parameter hints).
|
||||||
pub(super) signature_help_state: SignatureHelpState,
|
pub(super) signature_help_state: SignatureHelpState,
|
||||||
|
/// State for LSP rename (F2).
|
||||||
|
pub(super) rename_state: RenameState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalCodeEditorView {
|
impl LocalCodeEditorView {
|
||||||
@@ -555,6 +565,7 @@ impl LocalCodeEditorView {
|
|||||||
code_actions_state: CodeActionsState::default(),
|
code_actions_state: CodeActionsState::default(),
|
||||||
code_actions_debounce_tx,
|
code_actions_debounce_tx,
|
||||||
signature_help_state: SignatureHelpState::default(),
|
signature_help_state: SignatureHelpState::default(),
|
||||||
|
rename_state: RenameState::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(display_mode) = display_mode {
|
if let Some(display_mode) = display_mode {
|
||||||
@@ -1981,7 +1992,8 @@ impl LocalCodeEditorView {
|
|||||||
}
|
}
|
||||||
let had_sig_help = self.signature_help_state.clear();
|
let had_sig_help = self.signature_help_state.clear();
|
||||||
let had_code_actions = self.code_actions_state.dismiss();
|
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.
|
/// Perform goto definition at the cursor position and navigate directly.
|
||||||
@@ -2361,6 +2373,9 @@ impl TypedActionView for LocalCodeEditorView {
|
|||||||
LocalCodeEditorAction::OpenCodeActions => {
|
LocalCodeEditorAction::OpenCodeActions => {
|
||||||
self.open_code_actions_menu(ctx);
|
self.open_code_actions_menu(ctx);
|
||||||
}
|
}
|
||||||
|
LocalCodeEditorAction::StartRename => {
|
||||||
|
self.start_rename(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub mod completion;
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub mod find_references_view;
|
pub mod find_references_view;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub mod rename;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub mod signature_help;
|
pub mod signature_help;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub mod language_server_extension;
|
pub mod language_server_extension;
|
||||||
|
|||||||
@@ -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<EditorView>,
|
||||||
|
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<Self>) {
|
||||||
|
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<Option<lsp::PrepareRenameResult>>,
|
||||||
|
cursor_offset: CharOffset,
|
||||||
|
ctx: &mut ViewContext<Self>,
|
||||||
|
) {
|
||||||
|
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<Self>,
|
||||||
|
) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Option<lsp::RenameResult>>,
|
||||||
|
ctx: &mut ViewContext<Self>,
|
||||||
|
) {
|
||||||
|
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<CharOffset>)> = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user