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
|
||||
- `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
|
||||
|
||||
@@ -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<CharOffset>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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