Merge code-branch-20251026-025326: auto-drive cards and web search

This commit is contained in:
James Peter
2025-10-26 19:29:07 +10:00
18 changed files with 2231 additions and 509 deletions
+55 -82
View File
@@ -48,7 +48,33 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code
// I/O buffer sizing
const READ_CHUNK_SIZE: usize = 8192; // bytes per read
const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB
pub(crate) const EXEC_CAPTURE_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MiB tail per stream
pub(crate) const EXEC_CAPTURE_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MiB cap per stream
fn append_with_cap(
buf: &mut Vec<u8>,
chunk: &[u8],
truncated: &mut bool,
truncated_lines: &mut u32,
truncated_bytes: &mut usize,
) {
if chunk.is_empty() {
return;
}
buf.extend_from_slice(chunk);
if buf.len() > EXEC_CAPTURE_MAX_BYTES {
let drop_len = buf.len() - EXEC_CAPTURE_MAX_BYTES;
let mut lines_dropped = 0u32;
for byte in buf.drain(0..drop_len) {
if byte == b'\n' {
lines_dropped = lines_dropped.saturating_add(1);
}
}
*truncated = true;
*truncated_lines = (*truncated_lines).saturating_add(lines_dropped);
*truncated_bytes = (*truncated_bytes).saturating_add(drop_len);
}
}
/// Limit the number of ExecCommandOutputDelta events emitted per exec call.
/// Aggregation still collects full output; only the live event stream is capped.
@@ -262,71 +288,6 @@ impl StreamOutput<Vec<u8>> {
}
}
#[derive(Debug)]
struct TruncatingBuffer {
buf: Vec<u8>,
max_bytes: usize,
truncated_before_bytes: usize,
}
impl TruncatingBuffer {
fn with_capacity(max_bytes: usize, capacity_hint: usize) -> Self {
let capped_hint = max_bytes.min(capacity_hint);
Self {
buf: Vec::with_capacity(capped_hint),
max_bytes,
truncated_before_bytes: 0,
}
}
fn push(&mut self, chunk: &[u8]) {
if chunk.is_empty() {
return;
}
if self.max_bytes == 0 {
self.truncated_before_bytes = self
.truncated_before_bytes
.saturating_add(chunk.len());
return;
}
if chunk.len() >= self.max_bytes {
let drop_existing = self.buf.len();
let keep_start = chunk.len().saturating_sub(self.max_bytes);
self.truncated_before_bytes = self
.truncated_before_bytes
.saturating_add(drop_existing + keep_start);
self.buf.clear();
self.buf
.extend_from_slice(&chunk[chunk.len() - self.max_bytes..]);
return;
}
let total = self.buf.len().saturating_add(chunk.len());
if total <= self.max_bytes {
self.buf.extend_from_slice(chunk);
return;
}
let overflow = total - self.max_bytes;
self.buf.drain(..overflow);
self.truncated_before_bytes = self
.truncated_before_bytes
.saturating_add(overflow);
self.buf.extend_from_slice(chunk);
}
fn into_parts(self) -> (Vec<u8>, Option<usize>) {
let truncated = if self.truncated_before_bytes > 0 {
Some(self.truncated_before_bytes)
} else {
None
};
(self.buf, truncated)
}
}
#[derive(Debug, Clone)]
pub struct ExecToolCallOutput {
pub exit_code: i32,
@@ -479,18 +440,25 @@ async fn consume_truncated_output(
drop(agg_tx);
let mut combined = TruncatingBuffer::with_capacity(
EXEC_CAPTURE_MAX_BYTES,
AGGREGATE_BUFFER_INITIAL_CAPACITY,
);
let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
let mut combined_truncated = false;
let mut combined_truncated_lines = 0u32;
let mut combined_truncated_bytes = 0usize;
while let Ok(chunk) = agg_rx.recv().await {
combined.push(&chunk);
append_with_cap(
&mut combined_buf,
&chunk,
&mut combined_truncated,
&mut combined_truncated_lines,
&mut combined_truncated_bytes,
);
}
let (combined_buf, truncated_before_bytes) = combined.into_parts();
let aggregated_output = StreamOutput {
text: combined_buf,
truncated_after_lines: None,
truncated_before_bytes,
truncated_after_lines: combined_truncated
.then_some(combined_truncated_lines.max(1)),
truncated_before_bytes: (combined_truncated_bytes > 0)
.then_some(combined_truncated_bytes),
};
Ok(RawExecToolCallOutput {
@@ -508,10 +476,10 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
is_stderr: bool,
aggregate_tx: Option<Sender<Vec<u8>>>,
) -> io::Result<StreamOutput<Vec<u8>>> {
let mut buf = TruncatingBuffer::with_capacity(
EXEC_CAPTURE_MAX_BYTES,
AGGREGATE_BUFFER_INITIAL_CAPACITY,
);
let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
let mut truncated = false;
let mut truncated_lines = 0u32;
let mut truncated_bytes = 0usize;
let mut tmp = [0u8; READ_CHUNK_SIZE];
let mut emitted_deltas: usize = 0;
@@ -559,15 +527,20 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
let _ = tx.send(tmp[..n].to_vec()).await;
}
buf.push(&tmp[..n]);
append_with_cap(
&mut buf,
&tmp[..n],
&mut truncated,
&mut truncated_lines,
&mut truncated_bytes,
);
// Continue reading to EOF to avoid back-pressure
}
let (buf, truncated_before_bytes) = buf.into_parts();
Ok(StreamOutput {
text: buf,
truncated_after_lines: None,
truncated_before_bytes,
truncated_after_lines: truncated.then_some(truncated_lines.max(1)),
truncated_before_bytes: (truncated_bytes > 0).then_some(truncated_bytes),
})
}
+257
View File
@@ -0,0 +1,257 @@
use ratatui::prelude::Color;
use std::time::Duration;
#[derive(Clone, Copy, Debug)]
pub struct GradientSpec {
pub left: Color,
pub right: Color,
pub bias: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct PaletteSpec {
pub border: Color,
pub text: Color,
pub title: Color,
pub footer: Color,
}
#[derive(Clone, Copy, Debug)]
pub struct RevealConfig {
pub duration: Duration,
pub variant: RevealVariant,
}
#[derive(Clone, Copy, Debug)]
pub enum RevealVariant {
GlitchSweep,
VertDrift,
DiagonalPulse,
ChromaticScan,
SparkleFade,
RainbowBloom,
AuroraBridge,
PrismRise,
NeonRoad,
HorizonRush,
LightBloom,
}
#[derive(Clone, Copy, Debug)]
pub struct CardTheme {
pub gradient: GradientSpec,
pub palette: PaletteSpec,
pub reveal: Option<RevealConfig>,
}
#[derive(Clone, Copy, Debug)]
pub struct CardThemeDefinition {
pub name: &'static str,
pub theme: CardTheme,
}
#[derive(Clone, Copy, Debug)]
pub struct CardPreviewSpec {
pub name: &'static str,
pub body: &'static [&'static str],
pub theme: CardTheme,
}
impl CardThemeDefinition {
pub fn preview(&self, body: &'static [&'static str]) -> CardPreviewSpec {
CardPreviewSpec {
name: self.name,
body,
theme: self.theme,
}
}
}
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
fn gradient(left: (u8, u8, u8), right: (u8, u8, u8), bias: f32) -> GradientSpec {
GradientSpec {
left: rgb(left.0, left.1, left.2),
right: rgb(right.0, right.1, right.2),
bias,
}
}
fn palette(
border: (u8, u8, u8),
text: (u8, u8, u8),
title: (u8, u8, u8),
footer: (u8, u8, u8),
) -> PaletteSpec {
PaletteSpec {
border: rgb(border.0, border.1, border.2),
text: rgb(text.0, text.1, text.2),
title: rgb(title.0, title.1, title.2),
footer: rgb(footer.0, footer.1, footer.2),
}
}
fn reveal(duration_ms: u64, variant: RevealVariant) -> Option<RevealConfig> {
Some(RevealConfig {
duration: Duration::from_millis(duration_ms),
variant,
})
}
pub fn search_dark_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Search Dark",
theme: CardTheme {
gradient: gradient((48, 12, 96), (210, 120, 255), -0.05),
palette: palette((210, 170, 245), (242, 234, 255), (248, 240, 255), (232, 220, 250)),
reveal: None,
},
}
}
pub fn auto_drive_dark_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Auto Drive Dark",
theme: CardTheme {
gradient: gradient((50, 70, 200), (190, 220, 255), -0.05),
palette: palette((180, 210, 248), (232, 242, 254), (240, 248, 255), (220, 234, 248)),
reveal: reveal(720, RevealVariant::RainbowBloom),
},
}
}
pub fn agent_orange_dark_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Agent Orange Dark",
theme: CardTheme {
gradient: gradient((150, 60, 20), (235, 120, 50), -0.05),
palette: palette((226, 168, 120), (248, 230, 216), (254, 240, 228), (236, 212, 200)),
reveal: None,
},
}
}
pub fn browser_dark_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Browser Dark",
theme: CardTheme {
gradient: gradient((18, 32, 72), (96, 140, 210), -0.05),
palette: palette((150, 200, 255), (220, 236, 255), (204, 224, 252), (192, 212, 240)),
reveal: None,
},
}
}
pub fn agent_green_dark_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Agent Green Dark",
theme: CardTheme {
gradient: gradient((20, 70, 36), (160, 240, 120), -0.05),
palette: palette((168, 236, 186), (226, 250, 224), (210, 248, 210), (200, 240, 202)),
reveal: None,
},
}
}
pub fn search_light_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Search Light",
theme: CardTheme {
gradient: gradient((248, 228, 255), (216, 190, 255), -0.05),
palette: palette((82, 48, 164), (20, 12, 54), (30, 18, 72), (36, 22, 86)),
reveal: None,
},
}
}
pub fn auto_drive_light_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Auto Drive Light",
theme: CardTheme {
gradient: gradient((216, 238, 255), (160, 196, 232), -0.05),
palette: palette((32, 96, 140), (12, 34, 52), (18, 48, 70), (24, 60, 86)),
reveal: reveal(720, RevealVariant::LightBloom),
},
}
}
pub fn agent_orange_light_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Agent Orange Light",
theme: CardTheme {
gradient: gradient((255, 220, 200), (232, 160, 120), -0.05),
palette: palette((140, 64, 30), (78, 26, 4), (92, 34, 6), (104, 40, 10)),
reveal: None,
},
}
}
pub fn browser_light_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Browser Light",
theme: CardTheme {
gradient: gradient((200, 220, 242), (104, 132, 180), -0.05),
palette: palette((40, 70, 120), (16, 22, 44), (26, 34, 70), (32, 42, 82)),
reveal: None,
},
}
}
pub fn agent_green_light_theme() -> CardThemeDefinition {
CardThemeDefinition {
name: "Agent Green Light",
theme: CardTheme {
gradient: gradient((220, 255, 230), (182, 228, 188), -0.05),
palette: palette((48, 102, 82), (18, 40, 32), (26, 52, 40), (34, 64, 50)),
reveal: None,
},
}
}
pub fn dark_theme_catalog() -> Vec<CardThemeDefinition> {
vec![
search_dark_theme(),
auto_drive_dark_theme(),
agent_read_only_dark_theme(),
browser_dark_theme(),
agent_write_dark_theme(),
]
}
pub fn light_theme_catalog() -> Vec<CardThemeDefinition> {
vec![
search_light_theme(),
auto_drive_light_theme(),
agent_read_only_light_theme(),
browser_light_theme(),
agent_write_light_theme(),
]
}
pub fn theme_catalog() -> Vec<CardThemeDefinition> {
let mut themes = dark_theme_catalog();
themes.extend(light_theme_catalog());
themes
}
pub fn auto_drive_theme_catalog() -> Vec<CardThemeDefinition> {
vec![auto_drive_dark_theme(), auto_drive_light_theme()]
}
pub fn agent_read_only_dark_theme() -> CardThemeDefinition {
agent_green_dark_theme()
}
pub fn agent_read_only_light_theme() -> CardThemeDefinition {
agent_green_light_theme()
}
pub fn agent_write_dark_theme() -> CardThemeDefinition {
agent_orange_dark_theme()
}
pub fn agent_write_light_theme() -> CardThemeDefinition {
agent_orange_light_theme()
}
+121 -80
View File
@@ -76,6 +76,8 @@ mod terminal;
mod tools;
mod browser_sessions;
mod agent_runs;
mod web_search_sessions;
mod auto_drive_cards;
pub(crate) mod tool_cards;
mod running_tools;
#[cfg(any(test, feature = "test-helpers"))]
@@ -450,6 +452,7 @@ use crate::history_cell::PatchEventType;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::PlanUpdateCell;
use crate::history_cell::DiffCell;
use crate::history_cell::{AutoDriveActionKind, AutoDriveStatus};
use crate::history::state::PatchEventType as HistoryPatchEventType;
use crate::history::state::{
AssistantMessageState,
@@ -896,6 +899,7 @@ pub(crate) struct ChatWidget<'a> {
next_ghost_snapshot_id: u64,
pending_snapshot_dispatches: VecDeque<PendingSnapshotDispatch>,
auto_drive_card_sequence: u64,
auto_drive_variant: AutoDriveVariant,
auto_state: AutoDriveController,
auto_goal_escape_state: AutoGoalEscState,
@@ -1872,6 +1876,50 @@ impl ChatWidget<'_> {
self.background_before_next_output_ticket_internal()
}
fn auto_card_next_order_key(&mut self) -> OrderKey {
let ticket = self.make_background_tail_ticket();
let meta = ticket.next_order();
self.provider_order_key_from_order_meta(&meta)
}
fn auto_card_start(&mut self, goal: Option<String>) {
let order_key = self.auto_card_next_order_key();
auto_drive_cards::start_session(self, order_key, goal);
}
fn auto_card_add_action(&mut self, message: String, kind: AutoDriveActionKind) {
let order_key = self.auto_card_next_order_key();
let had_tracker = self.tools_state.auto_drive_tracker.is_some();
auto_drive_cards::record_action(self, order_key, message.clone(), kind);
if !had_tracker {
self.push_background_tail(message);
}
}
fn auto_card_set_status(&mut self, status: AutoDriveStatus) {
if self.tools_state.auto_drive_tracker.is_some() {
let order_key = self.auto_card_next_order_key();
auto_drive_cards::set_status(self, order_key, status);
}
}
fn auto_card_finalize(
&mut self,
message: Option<String>,
status: AutoDriveStatus,
kind: AutoDriveActionKind,
) {
let had_tracker = self.tools_state.auto_drive_tracker.is_some();
let order_key = self.auto_card_next_order_key();
auto_drive_cards::finalize(self, order_key, message.clone(), status, kind);
if !had_tracker {
if let Some(msg) = message {
self.push_background_tail(msg);
}
}
auto_drive_cards::clear(self);
}
fn spawn_conversation_runtime(
&mut self,
config: Config,
@@ -2465,7 +2513,7 @@ impl ChatWidget<'_> {
fn maybe_hide_spinner(&mut self) {
let any_tools_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty();
|| !self.tools_state.web_search_sessions.is_empty();
let any_streaming = self.stream.is_write_cycle_active();
let any_agents_active = self.agents_are_actively_running();
let any_tasks_active = !self.active_task_ids.is_empty();
@@ -3703,7 +3751,7 @@ impl ChatWidget<'_> {
let bottom_running = self.bottom_pane.is_task_running();
let exec_related_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty()
|| !self.tools_state.web_search_sessions.is_empty()
|| !self.tools_state.running_wait_tools.is_empty()
|| !self.tools_state.running_kill_tools.is_empty();
@@ -3989,6 +4037,7 @@ impl ChatWidget<'_> {
active_ghost_snapshot: None,
next_ghost_snapshot_id: 0,
pending_snapshot_dispatches: VecDeque::new(),
auto_drive_card_sequence: 0,
auto_drive_variant,
auto_state: AutoDriveController::default(),
auto_goal_escape_state: AutoGoalEscState::Inactive,
@@ -4180,7 +4229,9 @@ impl ChatWidget<'_> {
canceled_exec_call_ids: HashSet::new(),
tools_state: ToolState {
running_custom_tools: HashMap::new(),
running_web_search: HashMap::new(),
web_search_sessions: HashMap::new(),
web_search_by_call: HashMap::new(),
web_search_by_order: HashMap::new(),
running_wait_tools: HashMap::new(),
running_kill_tools: HashMap::new(),
browser_sessions: HashMap::new(),
@@ -4193,6 +4244,7 @@ impl ChatWidget<'_> {
agent_run_by_batch: HashMap::new(),
agent_run_by_agent: HashMap::new(),
agent_last_key: None,
auto_drive_tracker: None,
},
live_builder: RowBuilder::new(usize::MAX),
header_wave: {
@@ -4297,6 +4349,7 @@ impl ChatWidget<'_> {
active_ghost_snapshot: None,
next_ghost_snapshot_id: 0,
pending_snapshot_dispatches: VecDeque::new(),
auto_drive_card_sequence: 0,
auto_drive_variant,
auto_state: AutoDriveController::default(),
auto_goal_escape_state: AutoGoalEscState::Inactive,
@@ -7246,6 +7299,7 @@ impl ChatWidget<'_> {
}
// Merge adjacent tool cells with the same header (e.g., successive Web Search blocks)
#[allow(dead_code)]
fn history_maybe_merge_tool_with_previous(&mut self, idx: usize) {
if idx == 0 || idx >= self.history_cells.len() {
return;
@@ -9375,7 +9429,7 @@ impl ChatWidget<'_> {
ev.call_id,
event.event_seq
);
tools::web_search_begin(self, ev.call_id, ev.query, ok)
tools::web_search_begin(self, ev.call_id, ev.query, event.order.as_ref(), ok)
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// If the user requested an interrupt, ignore late final answers.
@@ -9477,7 +9531,7 @@ impl ChatWidget<'_> {
self.next_internal_key()
}
};
tools::web_search_complete(self, ev.call_id, ev.query, ok)
tools::web_search_complete(self, ev.call_id, ev.query, event.order.as_ref(), ok)
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
tracing::debug!("AgentMessageDelta: {:?}", delta);
@@ -9667,61 +9721,17 @@ impl ChatWidget<'_> {
// Convert any lingering running exec/tool cells to completed so the UI doesn't hang
self.finalize_all_running_due_to_answer();
// Mark any running web searches as completed
if !self.tools_state.running_web_search.is_empty() {
// Replace each running web search cell in-place with a completed one
// Iterate over a snapshot of keys to avoid borrow issues
let entries: Vec<(ToolCallId, (usize, Option<String>))> = self
.tools_state
.running_web_search
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (call_id, (idx, query_opt)) in entries {
// Try exact index; if out of bounds or shifted, search nearby from end
let mut target_idx = None;
if idx < self.history_cells.len() {
// Verify this index is still a running web search cell
let is_ws = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some_and(|rt| rt.has_title("Web Search..."));
if is_ws {
target_idx = Some(idx);
}
}
if target_idx.is_none() {
for i in (0..self.history_cells.len()).rev() {
if let Some(rt) = self.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>(
) {
if rt.has_title("Web Search...") {
target_idx = Some(i);
break;
}
}
}
}
if let Some(i) = target_idx {
if let Some(rt) = self.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
let completed = rt.finalize_web_search(true, query_opt);
self.history_replace_at(i, Box::new(completed));
}
}
// Remove from running set
self.tools_state.running_web_search.remove(&call_id);
}
}
web_search_sessions::finalize_all_failed(
self,
"Search cancelled before completion",
);
// Now that streaming is complete, flush any queued interrupts
self.flush_interrupt_queue();
// Only drop the working status if nothing is actually running.
let any_tools_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty();
|| !self.tools_state.web_search_sessions.is_empty();
let any_streaming = self.stream.is_write_cycle_active();
let any_agents_active = self.agents_are_actively_running();
let any_tasks_active = !self.active_task_ids.is_empty();
@@ -10859,7 +10869,7 @@ impl ChatWidget<'_> {
if all_agents_terminal {
let any_tools_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty();
|| !self.tools_state.web_search_sessions.is_empty();
let any_streaming = self.stream.is_write_cycle_active();
if !(any_tools_running || any_streaming) {
self.bottom_pane.set_task_running(false);
@@ -12957,7 +12967,7 @@ fi\n\
return true;
}
if !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty()
|| !self.tools_state.web_search_sessions.is_empty()
|| !self.tools_state.running_wait_tools.is_empty()
|| !self.tools_state.running_kill_tools.is_empty()
{
@@ -13551,9 +13561,10 @@ fi\n\
})
.collect::<Vec<_>>()
.join(", ");
self.push_background_tail(format!(
"Auto Drive enabled write mode for agent prompt(s): {joined}"
));
self.auto_card_add_action(
format!("Auto Drive enabled write mode for agent prompt(s): {joined}"),
AutoDriveActionKind::Info,
);
}
if !matches!(status, AutoCoordinatorStatus::Failed) {
@@ -13605,7 +13616,10 @@ Have we met every part of this goal and is there no further work to do?"#
self.submit_op(Op::SetNextTextFormat { format: tf.clone() });
self.next_cli_text_format = Some(tf);
self.auto_state.pending_stop_message = Some(message);
self.push_background_tail("Auto Drive Diagnostics: Validating progress".to_string());
self.auto_card_add_action(
"Auto Drive Diagnostics: Validating progress".to_string(),
AutoDriveActionKind::Info,
);
self.schedule_auto_cli_prompt(prompt_text);
self.auto_submit_prompt();
return;
@@ -13723,22 +13737,41 @@ Have we met every part of this goal and is there no further work to do?"#
AutoControllerEffect::LaunchStarted { goal } => {
self.bottom_pane.set_task_running(false);
self.bottom_pane.update_status_text("Auto Drive".to_string());
self.push_background_tail(format!("Auto Drive started: {goal}"));
self.auto_card_start(Some(goal.clone()));
self.auto_card_add_action(
format!("Auto Drive started: {goal}"),
AutoDriveActionKind::Info,
);
self.auto_card_set_status(AutoDriveStatus::Running);
}
AutoControllerEffect::LaunchFailed { goal, error } => {
self.push_background_tail(format!(
let message = format!(
"Coordinator failed to start for goal '{goal}': {error}"
));
);
self.auto_card_finalize(
Some(message),
AutoDriveStatus::Failed,
AutoDriveActionKind::Error,
);
}
AutoControllerEffect::StopCompleted { summary, message } => {
if let Some(handle) = self.auto_handle.take() {
handle.cancel();
let _ = handle.send(AutoCoordinatorCommand::Stop);
}
if let Some(msg) = message.or_else(|| summary.message.clone()) {
let final_message = message.or_else(|| summary.message.clone());
if let Some(msg) = final_message.clone() {
if !msg.trim().is_empty() {
self.push_background_tail(msg);
self.auto_card_finalize(
Some(msg),
AutoDriveStatus::Stopped,
AutoDriveActionKind::Info,
);
} else {
self.auto_card_finalize(None, AutoDriveStatus::Stopped, AutoDriveActionKind::Info);
}
} else {
self.auto_card_finalize(None, AutoDriveStatus::Stopped, AutoDriveActionKind::Info);
}
self.auto_turn_review_state = None;
if ENABLE_WARP_STRIPES {
@@ -13757,9 +13790,11 @@ Have we met every part of this goal and is there no further work to do?"#
self.bottom_pane.set_standard_terminal_hint(Some(
"Press Esc again to exit Auto Drive".to_string(),
));
self.push_background_tail(format!(
let message = format!(
"Auto Drive will retry automatically in {human_delay} (attempt {attempt}/{AUTO_RESTART_MAX_ATTEMPTS}). Last error: {reason}"
));
);
self.auto_card_add_action(message, AutoDriveActionKind::Warning);
self.auto_card_set_status(AutoDriveStatus::Paused);
}
AutoControllerEffect::ScheduleRestart {
token,
@@ -13784,7 +13819,7 @@ Have we met every part of this goal and is there no further work to do?"#
let has_activity = running
|| !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty()
|| !self.tools_state.web_search_sessions.is_empty()
|| !self.tools_state.running_wait_tools.is_empty()
|| !self.tools_state.running_kill_tools.is_empty()
|| self.stream.is_write_cycle_active()
@@ -13855,8 +13890,9 @@ Have we met every part of this goal and is there no further work to do?"#
}
let Some(goal) = self.auto_state.goal.clone() else {
self.push_background_tail(
self.auto_card_add_action(
"Auto Drive restart skipped because the goal is no longer available.".to_string(),
AutoDriveActionKind::Warning,
);
self.auto_state.pending_restart = None;
self.auto_state.on_recovery_attempt();
@@ -13877,16 +13913,18 @@ Have we met every part of this goal and is there no further work to do?"#
self.auto_state.on_recovery_attempt();
self.auto_state.restart_token = token;
if restart.reason.is_empty() {
self.push_background_tail(format!(
let resume_message = if restart.reason.is_empty() {
format!(
"Auto Drive resuming automatically (attempt {attempt}/{AUTO_RESTART_MAX_ATTEMPTS})."
));
)
} else {
self.push_background_tail(format!(
format!(
"Auto Drive resuming automatically (attempt {attempt}/{AUTO_RESTART_MAX_ATTEMPTS}); previous error: {}",
restart.reason
));
}
)
};
self.auto_card_add_action(resume_message, AutoDriveActionKind::Info);
self.auto_card_set_status(AutoDriveStatus::Running);
self.auto_launch_with_goal(
goal,
@@ -17811,7 +17849,7 @@ Have we met every part of this goal and is there no further work to do?"#
}
let exec_related_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty()
|| !self.tools_state.web_search_sessions.is_empty()
|| !self.tools_state.running_wait_tools.is_empty()
|| !self.tools_state.running_kill_tools.is_empty();
if self.bottom_pane.is_task_running() || exec_related_running {
@@ -18043,7 +18081,7 @@ Have we met every part of this goal and is there no further work to do?"#
self.terminal_is_running()
|| !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty()
|| !self.tools_state.web_search_sessions.is_empty()
|| !self.tools_state.running_wait_tools.is_empty()
|| !self.tools_state.running_kill_tools.is_empty()
}
@@ -18053,7 +18091,7 @@ Have we met every part of this goal and is there no further work to do?"#
|| self.terminal_is_running()
|| !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty()
|| !self.tools_state.web_search_sessions.is_empty()
|| !self.tools_state.running_wait_tools.is_empty()
|| !self.tools_state.running_kill_tools.is_empty()
}
@@ -18144,7 +18182,7 @@ Have we met every part of this goal and is there no further work to do?"#
pub(crate) fn mark_task_idle_after_denied(&mut self) {
let any_tools_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty();
|| !self.tools_state.web_search_sessions.is_empty();
let any_streaming = self.stream.is_write_cycle_active();
let any_agents_active = self.agents_are_actively_running();
let any_tasks_active = !self.active_task_ids.is_empty();
@@ -30142,7 +30180,9 @@ impl RunningToolEntry {
#[derive(Default)]
struct ToolState {
running_custom_tools: HashMap<ToolCallId, RunningToolEntry>,
running_web_search: HashMap<ToolCallId, (usize, Option<String>)>,
web_search_sessions: HashMap<String, web_search_sessions::WebSearchTracker>,
web_search_by_call: HashMap<String, String>,
web_search_by_order: HashMap<u64, String>,
running_wait_tools: HashMap<ToolCallId, ExecCallId>,
running_kill_tools: HashMap<ToolCallId, ExecCallId>,
browser_sessions: HashMap<String, browser_sessions::BrowserSessionTracker>,
@@ -30155,6 +30195,7 @@ struct ToolState {
agent_run_by_batch: HashMap<String, String>,
agent_run_by_agent: HashMap<String, String>,
agent_last_key: Option<String>,
auto_drive_tracker: Option<auto_drive_cards::AutoDriveTracker>,
}
#[derive(Default)]
struct StreamState {
@@ -0,0 +1,118 @@
use super::{tool_cards, ChatWidget, OrderKey};
use super::tool_cards::ToolCardSlot;
use crate::history_cell::{AutoDriveActionKind, AutoDriveCardCell, AutoDriveStatus};
pub(super) struct AutoDriveTracker {
pub slot: ToolCardSlot,
pub cell: AutoDriveCardCell,
pub session_id: u64,
pub request_ordinal: u64,
pub active: bool,
}
impl AutoDriveTracker {
fn new(order_key: OrderKey, session_id: u64, request_ordinal: u64, goal: Option<String>) -> Self {
Self {
slot: ToolCardSlot::new(order_key),
cell: AutoDriveCardCell::new(goal),
session_id,
request_ordinal,
active: true,
}
}
fn card_key(&self) -> String {
format!("auto_drive:{}", self.session_id)
}
fn assign_key(&mut self) {
let signature = Some(self.card_key());
self.cell.set_signature(signature.clone());
tool_cards::assign_tool_card_key(&mut self.slot, &mut self.cell, signature.clone());
if let Some(sig) = signature {
self.slot.set_signature(Some(sig));
}
}
fn ensure_insert(&mut self, chat: &mut ChatWidget<'_>) {
self.assign_key();
tool_cards::ensure_tool_card::<AutoDriveCardCell>(chat, &mut self.slot, &self.cell);
}
fn replace(&mut self, chat: &mut ChatWidget<'_>) {
tool_cards::replace_tool_card::<AutoDriveCardCell>(chat, &mut self.slot, &self.cell);
}
}
pub(super) fn start_session(
chat: &mut ChatWidget<'_>,
order_key: OrderKey,
goal: Option<String>,
) {
let request_ordinal = order_key.req;
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
if tracker.request_ordinal == request_ordinal {
tracker.slot.set_order_key(order_key);
tracker.replace(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
return;
}
}
let session_id = chat.auto_drive_card_sequence;
chat.auto_drive_card_sequence = chat.auto_drive_card_sequence.wrapping_add(1);
let mut tracker = AutoDriveTracker::new(order_key, session_id, request_ordinal, goal);
tracker.ensure_insert(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
}
pub(super) fn record_action(
chat: &mut ChatWidget<'_>,
order_key: OrderKey,
text: impl Into<String>,
kind: AutoDriveActionKind,
) {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.request_ordinal = order_key.req;
tracker.slot.set_order_key(order_key);
tracker.cell.push_action(text, kind);
tracker.replace(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
}
}
pub(super) fn set_status(chat: &mut ChatWidget<'_>, order_key: OrderKey, status: AutoDriveStatus) {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.request_ordinal = order_key.req;
tracker.slot.set_order_key(order_key);
tracker.cell.set_status(status);
tracker.replace(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
}
}
pub(super) fn finalize(
chat: &mut ChatWidget<'_>,
order_key: OrderKey,
message: Option<String>,
status: AutoDriveStatus,
action_kind: AutoDriveActionKind,
) {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.request_ordinal = order_key.req;
tracker.slot.set_order_key(order_key);
if let Some(msg) = message {
tracker.cell.push_action(msg, action_kind);
}
tracker.cell.set_status(status);
tracker.replace(chat);
tracker.active = false;
chat.tools_state.auto_drive_tracker = Some(tracker);
}
}
pub(super) fn clear(chat: &mut ChatWidget<'_>) {
chat.tools_state.auto_drive_tracker = None;
}
+4 -88
View File
@@ -1,6 +1,6 @@
//! Exec and tool call lifecycle helpers for `ChatWidget`.
use super::{running_tools, ChatWidget};
use super::{running_tools, web_search_sessions, ChatWidget};
use crate::app_event::AppEvent;
use crate::height_manager::HeightEvent;
use crate::history::state::{
@@ -484,49 +484,7 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
chat.request_redraw();
}
if !chat.tools_state.running_web_search.is_empty() {
let entries: Vec<(super::ToolCallId, (usize, Option<String>))> = chat
.tools_state
.running_web_search
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (call_id, (idx, query_opt)) in entries {
let mut target_idx = None;
if idx < chat.history_cells.len() {
let is_ws = chat.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some_and(|rt| rt.has_title("Web Search..."));
if is_ws {
target_idx = Some(idx);
}
}
if target_idx.is_none() {
for i in (0..chat.history_cells.len()).rev() {
if let Some(rt) = chat.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
if rt.has_title("Web Search...") {
target_idx = Some(i);
break;
}
}
}
}
if let Some(i) = target_idx {
if let Some(rt) = chat.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
let completed = rt.finalize_web_search(false, query_opt);
chat.history_replace_at(i, Box::new(completed));
}
}
chat.tools_state.running_web_search.remove(&call_id);
}
}
web_search_sessions::finalize_all_failed(chat, "Search cancelled by user.");
if !chat.tools_state.running_wait_tools.is_empty() {
chat.tools_state.running_wait_tools.clear();
@@ -586,49 +544,7 @@ pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
crate::chatwidget::running_tools::finalize_all_due_to_answer(chat);
if !chat.tools_state.running_web_search.is_empty() {
let entries: Vec<(super::ToolCallId, (usize, Option<String>))> = chat
.tools_state
.running_web_search
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (call_id, (idx, query_opt)) in entries {
let mut target_idx = None;
if idx < chat.history_cells.len() {
let is_ws = chat.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some_and(|rt| rt.has_title("Web Search..."));
if is_ws {
target_idx = Some(idx);
}
}
if target_idx.is_none() {
for i in (0..chat.history_cells.len()).rev() {
if let Some(rt) = chat.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
if rt.has_title("Web Search...") {
target_idx = Some(i);
break;
}
}
}
}
if let Some(i) = target_idx {
if let Some(rt) = chat.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
let completed = rt.finalize_web_search(true, query_opt);
chat.history_replace_at(i, Box::new(completed));
}
}
chat.tools_state.running_web_search.remove(&call_id);
}
}
web_search_sessions::finalize_all_completed(chat, "Search finished");
chat.maybe_hide_spinner();
chat.refresh_auto_drive_visuals();
@@ -1110,7 +1026,7 @@ pub(super) fn handle_exec_begin_now(
.or_else(|| chat.history_cell_ids.get(idx).and_then(|slot| *slot));
running.history_id = history_id;
}
if !chat.tools_state.running_web_search.is_empty() {
if !chat.tools_state.web_search_sessions.is_empty() {
chat.bottom_pane.update_status_text("Search".to_string());
} else {
let preview = chat
+11 -33
View File
@@ -1,20 +1,18 @@
use super::{history_cell, ChatWidget, RunningToolEntry, ToolCallId};
use crate::history::state::ArgumentValue;
use crate::history_cell::RunningToolCallCell;
use std::collections::HashMap;
use std::mem;
pub(super) fn rehydrate(chat: &mut ChatWidget<'_>) {
let prev_custom = chat.tools_state.running_custom_tools.len();
let prev_web = chat.tools_state.running_web_search.len();
let prev_wait = chat.tools_state.running_wait_tools.len();
let prev_kill = chat.tools_state.running_kill_tools.len();
let old_state = mem::take(&mut chat.tools_state);
let mut new_state = super::ToolState::default();
chat.history_debug(format!(
"running_tools.rehydrate.begin prev_custom={} prev_web={} prev_wait={} prev_kill={}",
prev_custom, prev_web, prev_wait, prev_kill
"running_tools.rehydrate.begin prev_custom={} prev_wait={} prev_kill={}",
prev_custom, prev_wait, prev_kill
));
for (idx, cell) in chat.history_cells.iter().enumerate() {
@@ -38,34 +36,6 @@ pub(super) fn rehydrate(chat: &mut ChatWidget<'_>) {
let tool_key = ToolCallId(call_id.clone());
let history_id = chat.history_cell_ids.get(idx).and_then(|slot| *slot);
if running_cell.has_title("Web Search...") {
let query = state.arguments.iter().find_map(|arg| {
if arg.name == "query" {
if let ArgumentValue::Text(text) = &arg.value {
Some(text.clone())
} else {
None
}
} else {
None
}
});
new_state
.running_web_search
.insert(tool_key, (idx, query.clone()));
chat.history_debug(format!(
"running_tools.rehydrate.web call_id={} idx={} history_id={:?} order=({}, {}, {}) query_present={}",
call_id,
idx,
history_id,
order_key.req,
order_key.out,
order_key.seq,
query.is_some()
));
continue;
}
new_state
.running_custom_tools
.insert(tool_key, RunningToolEntry::new(order_key, idx).with_history_id(history_id));
@@ -83,6 +53,9 @@ pub(super) fn rehydrate(chat: &mut ChatWidget<'_>) {
let super::ToolState {
running_wait_tools,
running_kill_tools,
web_search_sessions,
web_search_by_call,
web_search_by_order,
browser_sessions,
browser_session_by_call,
browser_session_by_order,
@@ -93,10 +66,14 @@ pub(super) fn rehydrate(chat: &mut ChatWidget<'_>) {
agent_run_by_batch,
agent_run_by_agent,
agent_last_key,
auto_drive_tracker,
..
} = old_state;
new_state.running_wait_tools = running_wait_tools;
new_state.running_kill_tools = running_kill_tools;
new_state.web_search_sessions = web_search_sessions;
new_state.web_search_by_call = web_search_by_call;
new_state.web_search_by_order = web_search_by_order;
new_state.browser_sessions = browser_sessions;
new_state.browser_session_by_call = browser_session_by_call;
new_state.browser_session_by_order = browser_session_by_order;
@@ -107,12 +84,13 @@ pub(super) fn rehydrate(chat: &mut ChatWidget<'_>) {
new_state.agent_run_by_batch = agent_run_by_batch;
new_state.agent_run_by_agent = agent_run_by_agent;
new_state.agent_last_key = agent_last_key;
new_state.auto_drive_tracker = auto_drive_tracker;
chat.tools_state = new_state;
chat.history_debug(format!(
"running_tools.rehydrate.end custom={} web={} wait={} kill={}",
chat.tools_state.running_custom_tools.len(),
chat.tools_state.running_web_search.len(),
chat.tools_state.web_search_sessions.len(),
chat.tools_state.running_wait_tools.len(),
chat.tools_state.running_kill_tools.len()
));
+19 -69
View File
@@ -1,82 +1,32 @@
//! Tool (Web Search, MCP) lifecycle handlers extracted from ChatWidget.
use super::{running_tools, ChatWidget, OrderKey};
use super::{running_tools, web_search_sessions, ChatWidget, OrderKey};
use crate::history_cell;
use code_core::protocol::{McpToolCallBeginEvent, McpToolCallEndEvent};
use code_core::protocol::{McpToolCallBeginEvent, McpToolCallEndEvent, OrderMeta};
pub(super) fn web_search_begin(chat: &mut ChatWidget<'_>, call_id: String, query: Option<String>, key: OrderKey) {
pub(super) fn web_search_begin(
chat: &mut ChatWidget<'_>,
call_id: String,
query: Option<String>,
order: Option<&OrderMeta>,
key: OrderKey,
) {
for cell in &chat.history_cells { cell.trigger_fade(); }
chat.finalize_active_stream();
chat.flush_interrupt_queue();
let mut cell = history_cell::new_running_web_search(query.clone());
cell.state_mut().call_id = Some(call_id.clone());
let idx = chat.history_insert_with_key_global(Box::new(cell), key);
tracing::info!("[order] WebSearchBegin call_id={} idx={}", call_id, idx);
chat.tools_state
.running_web_search
.insert(super::ToolCallId(call_id), (idx, query));
chat.bottom_pane.update_status_text("Search".to_string());
chat.mark_needs_redraw();
web_search_sessions::handle_begin(chat, order, call_id, query, key);
}
pub(super) fn web_search_complete(chat: &mut ChatWidget<'_>, call_id: String, query: Option<String>, key: OrderKey) {
let call_key = super::ToolCallId(call_id.clone());
let entry_removed = chat.tools_state.running_web_search.remove(&call_key);
let entry_query = entry_removed.as_ref().and_then(|(_, q)| q.clone());
let target_idx = entry_removed
.as_ref()
.and_then(|(idx, _)| {
if *idx < chat.history_cells.len() {
Some(*idx)
} else {
None
}
})
.filter(|idx| {
chat.history_cells[*idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some_and(|rt| rt.has_title("Web Search..."))
})
.or_else(|| running_tools::find_by_call_id(chat, &call_id));
if let Some(i) = target_idx {
if let Some(rt) = chat.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
let final_query = query.or(entry_query);
let mut completed = rt.finalize_web_search(true, final_query);
completed.state_mut().call_id = Some(call_id.clone());
chat.history_replace_at(i, Box::new(completed));
chat.history_maybe_merge_tool_with_previous(i);
tracing::info!("[order] WebSearchEnd replace at idx={}", i);
chat.history_debug(format!(
"web_search_end.in_place call_id={} idx={} order=({}, {}, {})",
call_id,
i,
key.req,
key.out,
key.seq
));
}
} else {
chat.history_debug(format!(
"web_search_end.fallback_insert call_id={} order=({}, {}, {})",
call_id,
key.req,
key.out,
key.seq
));
running_tools::collapse_spinner(chat, &call_id);
let seed = history_cell::new_running_web_search(entry_query.clone());
let mut completed = seed.finalize_web_search(true, query.or(entry_query));
completed.state_mut().call_id = Some(call_id.clone());
let _ = chat.history_insert_with_key_global(Box::new(completed), key);
}
chat.bottom_pane.update_status_text("responding".to_string());
chat.maybe_hide_spinner();
pub(super) fn web_search_complete(
chat: &mut ChatWidget<'_>,
call_id: String,
query: Option<String>,
order: Option<&OrderMeta>,
key: OrderKey,
) {
running_tools::collapse_spinner(chat, &call_id);
web_search_sessions::handle_complete(chat, order, call_id, query, key);
}
pub(super) fn mcp_begin(chat: &mut ChatWidget<'_>, ev: McpToolCallBeginEvent, key: OrderKey) {
@@ -0,0 +1,212 @@
use super::{tool_cards, ChatWidget, OrderKey};
use super::tool_cards::ToolCardSlot;
use crate::history_cell::{WebSearchSessionCell, WebSearchStatus};
use code_core::protocol::OrderMeta;
use std::collections::HashSet;
use std::mem;
use std::time::Instant;
pub(super) struct WebSearchTracker {
pub slot: ToolCardSlot,
pub cell: WebSearchSessionCell,
pub request_ordinal: u64,
pub started_at: Instant,
pub active_calls: HashSet<String>,
}
impl WebSearchTracker {
fn new(order_key: OrderKey, request_ordinal: u64) -> Self {
Self {
slot: ToolCardSlot::new(order_key),
cell: WebSearchSessionCell::new(),
request_ordinal,
started_at: Instant::now(),
active_calls: HashSet::new(),
}
}
fn card_key(&self) -> String {
web_search_key(self.request_ordinal)
}
fn assign_key(&mut self) {
let key = self.card_key();
self.cell.set_signature(Some(key.clone()));
tool_cards::assign_tool_card_key(&mut self.slot, &mut self.cell, Some(key.clone()));
self.slot.set_signature(Some(key));
}
fn ensure_insert(&mut self, chat: &mut ChatWidget<'_>) {
self.assign_key();
tool_cards::ensure_tool_card::<WebSearchSessionCell>(chat, &mut self.slot, &self.cell);
}
fn replace(&mut self, chat: &mut ChatWidget<'_>) {
tool_cards::replace_tool_card::<WebSearchSessionCell>(chat, &mut self.slot, &self.cell);
}
}
pub(super) fn handle_begin(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: String,
query: Option<String>,
order_key: OrderKey,
) {
let request_ordinal = resolve_request_ordinal(order, order_key.req);
let key = web_search_key(request_ordinal);
let (mut tracker, existed) = match chat.tools_state.web_search_sessions.remove(&key) {
Some(tracker) => (tracker, true),
None => (WebSearchTracker::new(order_key, request_ordinal), false),
};
tracker.slot.set_order_key(order_key);
if !existed {
tracker.cell.ensure_started_message();
}
if let Some(ref q) = query {
if tracker.cell.set_query(Some(q.clone())) {
tracker
.cell
.record_info(tracker.started_at.elapsed(), format!("Query: \"{}\"", q));
}
}
tracker.cell.set_status(WebSearchStatus::Running);
tracker.active_calls.insert(call_id.clone());
if existed {
tracker.replace(chat);
} else {
tracker.ensure_insert(chat);
}
chat
.tools_state
.web_search_sessions
.insert(key.clone(), tracker);
chat
.tools_state
.web_search_by_call
.insert(call_id, key.clone());
chat
.tools_state
.web_search_by_order
.insert(request_ordinal, key);
chat.bottom_pane.update_status_text("Search".to_string());
chat.request_redraw();
}
pub(super) fn handle_complete(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: String,
query: Option<String>,
order_key: OrderKey,
) {
let request_ordinal = resolve_request_ordinal(order, order_key.req);
let fallback_key = web_search_key(request_ordinal);
let key = chat
.tools_state
.web_search_by_call
.remove(&call_id)
.or_else(|| order.and_then(|meta| chat.tools_state.web_search_by_order.get(&meta.request_ordinal).cloned()))
.unwrap_or(fallback_key.clone());
let (mut tracker, existed) = match chat.tools_state.web_search_sessions.remove(&key) {
Some(tracker) => (tracker, true),
None => (WebSearchTracker::new(order_key, request_ordinal), false),
};
tracker.slot.set_order_key(order_key);
tracker.request_ordinal = request_ordinal;
tracker.active_calls.remove(&call_id);
if let Some(ref q) = query {
if tracker.cell.set_query(Some(q.clone())) {
tracker
.cell
.record_info(tracker.started_at.elapsed(), format!("Query: \"{}\"", q));
}
}
let elapsed = tracker.started_at.elapsed();
tracker
.cell
.record_success(elapsed, "Results ready".to_string());
tracker.cell.set_duration(Some(elapsed));
tracker.cell.set_status(WebSearchStatus::Completed);
if existed {
tracker.replace(chat);
} else {
tracker.ensure_insert(chat);
}
if !tracker.active_calls.is_empty() {
chat
.tools_state
.web_search_sessions
.insert(key.clone(), tracker);
chat
.tools_state
.web_search_by_order
.insert(request_ordinal, key);
} else {
chat
.tools_state
.web_search_by_order
.remove(&request_ordinal);
}
chat.bottom_pane.update_status_text("responding".to_string());
chat.maybe_hide_spinner();
}
pub(super) fn finalize_all_failed(chat: &mut ChatWidget<'_>, message: &str) {
if chat.tools_state.web_search_sessions.is_empty() {
return;
}
let mut trackers = mem::take(&mut chat.tools_state.web_search_sessions);
chat.tools_state.web_search_by_call.clear();
chat.tools_state.web_search_by_order.clear();
for (_, mut tracker) in trackers.drain() {
tracker.slot.set_order_key(chat.next_internal_key());
let elapsed = tracker.started_at.elapsed();
tracker
.cell
.record_error(elapsed, message.to_string());
tracker.cell.set_status(WebSearchStatus::Failed);
tracker.replace(chat);
}
}
pub(super) fn finalize_all_completed(chat: &mut ChatWidget<'_>, message: &str) {
if chat.tools_state.web_search_sessions.is_empty() {
return;
}
let mut trackers = mem::take(&mut chat.tools_state.web_search_sessions);
chat.tools_state.web_search_by_call.clear();
chat.tools_state.web_search_by_order.clear();
for (_, mut tracker) in trackers.drain() {
tracker.slot.set_order_key(chat.next_internal_key());
let elapsed = tracker.started_at.elapsed();
tracker
.cell
.record_success(elapsed, message.to_string());
tracker.cell.set_status(WebSearchStatus::Completed);
tracker.replace(chat);
}
}
fn resolve_request_ordinal(order: Option<&OrderMeta>, fallback: u64) -> u64 {
order
.map(|meta| meta.request_ordinal)
.unwrap_or(fallback)
}
fn web_search_key(request_ordinal: u64) -> String {
format!("web_search:req:{}", request_ordinal)
}
+296
View File
@@ -0,0 +1,296 @@
use crate::card_theme::{GradientSpec, RevealVariant};
use crate::colors;
use crate::glitch_animation::{gradient_multi, mix_rgb};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Color;
#[derive(Clone, Copy, Debug)]
pub struct RevealRender {
pub progress: f32,
pub variant: RevealVariant,
pub intro_light: bool,
}
pub struct GradientBackground;
impl GradientBackground {
pub fn render(
buf: &mut Buffer,
area: Rect,
gradient: &GradientSpec,
fg: Color,
reveal: Option<RevealRender>,
) {
if area.width == 0 || area.height == 0 {
return;
}
if let Some(reveal) = reveal {
Self::render_reveal(buf, area, gradient, fg, reveal);
} else {
Self::render_static(buf, area, gradient, fg);
}
}
pub fn render_static(buf: &mut Buffer, area: Rect, gradient: &GradientSpec, fg: Color) {
for row in 0..area.height {
for col in 0..area.width {
let x = col as f32 / area.width.max(1) as f32;
let t = (x + gradient.bias).clamp(0.0, 1.0);
let color = colors::mix_toward(gradient.left, gradient.right, t);
let cell = &mut buf[(area.x + col, area.y + row)];
cell.set_symbol(" ");
cell.set_fg(fg);
cell.set_bg(color);
}
}
}
pub fn render_reveal(
buf: &mut Buffer,
area: Rect,
gradient: &GradientSpec,
fg: Color,
reveal: RevealRender,
) {
let clamped_progress = reveal.progress.clamp(0.0, 1.0);
const LIGHT_REVEAL_HOLD: f32 = 0.06;
const LIGHT_REVEAL_FADE_END: f32 = 0.32;
if reveal.intro_light && clamped_progress < LIGHT_REVEAL_HOLD {
let warm_fg = mix_rgb(fg, Color::Rgb(255, 255, 255), 0.55);
Self::render_static(buf, area, &GradientSpec {
left: Color::Rgb(255, 255, 255),
right: Color::Rgb(255, 255, 255),
bias: 0.0,
}, warm_fg);
return;
}
if clamped_progress >= 0.999 {
Self::render_static(buf, area, gradient, fg);
return;
}
let width = area.width.max(1);
let height = area.height.max(1);
let width_f = width as f32;
let height_f = height as f32;
for row in 0..height {
for col in 0..width {
let x_norm = (col as f32 + 0.5) / width_f;
let y_norm = (row as f32 + 0.5) / height_f;
let gradient_pos = ((col as f32 / width_f) + gradient.bias).clamp(0.0, 1.0);
let final_color = colors::mix_toward(gradient.left, gradient.right, gradient_pos);
let coverage = reveal_coverage(
clamped_progress,
x_norm,
y_norm,
col as u16,
row as u16,
reveal.variant,
);
let softened_color = if reveal.intro_light && clamped_progress < LIGHT_REVEAL_FADE_END {
mix_rgb(
Color::Rgb(255, 255, 255),
final_color,
smoothstep(LIGHT_REVEAL_HOLD, LIGHT_REVEAL_FADE_END, clamped_progress),
)
} else {
final_color
};
let mut blend = smoothstep(0.0, 1.0, coverage);
let mut accent = accent_color(
reveal.variant,
clamped_progress,
x_norm,
y_norm,
col as u16,
row as u16,
final_color,
);
if reveal.intro_light {
let fade_factor = ((clamped_progress - LIGHT_REVEAL_HOLD)
/ (LIGHT_REVEAL_FADE_END - LIGHT_REVEAL_HOLD))
.clamp(0.0, 1.0);
let whiten_mix = fade_factor.powf(1.1);
accent = mix_rgb(Color::Rgb(255, 255, 255), accent, whiten_mix);
blend *= fade_factor.max(0.02);
}
let bg = mix_rgb(accent, softened_color, blend);
let text_tint = mix_rgb(fg, accent, (1.0 - blend) * 0.35);
let cell = &mut buf[(area.x + col, area.y + row)];
cell.set_symbol(" ");
cell.set_fg(text_tint);
cell.set_bg(bg);
}
}
}
}
fn reveal_coverage(
progress: f32,
x: f32,
y: f32,
col: u16,
row: u16,
variant: RevealVariant,
) -> f32 {
let base = match variant {
RevealVariant::GlitchSweep => {
let sweep = progress * 1.35 - x - 0.1;
let wave = ((y * 6.0 + progress * 12.0).sin()) * 0.1;
sweep + wave
}
RevealVariant::VertDrift => {
let sweep = progress * 1.28 - x - 0.12;
let sway = ((y * 5.0 + progress * 6.0).cos()) * 0.09;
sweep + sway
}
RevealVariant::DiagonalPulse => {
let diag = x * 0.85 + y * 0.2;
let sweep = progress * 1.4 - diag - 0.1;
let pulse = (((x + y) * std::f32::consts::PI * 2.0) + progress * 7.5).sin() * 0.1;
sweep + pulse
}
RevealVariant::ChromaticScan => {
let sweep = progress * 1.22 - x - 0.18 - (0.5 - y).abs() * 0.22;
let ripple = ((progress * 10.0 + y * 5.0).cos()) * 0.07;
sweep + ripple
}
RevealVariant::SparkleFade => {
let sparkle = hash_noise(col, row);
let flicker = ((progress * 14.0 + (x + y) * 6.0).sin()) * 0.07;
progress * 1.55 - x - 0.22 + sparkle * 0.32 + flicker
}
RevealVariant::RainbowBloom => {
let center = (x - 0.5).abs();
let lead = (progress * 1.4 - center).max(0.0);
let sweep = lead.powf(1.2) - 0.2;
let ripple = ((y * 4.0 + progress * 9.0).sin()) * 0.08;
sweep + ripple
}
RevealVariant::AuroraBridge => {
let arch = (x - 0.5).abs() * 1.4 + (y - 0.5).abs() * 0.6;
let sweep = progress * 1.35 - arch - 0.15;
let shimmer = ((x * 12.0 + progress * 10.0).cos()) * 0.05;
sweep + shimmer
}
RevealVariant::PrismRise => {
let radial = ((x - 0.5).powi(2) + (y - 0.5).powi(2)).sqrt();
let sweep = progress * 1.45 - radial - 0.1;
sweep + ((y * 8.0 + progress * 6.0).sin()) * 0.06
}
RevealVariant::NeonRoad => {
let road = (x - 0.5).abs() * 1.1 + (y - 0.65).abs() * 0.3;
let sweep = (progress * 1.5 - road) - 0.18;
sweep + ((x * 6.0 + progress * 8.0).sin()) * 0.04
}
RevealVariant::HorizonRush => {
let band = (x - 0.5).abs() * 0.9 + (y * 0.45);
let horizon = (progress * 1.32 - band) - 0.12;
horizon + ((y * 7.0 + progress * 5.0).cos()) * 0.05
}
RevealVariant::LightBloom => {
let radial = ((x - 0.5).powi(2) + (y - 0.5).powi(2)).sqrt();
let envelope = (progress * 1.8 - radial * 1.2).max(-0.12);
envelope + ((x + y) * std::f32::consts::PI * 1.6).sin() * 0.05
}
};
let base = if base.is_finite() { base } else { 0.0 };
smoothstep(-0.15, 0.95, base)
}
fn accent_color(
variant: RevealVariant,
progress: f32,
x: f32,
y: f32,
col: u16,
row: u16,
final_color: Color,
) -> Color {
let seed = (x * 0.7 + y * 0.3 + progress * 0.9).fract();
let base = gradient_multi(seed);
match variant {
RevealVariant::GlitchSweep => {
let jitter = ((y * 12.0 + progress * 16.0).sin() + 1.0) * 0.25;
mix_rgb(base, final_color, 0.3 + jitter.clamp(0.0, 0.4))
}
RevealVariant::VertDrift => {
let lift = ((x * 8.0 + progress * 10.0).cos() + 1.0) * 0.2;
mix_rgb(base, final_color, 0.35 + lift.clamp(0.0, 0.3))
}
RevealVariant::DiagonalPulse => {
let pulse = (((x + y) * std::f32::consts::PI * 2.0) + progress * 6.0).sin().abs();
let glow = mix_rgb(base, Color::Rgb(255, 255, 255), 0.4 * pulse);
mix_rgb(glow, final_color, 0.45)
}
RevealVariant::ChromaticScan => {
let scan = ((progress * 12.0 + x * 10.0).cos() + 1.0) * 0.25;
let cool = mix_rgb(base, Color::Rgb(180, 220, 255), 0.5);
mix_rgb(cool, final_color, 0.4 + scan.clamp(0.0, 0.3))
}
RevealVariant::SparkleFade => {
let sparkle = hash_noise(col, row);
let white = Color::Rgb(255, 255, 255);
let glint = mix_rgb(base, white, (0.6 - progress * 0.4).max(0.0) * sparkle);
mix_rgb(glint, final_color, 0.5)
}
RevealVariant::RainbowBloom => {
let bloom = ((progress * 9.0 + x * 6.0).cos() + 1.0) * 0.35;
let neon = mix_rgb(base, Color::Rgb(255, 180, 255), bloom * 0.3);
mix_rgb(neon, final_color, 0.45)
}
RevealVariant::AuroraBridge => {
let arch = ((y * 9.0 + progress * 8.0).sin() + 1.0) * 0.28;
let glow = mix_rgb(base, Color::Rgb(140, 200, 255), arch * 0.4);
mix_rgb(glow, final_color, 0.4)
}
RevealVariant::PrismRise => {
let shimmer = ((x * 7.0 + y * 5.0 + progress * 6.0).sin() + 1.0) * 0.3;
let prism = mix_rgb(base, Color::Rgb(255, 210, 120), shimmer * 0.4);
mix_rgb(prism, final_color, 0.42)
}
RevealVariant::NeonRoad => {
let strip = ((x * 12.0 + progress * 14.0).sin() + 1.0) * 0.25;
let warm = mix_rgb(base, Color::Rgb(255, 120, 80), strip * 0.35);
mix_rgb(warm, final_color, 0.38)
}
RevealVariant::HorizonRush => {
let wash = ((y * 6.0 + progress * 7.0).cos() + 1.0) * 0.22;
let dawn = mix_rgb(base, Color::Rgb(255, 200, 150), wash * 0.4);
mix_rgb(dawn, final_color, 0.36)
}
RevealVariant::LightBloom => {
let halo = ((progress * 6.0 + (x - 0.5).abs() * 18.0).sin() + 1.0) * 0.25;
let glow = mix_rgb(base, Color::Rgb(255, 240, 255), halo * 0.5);
mix_rgb(glow, final_color, 0.28)
}
}
}
fn hash_noise(x: u16, y: u16) -> f32 {
let mut n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663);
n ^= n >> 13;
n = n.wrapping_mul(1274126177);
((n >> 10) & 0xffff) as f32 / 65535.0
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
if (e1 - e0).abs() < f32::EPSILON {
return 0.0;
}
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+52 -39
View File
@@ -1,9 +1,11 @@
use super::card_style::{
agent_card_style,
fill_card_background,
hint_text_style,
primary_text_style,
rows_to_lines,
secondary_text_style,
title_text_style,
truncate_with_ellipsis,
CardRow,
CardSegment,
@@ -353,10 +355,20 @@ impl AgentRunCell {
}
fn accent_style(style: &CardStyle) -> Style {
let dim = colors::mix_toward(style.accent_fg, colors::text_dim(), 0.85);
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
fn prompt_text_style(style: &CardStyle) -> Style {
let fg = colors::mix_toward(style.text_primary, colors::background(), 0.45);
Style::default().fg(fg)
}
fn action_description_style(style: &CardStyle) -> Style {
let fg = colors::mix_toward(style.text_secondary, colors::background(), 0.55);
Style::default().fg(fg)
}
fn top_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let mut segments = Vec::new();
if body_width == 0 {
@@ -411,37 +423,35 @@ impl AgentRunCell {
}
segments.push(CardSegment::new(" ".to_string(), primary_text_style(style)));
remaining = remaining.saturating_sub(1);
let write_label = self.write_mode_label();
let mode_label = self.write_mode_label();
let bullet_label = mode_label.map(|value| format!(" • {value}"));
let bullet_width = bullet_label
.as_ref()
.map(|value| string_width(value.as_str()))
.unwrap_or(0);
let mut available = remaining;
let mut name_allow = available;
if let Some(label) = write_label.as_ref() {
let label_width = string_width(label.as_str());
if label_width + 1 <= available {
name_allow = available.saturating_sub(label_width);
} else {
// Not enough room for label; skip rendering it.
name_allow = available;
}
}
let name_allow = if bullet_width > 0 {
available.saturating_sub(bullet_width).max(1)
} else {
available
};
let truncated = truncate_with_ellipsis(text_value, name_allow.max(1));
let name_width = string_width(truncated.as_str());
if !truncated.is_empty() {
segments.push(CardSegment::new(truncated, primary_text_style(style)));
let trimmed_name = truncated.trim_end().to_string();
let name_width = string_width(trimmed_name.as_str());
if !trimmed_name.is_empty() {
segments.push(CardSegment::new(trimmed_name, title_text_style(style)));
}
available = available.saturating_sub(name_width);
if let Some(label) = write_label {
if available >= string_width(label.as_str()) {
let label_style = if label.contains("read only") {
Style::default().fg(colors::success())
} else if label.contains("write") {
Style::default().fg(colors::warning())
} else {
secondary_text_style(style)
};
segments.push(CardSegment::new(label, label_style));
if let Some(bullet) = bullet_label {
if available >= bullet_width && bullet_width > 0 {
segments.push(CardSegment::new(
bullet,
Self::mode_label_style(style),
));
}
}
}
@@ -454,6 +464,11 @@ impl AgentRunCell {
)
}
fn mode_label_style(style: &CardStyle) -> Style {
let fg = colors::mix_toward(style.text_secondary, colors::background(), 0.55);
Style::default().fg(fg)
}
fn blank_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let filler = " ".repeat(body_width);
CardRow::new(
@@ -537,7 +552,7 @@ impl AgentRunCell {
" [Ctrl+A] Expand".to_string()
};
let text = truncate_with_ellipsis(text_value.as_str(), body_width);
let segment = CardSegment::new(text, secondary_text_style(style));
let segment = CardSegment::new(text, hint_text_style(style));
CardRow::new(BORDER_BOTTOM.to_string(), Self::accent_style(style), vec![segment], None)
}
@@ -597,14 +612,12 @@ impl AgentRunCell {
rows
}
fn write_mode_label(&self) -> Option<String> {
self.write_enabled.map(|flag| {
if flag {
" write ".to_string()
} else {
" read only ".to_string()
}
})
fn write_mode_label(&self) -> Option<&'static str> {
match self.write_enabled {
Some(true) => Some("Write Agents"),
Some(false) => Some("Read Agents"),
None => None,
}
}
fn prompt_rows(&self, body_width: usize, style: &CardStyle) -> Vec<CardRow> {
@@ -640,7 +653,7 @@ impl AgentRunCell {
line,
body_width,
style,
secondary_text_style(style),
Self::prompt_text_style(style),
HEADING_INDENT,
)
})
@@ -1030,7 +1043,7 @@ impl AgentRunCell {
let time_indent = " ".repeat(ACTION_TIME_INDENT);
let indent_style = secondary_text_style(style);
let time_style = Style::default().fg(colors::text());
let label_style = secondary_text_style(style);
let label_style = Self::action_description_style(style);
let ellipsis_time = |width: usize| {
if width <= 1 {
return "⋮".to_string();
@@ -1051,7 +1064,7 @@ impl AgentRunCell {
ellipsis_segments.push(CardSegment::new(time_indent.clone(), indent_style));
ellipsis_segments.push(CardSegment::new(
ellipsis_time(time_width),
secondary_text_style(style),
label_style,
));
if ACTION_TIME_SEPARATOR_WIDTH > 0 {
ellipsis_segments.push(CardSegment::new(
@@ -1282,7 +1295,7 @@ impl HistoryCell for AgentRunCell {
}
fn desired_height(&self, width: u16) -> u16 {
let style = agent_card_style();
let style = agent_card_style(self.write_enabled);
let rows = self.build_card_rows(width, &style);
rows.len().max(1) as u16
}
@@ -1296,7 +1309,7 @@ impl HistoryCell for AgentRunCell {
return;
}
let style = agent_card_style();
let style = agent_card_style(self.write_enabled);
fill_card_background(buf, area, &style);
let rows = self.build_card_rows(area.width, &style);
let lines = rows_to_lines(&rows, &style, area.width);
+379
View File
@@ -0,0 +1,379 @@
use super::card_style::{
auto_drive_card_style,
hint_text_style,
primary_text_style,
rows_to_lines,
secondary_text_style,
title_text_style,
truncate_with_ellipsis,
CardRow,
CardSegment,
CardStyle,
CARD_ACCENT_WIDTH,
};
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use crate::card_theme;
use crate::gradient_background::{GradientBackground, RevealRender};
use crate::colors;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Text};
use ratatui::widgets::{Paragraph, Widget, Wrap};
use unicode_width::UnicodeWidthStr;
use std::time::Instant;
const BORDER_TOP: &str = "╭─";
const BORDER_BODY: &str = "│";
const BORDER_BOTTOM: &str = "╰─";
const HINT_TEXT: &str = " [Ctrl+S] Settings · [Esc] Stop";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AutoDriveStatus {
Running,
Paused,
Failed,
Stopped,
}
impl AutoDriveStatus {
fn label(self) -> &'static str {
match self {
AutoDriveStatus::Running => "Running",
AutoDriveStatus::Paused => "Paused",
AutoDriveStatus::Failed => "Failed",
AutoDriveStatus::Stopped => "Stopped",
}
}
fn style(self) -> Style {
match self {
AutoDriveStatus::Running => Style::default().fg(colors::info()),
AutoDriveStatus::Paused => Style::default().fg(colors::warning()),
AutoDriveStatus::Failed => Style::default().fg(colors::error()),
AutoDriveStatus::Stopped => Style::default().fg(colors::text_mid()),
}
}
fn tool_status(self) -> ToolCellStatus {
match self {
AutoDriveStatus::Running | AutoDriveStatus::Paused => ToolCellStatus::Running,
AutoDriveStatus::Stopped => ToolCellStatus::Success,
AutoDriveStatus::Failed => ToolCellStatus::Failed,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AutoDriveActionKind {
Info,
Warning,
Error,
}
#[derive(Clone, Debug)]
struct AutoDriveAction {
text: String,
kind: AutoDriveActionKind,
}
impl AutoDriveAction {
fn new(text: String, kind: AutoDriveActionKind) -> Self {
Self { text, kind }
}
fn style(&self, style: &CardStyle) -> Style {
match self.kind {
AutoDriveActionKind::Info => primary_text_style(style),
AutoDriveActionKind::Warning => Style::default().fg(colors::warning()),
AutoDriveActionKind::Error => Style::default().fg(colors::error()),
}
}
}
#[derive(Clone)]
pub(crate) struct AutoDriveCardCell {
goal: Option<String>,
status: AutoDriveStatus,
actions: Vec<AutoDriveAction>,
cell_key: Option<String>,
signature: Option<String>,
reveal_started_at: Option<Instant>,
}
impl AutoDriveCardCell {
pub(crate) fn new(goal: Option<String>) -> Self {
let reveal_started_at = active_auto_drive_theme()
.theme
.reveal
.map(|_| Instant::now());
let mut cell = Self {
goal: goal.and_then(Self::normalize_text),
status: AutoDriveStatus::Running,
actions: Vec::new(),
cell_key: None,
signature: None,
reveal_started_at,
};
if let Some(goal) = cell.goal.clone() {
cell.actions.push(AutoDriveAction::new(
format!("Goal: {goal}"),
AutoDriveActionKind::Info,
));
}
cell
}
fn normalize_text(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn set_status(&mut self, status: AutoDriveStatus) {
self.status = status;
}
pub(crate) fn push_action(&mut self, text: impl Into<String>, kind: AutoDriveActionKind) {
self.actions.push(AutoDriveAction::new(text.into(), kind));
}
fn accent_style(style: &CardStyle) -> Style {
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
fn build_card_rows(&self, width: u16, style: &CardStyle) -> Vec<CardRow> {
if width == 0 {
return Vec::new();
}
let accent_width = CARD_ACCENT_WIDTH.min(width as usize);
let body_width = width.saturating_sub(accent_width as u16) as usize;
if body_width == 0 {
return Vec::new();
}
let mut rows: Vec<CardRow> = Vec::new();
rows.push(self.title_row(body_width, style));
if let Some(goal) = &self.goal {
rows.push(self.description_row(goal.as_str(), body_width, style));
}
for action in &self.actions {
rows.push(self.action_row(action, body_width, style));
}
rows.push(self.bottom_border_row(body_width, style));
rows
}
fn title_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let mut segments: Vec<CardSegment> = Vec::new();
let title_text = " Auto Drive";
let status_text = format!(" {}", self.status.label());
let combined = format!("{title_text}{status_text}");
if UnicodeWidthStr::width(combined.as_str()) <= body_width {
let mut title_style = title_text_style(style);
title_style = title_style.add_modifier(Modifier::BOLD);
segments.push(CardSegment::new(title_text.to_string(), title_style));
segments.push(CardSegment::new(status_text, self.status.style()));
} else {
let mut title_style = title_text_style(style);
title_style = title_style.add_modifier(Modifier::BOLD);
let display = truncate_with_ellipsis(title_text, body_width);
segments.push(CardSegment::new(display, title_style));
}
CardRow::new(
BORDER_TOP.to_string(),
Self::accent_style(style),
segments,
None,
)
}
fn description_row(&self, goal: &str, body_width: usize, style: &CardStyle) -> CardRow {
let display = truncate_with_ellipsis(goal, body_width);
let mut segment = CardSegment::new(display, secondary_text_style(style));
segment.inherit_background = true;
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
vec![segment],
None,
)
}
fn action_row(&self, action: &AutoDriveAction, body_width: usize, style: &CardStyle) -> CardRow {
if body_width == 0 {
return CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
Vec::new(),
None,
);
}
let bullet = match action.kind {
AutoDriveActionKind::Warning => "! ",
AutoDriveActionKind::Error => "✗ ",
AutoDriveActionKind::Info => "• ",
};
let value = format!("{bullet}{}", action.text);
let display = truncate_with_ellipsis(value.as_str(), body_width);
let mut segment = CardSegment::new(display, action.style(style));
segment.inherit_background = true;
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
vec![segment],
None,
)
}
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text = truncate_with_ellipsis(HINT_TEXT, body_width);
let mut segment = CardSegment::new(text, hint_text_style(style));
segment.inherit_background = true;
CardRow::new(
BORDER_BOTTOM.to_string(),
Self::accent_style(style),
vec![segment],
None,
)
}
fn render_rows(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
if area.width == 0 || area.height == 0 {
return;
}
let is_dark = is_dark_theme_active();
let theme = active_auto_drive_theme();
let style = auto_drive_card_style();
let reveal = theme.theme.reveal.map(|config| {
let progress = self
.reveal_started_at
.map(|started| {
let elapsed = started.elapsed().as_secs_f32();
(elapsed / config.duration.as_secs_f32()).clamp(0.0, 1.0)
})
.unwrap_or(1.0);
RevealRender {
progress,
variant: config.variant,
intro_light: !is_dark,
}
});
GradientBackground::render(buf, area, &style.gradient, style.text_primary, reveal);
let rows = self.build_card_rows(area.width, &style);
let lines = rows_to_lines(&rows, &style, area.width);
let text = Text::from(lines);
Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.render(area, buf);
}
fn desired_rows(&self, width: u16) -> usize {
let style = auto_drive_card_style();
self.build_card_rows(width, &style).len().max(1)
}
pub(crate) fn set_tool_card_key_internal(&mut self, key: Option<String>) {
self.cell_key = key;
}
pub(crate) fn set_signature(&mut self, signature: Option<String>) {
self.signature = signature;
}
pub(crate) fn current_tool_card_key(&self) -> Option<&str> {
self.cell_key.as_deref()
}
pub(crate) fn signature(&self) -> Option<&str> {
self.signature.as_deref()
}
}
fn is_dark_theme_active() -> bool {
let (r, g, b) = colors::color_to_rgb(colors::background());
let luminance = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0;
luminance < 0.5
}
fn active_auto_drive_theme() -> card_theme::CardThemeDefinition {
if is_dark_theme_active() {
card_theme::auto_drive_dark_theme()
} else {
card_theme::auto_drive_light_theme()
}
}
impl crate::chatwidget::tool_cards::ToolCardCell for AutoDriveCardCell {
fn tool_card_key(&self) -> Option<&str> {
self.current_tool_card_key()
}
fn set_tool_card_key(&mut self, key: Option<String>) {
self.set_tool_card_key_internal(key);
}
fn dedupe_signature(&self) -> Option<String> {
self.signature().map(|value| value.to_string())
}
}
impl HistoryCell for AutoDriveCardCell {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn gutter_symbol(&self) -> Option<&'static str> {
None
}
fn kind(&self) -> HistoryCellType {
HistoryCellType::Tool {
status: self.status.tool_status(),
}
}
fn display_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(format!("{} — {}", "Auto Drive", self.status.label())));
if let Some(goal) = &self.goal {
lines.push(Line::from(format!("goal: {goal}")));
}
for action in &self.actions {
lines.push(Line::from(format!("- {}", action.text)));
}
lines
}
fn desired_height(&self, width: u16) -> u16 {
self.desired_rows(width) as u16
}
fn has_custom_render(&self) -> bool {
true
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
self.render_rows(area, buf, skip_rows);
}
}
+6 -4
View File
@@ -1,9 +1,11 @@
use super::card_style::{
browser_card_style,
fill_card_background,
hint_text_style,
primary_text_style,
rows_to_lines,
secondary_text_style,
title_text_style,
truncate_with_ellipsis,
CardRow,
CardSegment,
@@ -287,7 +289,7 @@ impl BrowserSessionCell {
}
fn accent_style(style: &CardStyle) -> Style {
let dim = colors::mix_toward(style.accent_fg, colors::text_dim(), 0.85);
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
@@ -324,11 +326,11 @@ impl BrowserSessionCell {
);
}
segments.push(CardSegment::new(" ".to_string(), primary_text_style(style)));
segments.push(CardSegment::new(" ".to_string(), title_text_style(style)));
let remaining = body_width.saturating_sub(1);
let text = truncate_with_ellipsis(self.header_summary_text().as_str(), remaining);
if !text.is_empty() {
segments.push(CardSegment::new(text, primary_text_style(style)));
segments.push(CardSegment::new(text, title_text_style(style)));
}
CardRow::new(BORDER_TOP.to_string(), Self::accent_style(style), segments, None)
}
@@ -381,7 +383,7 @@ impl BrowserSessionCell {
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text_value = format!(" [Ctrl+B] View · [Esc] Stop");
let text = truncate_with_ellipsis(text_value.as_str(), body_width);
let segment = CardSegment::new(text, secondary_text_style(style));
let segment = CardSegment::new(text, hint_text_style(style));
CardRow::new(BORDER_BOTTOM.to_string(), Self::accent_style(style), vec![segment], None)
}
+90 -56
View File
@@ -1,16 +1,20 @@
use ratatui::buffer::Buffer;
use ratatui::prelude::*;
use ratatui::style::Color;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::card_theme;
use crate::card_theme::{CardThemeDefinition, GradientSpec};
use crate::colors;
use crate::gradient_background::GradientBackground;
#[derive(Clone, Copy)]
pub(crate) struct CardStyle {
pub accent_fg: Color,
pub background_top: Color,
pub background_bottom: Color,
pub text_primary: Color,
pub text_secondary: Color,
pub title_text: Color,
pub gradient: GradientSpec,
}
#[derive(Clone, Debug)]
@@ -56,60 +60,81 @@ impl CardRow {
pub(crate) const CARD_ACCENT_WIDTH: usize = 2;
pub(crate) fn agent_card_style() -> CardStyle {
let bg = colors::background();
let info = colors::info();
let accent = colors::mix_toward(info, bg, 0.15);
let accent_fg = colors::mix_toward(colors::text_bright(), accent, 0.40);
let secondary = colors::text_dim();
let background_bottom = colors::mix_toward(bg, info, 0.10);
let background_top = colors::mix_toward(bg, info, 0.05);
CardStyle {
accent_fg,
background_top,
background_bottom,
text_primary: colors::text(),
text_secondary: secondary,
}
pub(crate) fn agent_card_style(write_enabled: Option<bool>) -> CardStyle {
let is_dark = is_dark_theme_active();
let definition = match (write_enabled.unwrap_or(false), is_dark) {
(true, true) => card_theme::agent_write_dark_theme(),
(true, false) => card_theme::agent_write_light_theme(),
(false, true) => card_theme::agent_read_only_dark_theme(),
(false, false) => card_theme::agent_read_only_light_theme(),
};
style_from_theme(definition, is_dark)
}
pub(crate) fn browser_card_style() -> CardStyle {
let bg = colors::background();
let primary = colors::primary();
let accent = colors::mix_toward(primary, bg, 0.20);
let accent_fg = colors::mix_toward(colors::text_bright(), accent, 0.35);
let secondary = colors::text_mid();
let background_bottom = colors::mix_toward(bg, primary, 0.09);
let background_top = colors::mix_toward(bg, primary, 0.04);
let is_dark = is_dark_theme_active();
let definition = if is_dark {
card_theme::browser_dark_theme()
} else {
card_theme::browser_light_theme()
};
style_from_theme(definition, is_dark)
}
pub(crate) fn auto_drive_card_style() -> CardStyle {
let is_dark = is_dark_theme_active();
let definition = if is_dark {
card_theme::auto_drive_dark_theme()
} else {
card_theme::auto_drive_light_theme()
};
style_from_theme(definition, is_dark)
}
pub(crate) fn web_search_card_style() -> CardStyle {
let is_dark = is_dark_theme_active();
let definition = if is_dark {
card_theme::search_dark_theme()
} else {
card_theme::search_light_theme()
};
style_from_theme(definition, is_dark)
}
fn style_from_theme(definition: CardThemeDefinition, is_dark: bool) -> CardStyle {
let theme = definition.theme;
CardStyle {
accent_fg,
background_top,
background_bottom,
text_primary: colors::text(),
text_secondary: secondary,
accent_fg: theme.palette.border,
text_primary: theme.palette.text,
text_secondary: theme.palette.footer,
title_text: theme.palette.title,
gradient: adjust_gradient(theme.gradient, is_dark),
}
}
fn is_dark_theme_active() -> bool {
let (r, g, b) = colors::color_to_rgb(colors::background());
let luminance = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0;
luminance < 0.5
}
fn adjust_gradient(gradient: GradientSpec, is_dark: bool) -> GradientSpec {
const LIGHTEN_FACTOR: f32 = 0.75;
const DARKEN_FACTOR: f32 = 0.42;
let target = if is_dark { Color::Black } else { Color::White };
let amount = if is_dark { DARKEN_FACTOR } else { LIGHTEN_FACTOR };
let adjusted_left = colors::mix_toward(gradient.left, target, amount);
let adjusted_right = colors::mix_toward(gradient.right, target, amount);
GradientSpec {
left: adjusted_right,
right: adjusted_left,
bias: gradient.bias,
}
}
pub(crate) fn fill_card_background(buf: &mut Buffer, area: Rect, style: &CardStyle) {
let height = area.height.max(1);
for row in 0..area.height {
let color = gradient_color(style, row as usize, height as usize);
for col in 0..area.width {
let cell = &mut buf[(area.x + col, area.y + row)];
cell.set_symbol(" ");
cell.set_style(Style::default().bg(color).fg(style.text_primary));
}
}
}
pub(crate) fn gradient_color(style: &CardStyle, position: usize, total: usize) -> Color {
if total <= 1 {
return style.background_bottom;
}
let t = position as f32 / ((total - 1) as f32);
colors::mix_toward(style.background_top, style.background_bottom, t)
GradientBackground::render(buf, area, &style.gradient, style.text_primary, None);
}
pub(crate) fn pad_icon(icon: &str, width: usize) -> String {
@@ -177,7 +202,7 @@ pub(crate) fn truncate_with_ellipsis(text: &str, width: usize) -> String {
result
}
pub(crate) fn rows_to_lines(rows: &[CardRow], style: &CardStyle, total_width: u16) -> Vec<Line<'static>> {
pub(crate) fn rows_to_lines(rows: &[CardRow], _style: &CardStyle, total_width: u16) -> Vec<Line<'static>> {
if total_width == 0 {
return Vec::new();
}
@@ -189,8 +214,7 @@ pub(crate) fn rows_to_lines(rows: &[CardRow], style: &CardStyle, total_width: u1
};
let body_width = total_width.saturating_sub(accent_width as u16) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
let total_rows = rows.len();
for (idx, row) in rows.iter().enumerate() {
for row in rows.iter() {
let mut spans: Vec<Span<'static>> = Vec::new();
if accent_width > 0 {
let accent_text = pad_icon(row.accent.as_str(), accent_width);
@@ -198,14 +222,12 @@ pub(crate) fn rows_to_lines(rows: &[CardRow], style: &CardStyle, total_width: u1
spans.push(accent_span);
}
let row_bg = row
.body_bg
.unwrap_or_else(|| gradient_color(style, idx, total_rows.max(1)));
let row_bg = row.body_bg;
let mut used_width = 0;
for segment in &row.segments {
let mut seg_style = segment.style;
if segment.inherit_background {
seg_style = seg_style.bg(row_bg);
if let (true, Some(bg)) = (segment.inherit_background, row_bg) {
seg_style = seg_style.bg(bg);
}
let width = UnicodeWidthStr::width(segment.text.as_str());
used_width += width;
@@ -213,7 +235,10 @@ pub(crate) fn rows_to_lines(rows: &[CardRow], style: &CardStyle, total_width: u1
}
if used_width < body_width {
let filler = " ".repeat(body_width - used_width);
spans.push(Span::styled(filler, Style::default().bg(row_bg)));
let filler_style = row_bg
.map(|bg| Style::default().bg(bg))
.unwrap_or_else(Style::default);
spans.push(Span::styled(filler, filler_style));
}
lines.push(Line::from(spans));
}
@@ -227,3 +252,12 @@ pub(crate) fn primary_text_style(style: &CardStyle) -> Style {
pub(crate) fn secondary_text_style(style: &CardStyle) -> Style {
Style::default().fg(style.text_secondary)
}
pub(crate) fn title_text_style(style: &CardStyle) -> Style {
Style::default().fg(style.title_text)
}
pub(crate) fn hint_text_style(style: &CardStyle) -> Style {
let fg = colors::mix_toward(style.text_secondary, colors::background(), 0.45);
Style::default().fg(fg)
}
+12 -23
View File
@@ -72,8 +72,8 @@ use tracing::error;
mod assistant;
mod animated;
mod card_style;
mod background;
mod card_style;
mod exec;
mod diff;
mod explore;
@@ -83,6 +83,8 @@ mod reasoning;
mod tool;
mod browser;
mod agent;
mod auto_drive;
mod web_search;
mod wait_status;
mod plan_update;
mod rate_limits;
@@ -113,6 +115,15 @@ pub(crate) use diff::{
DiffCell,
};
pub(crate) use browser::BrowserSessionCell;
pub(crate) use auto_drive::{
AutoDriveActionKind,
AutoDriveCardCell,
AutoDriveStatus,
};
pub(crate) use web_search::{
WebSearchSessionCell,
WebSearchStatus,
};
pub(crate) use agent::{AgentDetail, AgentRunCell, AgentStatusKind, AgentStatusPreview, StepProgress};
pub(crate) use explore::{
explore_lines_from_record,
@@ -4281,28 +4292,6 @@ pub(crate) fn new_running_custom_tool_call(
RunningToolCallCell::new(state)
}
/// Running web search call (native Responses web_search)
pub(crate) fn new_running_web_search(query: Option<String>) -> RunningToolCallCell {
let mut arguments: Vec<ToolArgument> = Vec::new();
if let Some(q) = query {
arguments.push(ToolArgument {
name: "query".to_string(),
value: ArgumentValue::Text(q),
});
}
let state = RunningToolState {
id: HistoryId::ZERO,
call_id: None,
title: "Web Search...".to_string(),
started_at: SystemTime::now(),
arguments,
wait_has_target: false,
wait_has_call_id: false,
wait_cap_ms: None,
};
RunningToolCallCell::new(state)
}
pub(crate) fn new_running_mcp_tool_call(invocation: McpInvocation) -> RunningToolCallCell {
// Represent as provider.tool(...) on one dim line beneath a generic running header with timer
let line = format_mcp_invocation(invocation);
-35
View File
@@ -168,41 +168,6 @@ impl RunningToolCallCell {
self.state.title == title
}
pub(crate) fn finalize_web_search(
&self,
success: bool,
query: Option<String>,
) -> ToolCallCell {
let duration = self.elapsed_duration();
let mut arguments: Vec<ToolArgument> = Vec::new();
if let Some(q) = query {
arguments.push(ToolArgument {
name: "query".to_string(),
value: ArgumentValue::Text(q),
});
}
let status = if success {
HistoryToolStatus::Success
} else {
HistoryToolStatus::Failed
};
let state = ToolCallState {
id: HistoryId::ZERO,
call_id: None,
status,
title: if success {
"Web Search".to_string()
} else {
"Web Search (failed)".to_string()
},
duration: Some(duration),
arguments,
result_preview: None,
error_message: None,
};
ToolCallCell::new(state)
}
fn elapsed_duration(&self) -> Duration {
SystemTime::now()
.duration_since(self.state.started_at)
+523
View File
@@ -0,0 +1,523 @@
use super::card_style::{
fill_card_background,
hint_text_style,
primary_text_style,
rows_to_lines,
secondary_text_style,
title_text_style,
truncate_with_ellipsis,
web_search_card_style,
CardRow,
CardSegment,
CardStyle,
CARD_ACCENT_WIDTH,
};
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use crate::colors;
use code_common::elapsed::format_duration_digital;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Text};
use ratatui::widgets::{Paragraph, Widget, Wrap};
use std::time::Duration;
use unicode_width::UnicodeWidthStr;
const BORDER_TOP: &str = "╭─";
const BORDER_BODY: &str = "│";
const BORDER_BOTTOM: &str = "╰─";
const HINT_TEXT: &str = " [Ctrl+S] Settings · [Esc] Stop";
const ACTION_TIME_INDENT: usize = 2;
const ACTION_TIME_SEPARATOR_WIDTH: usize = 2;
const ACTION_TIME_COLUMN_MIN_WIDTH: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum WebSearchStatus {
Running,
Completed,
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WebSearchActionKind {
Info,
Success,
Error,
}
impl WebSearchActionKind {
fn glyph(self) -> &'static str {
match self {
WebSearchActionKind::Info => "•",
WebSearchActionKind::Success => "✓",
WebSearchActionKind::Error => "✗",
}
}
}
#[derive(Clone, Debug)]
struct WebSearchAction {
text: String,
kind: WebSearchActionKind,
timestamp: Duration,
}
impl WebSearchAction {
fn new(text: String, kind: WebSearchActionKind, timestamp: Duration) -> Self {
Self {
text,
kind,
timestamp,
}
}
fn style(&self, style: &CardStyle) -> Style {
match self.kind {
WebSearchActionKind::Info => primary_text_style(style),
WebSearchActionKind::Success => Style::default().fg(colors::success()),
WebSearchActionKind::Error => Style::default().fg(colors::error()),
}
}
}
#[derive(Clone)]
pub(crate) struct WebSearchSessionCell {
query: Option<String>,
status: WebSearchStatus,
actions: Vec<WebSearchAction>,
duration: Option<Duration>,
cell_key: Option<String>,
signature: Option<String>,
}
impl WebSearchSessionCell {
pub(crate) fn new() -> Self {
Self {
query: None,
status: WebSearchStatus::Running,
actions: Vec::new(),
duration: None,
cell_key: None,
signature: None,
}
}
pub(crate) fn set_query(&mut self, query: Option<String>) -> bool {
let previous = self.query.clone();
self.query = query
.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
self.query != previous
}
pub(crate) fn set_status(&mut self, status: WebSearchStatus) {
self.status = status;
}
fn record_action(
&mut self,
timestamp: Duration,
text: impl Into<String>,
kind: WebSearchActionKind,
) {
self.actions
.push(WebSearchAction::new(text.into(), kind, timestamp));
}
pub(crate) fn record_info(&mut self, timestamp: Duration, text: impl Into<String>) {
self.record_action(timestamp, text, WebSearchActionKind::Info);
}
pub(crate) fn record_success(&mut self, timestamp: Duration, text: impl Into<String>) {
self.record_action(timestamp, text, WebSearchActionKind::Success);
}
pub(crate) fn record_error(&mut self, timestamp: Duration, text: impl Into<String>) {
self.record_action(timestamp, text, WebSearchActionKind::Error);
}
pub(crate) fn set_duration(&mut self, duration: Option<Duration>) {
self.duration = duration;
}
pub(crate) fn ensure_started_message(&mut self) {
if self.actions.is_empty() {
self.record_action(Duration::ZERO, "Searching…", WebSearchActionKind::Info);
}
}
pub(crate) fn tool_title(&self) -> &'static str {
"Web Search"
}
pub(crate) fn status_label(&self) -> &'static str {
match self.status {
WebSearchStatus::Running => "Running",
WebSearchStatus::Completed => "Completed",
WebSearchStatus::Failed => "Failed",
}
}
fn status_style(&self) -> Style {
match self.status {
WebSearchStatus::Running => Style::default().fg(colors::info()),
WebSearchStatus::Completed => Style::default().fg(colors::success()),
WebSearchStatus::Failed => Style::default().fg(colors::error()),
}
}
fn accent_style(style: &CardStyle) -> Style {
let dim = colors::mix_toward(style.accent_fg, style.text_secondary, 0.85);
Style::default().fg(dim)
}
fn build_card_rows(&self, width: u16, style: &CardStyle) -> Vec<CardRow> {
if width == 0 {
return Vec::new();
}
let accent_width = CARD_ACCENT_WIDTH.min(width as usize);
let body_width = width.saturating_sub(accent_width as u16) as usize;
if body_width == 0 {
return Vec::new();
}
let mut rows: Vec<CardRow> = Vec::new();
rows.push(self.title_row(body_width, style));
rows.push(self.blank_border_row(body_width, style));
rows.extend(self.actions_section_rows(body_width, style));
rows.push(self.blank_border_row(body_width, style));
rows.push(self.bottom_border_row(body_width, style));
rows
}
fn title_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let mut segments: Vec<CardSegment> = Vec::new();
let title_text = " Web Search";
let status_text = format!(" {}", self.status_label());
let total = format!("{title_text}{status_text}");
if UnicodeWidthStr::width(total.as_str()) <= body_width {
let mut title_style = title_text_style(style);
title_style = title_style.add_modifier(Modifier::BOLD);
segments.push(CardSegment::new(title_text.to_string(), title_style));
segments.push(CardSegment::new(status_text, self.status_style()));
} else {
let mut title_style = title_text_style(style);
title_style = title_style.add_modifier(Modifier::BOLD);
let display = truncate_with_ellipsis(title_text, body_width);
segments.push(CardSegment::new(display, title_style));
}
CardRow::new(
BORDER_TOP.to_string(),
Self::accent_style(style),
segments,
None,
)
}
fn blank_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
if body_width == 0 {
return CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
Vec::new(),
None,
);
}
let filler = " ".repeat(body_width);
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
vec![CardSegment::new(filler, Style::default())],
None,
)
}
fn actions_section_rows(&self, body_width: usize, style: &CardStyle) -> Vec<CardRow> {
if body_width == 0 {
return Vec::new();
}
let mut rows = Vec::new();
rows.push(self.actions_heading_row(body_width, style));
if self.actions.is_empty() {
rows.push(self.placeholder_row(body_width, style));
return rows;
}
let elapsed_labels: Vec<String> = self
.actions
.iter()
.map(|action| format_duration_digital(action.timestamp))
.collect();
let time_width = elapsed_labels
.iter()
.map(|label| UnicodeWidthStr::width(label.as_str()))
.max()
.unwrap_or(0)
.max(ACTION_TIME_COLUMN_MIN_WIDTH);
let indent_text = " ".repeat(ACTION_TIME_INDENT);
let indent_style = secondary_text_style(style);
let time_style = secondary_text_style(style);
let separator_text = if ACTION_TIME_SEPARATOR_WIDTH > 0 {
Some(" ".repeat(ACTION_TIME_SEPARATOR_WIDTH))
} else {
None
};
for (action, elapsed) in self.actions.iter().zip(elapsed_labels.iter()) {
let mut segments = Vec::new();
segments.push(CardSegment::new(indent_text.clone(), indent_style));
let mut remaining = body_width.saturating_sub(ACTION_TIME_INDENT);
if remaining == 0 {
rows.push(CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
));
continue;
}
let padded_time = format!("{elapsed:>width$}", width = time_width);
segments.push(CardSegment::new(padded_time, time_style));
remaining = remaining.saturating_sub(time_width);
if let Some(separator) = separator_text.as_ref() {
if remaining < ACTION_TIME_SEPARATOR_WIDTH {
rows.push(CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
));
continue;
}
segments.push(CardSegment::new(separator.clone(), Style::default()));
remaining = remaining.saturating_sub(ACTION_TIME_SEPARATOR_WIDTH);
}
if remaining == 0 {
rows.push(CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
));
continue;
}
let description_text = format!("{} {}", action.kind.glyph(), action.text);
let display = truncate_with_ellipsis(description_text.as_str(), remaining);
let mut description_segment = CardSegment::new(display, action.style(style));
description_segment.inherit_background = true;
segments.push(description_segment);
rows.push(CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
));
}
rows
}
fn actions_heading_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
if body_width == 0 {
return CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
Vec::new(),
None,
);
}
let mut segments = Vec::new();
if ACTION_TIME_INDENT > 0 {
segments.push(CardSegment::new(
" ".repeat(ACTION_TIME_INDENT),
secondary_text_style(style),
));
}
let available = body_width.saturating_sub(ACTION_TIME_INDENT);
if available > 0 {
let title = truncate_with_ellipsis("Actions", available);
let mut heading = CardSegment::new(title, primary_text_style(style));
heading.inherit_background = true;
segments.push(heading);
}
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
)
}
fn placeholder_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
if body_width == 0 {
return CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
Vec::new(),
None,
);
}
let mut segments = Vec::new();
if ACTION_TIME_INDENT > 0 {
segments.push(CardSegment::new(
" ".repeat(ACTION_TIME_INDENT),
secondary_text_style(style),
));
}
let available = body_width.saturating_sub(ACTION_TIME_INDENT);
if available > 0 {
let message = truncate_with_ellipsis("Awaiting web search activity", available);
let mut placeholder = CardSegment::new(message, secondary_text_style(style));
placeholder.inherit_background = true;
segments.push(placeholder);
}
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
)
}
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text = truncate_with_ellipsis(HINT_TEXT, body_width);
let mut segment = CardSegment::new(text, hint_text_style(style));
segment.inherit_background = true;
CardRow::new(
BORDER_BOTTOM.to_string(),
Self::accent_style(style),
vec![segment],
None,
)
}
fn desired_rows(&self, width: u16) -> usize {
let style = web_search_card_style();
self.build_card_rows(width, &style).len().max(1)
}
fn render_rows(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
if area.width == 0 || area.height == 0 {
return;
}
let style = web_search_card_style();
fill_card_background(buf, area, &style);
let rows = self.build_card_rows(area.width, &style);
let lines = rows_to_lines(&rows, &style, area.width);
let text = Text::from(lines);
Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.render(area, buf);
}
pub(crate) fn set_tool_card_key_internal(&mut self, key: Option<String>) {
self.cell_key = key;
}
pub(crate) fn set_signature(&mut self, signature: Option<String>) {
self.signature = signature;
}
pub(crate) fn current_tool_card_key(&self) -> Option<&str> {
self.cell_key.as_deref()
}
pub(crate) fn signature(&self) -> Option<&str> {
self.signature.as_deref()
}
}
impl crate::chatwidget::tool_cards::ToolCardCell for WebSearchSessionCell {
fn tool_card_key(&self) -> Option<&str> {
self.current_tool_card_key()
}
fn set_tool_card_key(&mut self, key: Option<String>) {
self.set_tool_card_key_internal(key);
}
fn dedupe_signature(&self) -> Option<String> {
self.signature().map(|value| value.to_string())
}
}
impl HistoryCell for WebSearchSessionCell {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn gutter_symbol(&self) -> Option<&'static str> {
None
}
fn kind(&self) -> HistoryCellType {
let status = match self.status {
WebSearchStatus::Running => ToolCellStatus::Running,
WebSearchStatus::Completed => ToolCellStatus::Success,
WebSearchStatus::Failed => ToolCellStatus::Failed,
};
HistoryCellType::Tool { status }
}
fn display_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::styled(
format!("{} — {}", self.tool_title(), self.status_label()),
Style::default().add_modifier(Modifier::BOLD),
));
if let Some(query) = &self.query {
lines.push(Line::from(format!("query: {query}")));
}
for action in &self.actions {
lines.push(Line::from(format!("- {}", action.text)));
}
lines
}
fn desired_height(&self, width: u16) -> u16 {
self.desired_rows(width) as u16
}
fn has_custom_render(&self) -> bool {
true
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
self.render_rows(area, buf, skip_rows);
}
}
+2
View File
@@ -46,9 +46,11 @@ mod cloud_tasks_service;
mod cli;
mod common;
mod colors;
pub mod card_theme;
mod diff_render;
mod exec_command;
mod file_search;
pub mod gradient_background;
mod get_git_diff;
mod glitch_animation;
mod auto_drive_strings;
+74
View File
@@ -0,0 +1,74 @@
# TUI Card Theme System
The TUI now exposes a shared card theme catalog that any surface (Agent, Browser, Auto Drive, Search, etc.) can use to present consistent gradients, palettes, and reveal animations.
## 1. Import the theme helpers
```rust
use crate::card_theme::{self, CardThemeDefinition};
use crate::gradient_background::{GradientBackground, RevealRender};
```
`card_theme` owns the data model (gradients, palettes, animation metadata). `gradient_background` handles all gradient and reveal rendering.
## 2. Pick a theme
Built-in helpers return `CardThemeDefinition` values that carry the display name and styling bundle:
```rust
let search_dark = card_theme::search_dark_theme();
let auto_drive_light = card_theme::auto_drive_light_theme();
let all = card_theme::theme_catalog();
```
Use `dark_theme_catalog()`, `light_theme_catalog()`, or `auto_drive_theme_catalog()` when you need grouped collections (for example, to build preview pickers).
To attach body content, convert the definition into a `CardPreviewSpec`:
```rust
const BODY: &[&str] = &["LLM output in a card", "Second paragraph..."];
let preview = theme.preview(BODY);
```
## 3. Render a gradient card
`GradientBackground::render` applies either a static gradient or an animated reveal. Pass the palette’s text color as the foreground; the helper keeps text legible while animating.
```rust
let reveal = preview
.theme
.reveal
.map(|config| RevealRender {
progress: animation.progress(),
variant: config.variant,
intro_light: preview.name.contains("Light"),
});
GradientBackground::render(
buf,
area,
&preview.theme.gradient,
preview.theme.palette.text,
reveal,
);
```
After painting the background, write title/body/footer text with the palette colors (`palette.border`, `palette.title`, etc.).
## 4. Drive animations when available
Themes that define a `RevealConfig` opt into animated reveals. Track an animation progress value (`0.0..=1.0`) of your choosing and pass it through `RevealRender`. Themes with `reveal: None` render statically; supply `None` to `GradientBackground::render` in that case.
## 5. Examples by surface
- **Search** – `search_dark_theme()` / `search_light_theme()`
- **Auto Drive** – `auto_drive_dark_theme()` / `auto_drive_light_theme()` (the only themes with `reveal` animation data)
- **Agent (read-only)** – `agent_read_only_dark_theme()` / `agent_read_only_light_theme()`
- **Agent (write)** – `agent_write_dark_theme()` / `agent_write_light_theme()`
- **Browser** – `browser_dark_theme()` / `browser_light_theme()`
## Tips
- Keep body copy narrow (`textwrap` width ≈ `area.width - padding`).
- When you introduce a new theme, add it to `card_theme.rs` so every surface can reuse it.
- Prefer `CardThemeDefinition::preview` over ad-hoc palette wiring; this keeps names, palettes, and animations aligned.