Improve local tool execution and shell output panes

This commit is contained in:
2026-08-28 10:20:16 -05:00
parent a69b927cc3
commit 88c1ef9716
23 changed files with 807 additions and 238 deletions
+213 -1
View File
@@ -1,17 +1,26 @@
use std::collections::HashMap;
use galaxyui::elements::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use galaxyui::event::DispatchedEvent;
use galaxyui::fonts::Properties;
use galaxyui::geometry::rect::RectF;
use galaxyui::units::IntoPixels;
use galaxyui::EntityId;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::blockgrid_renderer::GridRenderParams;
use crate::appearance::Appearance;
use crate::settings::EnforceMinimumContrast;
use crate::terminal::blockgrid_renderer::BlockGridParams;
use crate::terminal::grid_renderer::{self, CellGlyphCache};
use crate::terminal::grid_size_util::grid_cell_dimensions;
use crate::terminal::model::blockgrid::BlockGrid;
use crate::terminal::model::grid::Dimensions;
use crate::terminal::model::grid::grid_handler::{GridHandler, TermMode};
use crate::terminal::model::grid::{Dimensions, RespectDisplayedOutput};
use crate::terminal::model::image_map::StoredImageMetadata;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::{color, SizeInfo};
@@ -119,3 +128,206 @@ impl Element for BlockGridElement {
self.origin
}
}
/// A paint-only snapshot of a live terminal grid.
///
/// Unlike [`BlockGridElement`], this can render both normal command output and an alternate-screen
/// grid. It deliberately owns a snapshot so the terminal model lock never spans layout or paint.
pub struct TerminalGridSnapshotElement {
grid: GridHandler,
row_count: usize,
colors: color::List,
override_colors: color::OverrideList,
image_metadata: HashMap<u32, StoredImageMetadata>,
grid_render_params: GridRenderParams,
terminal_view_id: EntityId,
natural_size: Vector2F,
size: Vector2F,
origin: Option<Point>,
bounds: Option<RectF>,
}
impl TerminalGridSnapshotElement {
#[allow(clippy::too_many_arguments)]
pub fn new(
grid: &GridHandler,
row_count: usize,
colors: color::List,
override_colors: color::OverrideList,
image_metadata: HashMap<u32, StoredImageMetadata>,
appearance: &Appearance,
enforce_minimum_contrast: EnforceMinimumContrast,
obfuscate_secrets: ObfuscateSecrets,
terminal_view_id: EntityId,
app: &AppContext,
) -> Self {
let cell_size = grid_cell_dimensions(
app.font_cache(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
appearance.line_height_ratio(),
);
let size = vec2f(
grid.columns() as f32 * cell_size.x(),
row_count as f32 * cell_size.y(),
);
let size_info = SizeInfo::new(
size,
cell_size.x().into_pixels(),
cell_size.y().into_pixels(),
0.0.into_pixels(),
0.0.into_pixels(),
)
.with_rows_and_columns(row_count, grid.columns());
Self {
grid: grid.clone(),
row_count,
colors,
override_colors,
image_metadata,
grid_render_params: GridRenderParams {
warp_theme: appearance.theme().clone(),
font_family: appearance.monospace_font_family(),
font_size: appearance.monospace_font_size(),
font_weight: appearance.monospace_font_weight(),
line_height_ratio: appearance.line_height_ratio(),
enforce_minimum_contrast,
obfuscate_secrets,
size_info,
cell_size,
use_ligature_rendering: false,
hide_cursor_cell: false,
},
terminal_view_id,
natural_size: size,
size,
origin: None,
bounds: None,
}
}
pub fn with_ligature_rendering(mut self) -> Self {
self.grid_render_params.use_ligature_rendering = true;
self
}
}
impl Element for TerminalGridSnapshotElement {
fn layout(
&mut self,
constraint: SizeConstraint,
_ctx: &mut LayoutContext,
_app: &AppContext,
) -> Vector2F {
// The parent clips and scrolls this element vertically, so preserve the full terminal
// height here. Clamping it to the viewport makes the scroll container believe rows below
// the fold do not exist, which also prevents its bottom-follow behavior from engaging.
self.size = vec2f(
self.natural_size
.x()
.min(constraint.max.x())
.max(constraint.min.x()),
self.natural_size.y().max(constraint.min.y()),
);
self.size
}
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
self.bounds = Some(RectF::new(origin, self.size));
let Some(visible_bounds) = ctx.scene.visible_rect(
self.origin.expect("origin was set immediately above"),
self.size,
) else {
return;
};
let row_height = self.grid_render_params.cell_size.y();
let start_row = ((visible_bounds.min_y() - origin.y()) / row_height)
.floor()
.max(0.) as usize;
let end_row = ((visible_bounds.max_y() - origin.y()) / row_height)
.ceil()
.max(0.) as usize;
let start_row = start_row.min(self.row_count);
let end_row = end_row.min(self.row_count);
let mut glyphs = CellGlyphCache::default();
let cursor_visible = self.grid.is_mode_set(TermMode::SHOW_CURSOR)
&& (start_row..end_row).contains(&self.grid.cursor_render_point().row);
let cursor_style = self.grid.cursor_style();
let obfuscate_secrets = self
.grid_render_params
.obfuscate_secrets
.and(&self.grid.get_secret_obfuscation());
grid_renderer::render_grid(
&self.grid,
start_row,
end_row,
&self.colors,
&self.override_colors,
&self.grid_render_params.warp_theme,
Properties::default().weight(self.grid_render_params.font_weight),
self.grid_render_params.font_family,
self.grid_render_params.font_size,
self.grid_render_params.line_height_ratio,
self.grid_render_params.cell_size,
self.grid_render_params.size_info.padding_x_px(),
origin,
&mut glyphs,
255,
None,
None,
None::<std::iter::Empty<&std::ops::RangeInclusive<crate::terminal::model::index::Point>>>,
None,
self.grid_render_params.enforce_minimum_contrast,
obfuscate_secrets,
None,
self.grid_render_params.use_ligature_rendering,
cursor_visible.then_some(cursor_style.shape),
RespectDisplayedOutput::Yes,
&self.image_metadata,
None,
false,
ctx,
app,
);
if cursor_visible {
grid_renderer::render_cursor(
&self.grid_render_params,
self.grid.cursor_render_point(),
self.grid.is_cursor_on_wide_char(),
cursor_style,
self.grid_render_params.size_info.padding_x_px(),
origin,
self.grid_render_params.warp_theme.cursor().into(),
ctx,
self.terminal_view_id,
None,
app,
);
}
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
fn size(&self) -> Option<Vector2F> {
Some(self.size)
}
fn origin(&self) -> Option<Point> {
self.origin
}
}
@@ -346,15 +346,20 @@ impl InteractionMode {
task_id: &TaskId,
conversation_id: AIConversationId,
) -> Result<Self, UpdateInteractionModeError> {
let requested_command_action_id = match self {
InteractionMode::User(_) => None,
InteractionMode::Agent(metadata) => {
if metadata.conversation_id != conversation_id {
return Err(UpdateInteractionModeError::UnexpectedConversationId);
let (requested_command_action_id, has_agent_written_to_block, should_hide_block) =
match self {
InteractionMode::User(_) => (None, false, false),
InteractionMode::Agent(metadata) => {
if metadata.conversation_id != conversation_id {
return Err(UpdateInteractionModeError::UnexpectedConversationId);
}
(
metadata.requested_command_action_id.clone(),
metadata.has_agent_written_to_block,
metadata.should_hide_block,
)
}
metadata.requested_command_action_id.clone()
}
};
};
Ok(Self::Agent(AgentInteractionMetadata {
requested_command_action_id,
@@ -364,8 +369,8 @@ impl InteractionMode {
is_blocked: false,
should_hide_responses: false,
}),
has_agent_written_to_block: false,
should_hide_block: false,
has_agent_written_to_block,
should_hide_block,
}))
}
+57 -8
View File
@@ -6791,7 +6791,18 @@ impl TerminalView {
agent_has_control,
..
} => {
self.redetermine_terminal_focus(ctx);
if !*agent_has_control && ctx.is_self_or_child_focused() {
let block_index = self.model.lock().block_list().block_index_for_id(block_id);
if let Some(block_index) = block_index {
self.update_scroll_position_locking(
ScrollPositionUpdate::ScrollToBottomOfBlock { block_index },
ctx,
);
}
self.focus_terminal(ctx);
} else {
self.redetermine_terminal_focus(ctx);
}
self.emit_long_running_command_agent_interaction_state_changed(
*agent_has_control,
block_id.clone(),
@@ -9744,6 +9755,7 @@ impl TerminalView {
model.block_list_mut().update_active_block_height();
}
self.maybe_emit_terminal_view_state_changed_for_long_running_block(ctx);
self.notify_active_requested_command_output(ctx);
self.use_agent_footer.update(ctx, |footer, ctx| {
footer.notify_and_notify_children(ctx);
});
@@ -9752,6 +9764,47 @@ impl TerminalView {
ctx.notify();
}
/// Invalidates the nested tool pane that owns the active requested-command block.
///
/// `TerminalView` and `RequestedCommandView` are independently cached views. A PTY wakeup
/// invalidates the former, but without this targeted notification the inline pane only sees
/// new output when a slower action or conversation event happens to invalidate it.
fn notify_active_requested_command_output(&self, ctx: &mut ViewContext<Self>) {
let active_command = {
let model = self.model.lock();
model
.block_list()
.active_block()
.agent_interaction_metadata()
.and_then(|metadata| {
metadata
.requested_command_action_id()
.cloned()
.map(|action_id| (action_id, *metadata.conversation_id()))
})
};
let Some((action_id, conversation_id)) = active_command else {
return;
};
let requested_command_view = self
.rich_content_views
.iter()
.rev()
.filter_map(|rich_content| rich_content.ai_block_metadata())
.filter(|metadata| metadata.conversation_id == conversation_id)
.find_map(|metadata| {
metadata
.ai_block_handle
.as_ref(ctx)
.requested_command_view(&action_id)
});
if let Some(requested_command_view) = requested_command_view {
requested_command_view.update(ctx, |_, ctx| ctx.notify());
}
}
/// This function is invoked whenever we detect an SSH ControlMaster error,
/// in which case completions will not work as expected.
fn handle_control_master_error(&mut self, ctx: &mut ViewContext<Self>) {
@@ -18951,9 +19004,9 @@ impl TerminalView {
// Otherwise, if the agent is monitoring this long-running block,
// then clear just that block and leave the rest of the blocklist in tact.
self.model.lock().clear_screen(ClearMode::ActiveBlock);
// In the agent-driving-but-not-monitoring state the terminal block is hidden. Make
// it visible and expand the header immediately so the user sees the cleared state
// right away, rather than waiting for the auto-expand timer (~3 s).
// In the agent-driving-but-not-monitoring state the terminal block is hidden.
// Expand its inline tool pane immediately so the user sees the cleared state there,
// rather than revealing a duplicate terminal block below the conversation.
if is_agent_driving_command && !is_agent_monitoring {
let requested_command_action_id = self
.model
@@ -18963,10 +19016,6 @@ impl TerminalView {
.requested_command_action_id()
.cloned();
if let Some(action_id) = &requested_command_action_id {
self.model
.lock()
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, true);
if let Some(ai_block_handle) = self.active_ai_block(ctx).cloned() {
ai_block_handle.update(ctx, |ai_block, ctx| {
ai_block.expand_requested_command_view(action_id, ctx);