mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Merge code-branch-20251026-025326: auto-drive cards and web search
This commit is contained in:
+55
-82
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user