mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
chore(auto-review): rename worktree and footer labels
This commit is contained in:
@@ -3,7 +3,7 @@ use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -13,7 +13,7 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::{sleep, sleep_until, Instant};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{info, warn};
|
||||
@@ -37,6 +37,9 @@ struct BridgeMeta {
|
||||
|
||||
const HEARTBEAT_STALE_MS: i64 = 20_000;
|
||||
const SUBSCRIPTION_OVERRIDE_FILE: &str = "code-bridge.subscription.json";
|
||||
const BATCH_WINDOW: Duration = Duration::from_secs(3);
|
||||
const MAX_EVENTS_PER_BATCH: usize = 50;
|
||||
const MAX_EVENT_SUMMARY_CHARS: usize = 1200;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -63,6 +66,13 @@ static CONTROL_SENDER: Lazy<Mutex<Option<tokio::sync::mpsc::UnboundedSender<Stri
|
||||
static LAST_OVERRIDE_FINGERPRINT: Lazy<Mutex<Option<u64>>> = Lazy::new(|| Mutex::new(None));
|
||||
static BRIDGE_HINT_EMITTED: Lazy<AtomicBool> = Lazy::new(|| AtomicBool::new(false));
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BridgeBatchEvent {
|
||||
summary: String,
|
||||
level: Option<String>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn default_levels() -> Vec<String> {
|
||||
vec!["errors".to_string()]
|
||||
}
|
||||
@@ -126,6 +136,161 @@ fn normalise_vec(values: Vec<String>) -> Vec<String> {
|
||||
vals
|
||||
}
|
||||
|
||||
fn parse_level(raw: &str) -> Option<String> {
|
||||
serde_json::from_str::<Value>(raw)
|
||||
.ok()
|
||||
.and_then(|val| {
|
||||
val.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.or_else(|| {
|
||||
val.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn is_error_level(level: &str) -> bool {
|
||||
matches!(level, "error" | "errors" | "err" | "fatal" | "critical" | "panic")
|
||||
}
|
||||
|
||||
fn truncate_summary(text: &str) -> (String, bool) {
|
||||
let mut chars = text.chars();
|
||||
let truncated: String = chars.by_ref().take(MAX_EVENT_SUMMARY_CHARS).collect();
|
||||
if text.chars().count() > MAX_EVENT_SUMMARY_CHARS {
|
||||
let remaining = text.chars().count().saturating_sub(MAX_EVENT_SUMMARY_CHARS);
|
||||
(format!("{}... [truncated {remaining} chars]", truncated), true)
|
||||
} else {
|
||||
(truncated, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_event(raw: &str) -> BridgeBatchEvent {
|
||||
let level = parse_level(raw);
|
||||
let summary = summarize(raw);
|
||||
let (summary, truncated) = truncate_summary(&summary);
|
||||
|
||||
BridgeBatchEvent {
|
||||
summary,
|
||||
level,
|
||||
truncated,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CoalescedBatch {
|
||||
entries: Vec<(String, usize)>,
|
||||
total_events: usize,
|
||||
truncated_events: usize,
|
||||
dropped_events: usize,
|
||||
saw_error: bool,
|
||||
}
|
||||
|
||||
fn coalesce_events(events: Vec<BridgeBatchEvent>) -> CoalescedBatch {
|
||||
let mut entries: Vec<(String, usize)> = Vec::new();
|
||||
let mut truncated_events = 0;
|
||||
let mut dropped_events = 0;
|
||||
let mut saw_error = false;
|
||||
|
||||
for event in events {
|
||||
let BridgeBatchEvent {
|
||||
summary,
|
||||
level,
|
||||
truncated,
|
||||
} = event;
|
||||
|
||||
if truncated {
|
||||
truncated_events += 1;
|
||||
}
|
||||
|
||||
if let Some(level) = level.as_deref() {
|
||||
if is_error_level(level) {
|
||||
saw_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, count)) = entries.iter_mut().find(|(msg, _)| msg == &summary) {
|
||||
*count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if entries.len() < MAX_EVENTS_PER_BATCH {
|
||||
entries.push((summary, 1));
|
||||
} else {
|
||||
dropped_events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let total_events = entries.iter().map(|(_, count)| *count).sum::<usize>() + dropped_events;
|
||||
|
||||
CoalescedBatch {
|
||||
entries,
|
||||
total_events,
|
||||
truncated_events,
|
||||
dropped_events,
|
||||
saw_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_batch_message(batch: &CoalescedBatch) -> String {
|
||||
if batch.entries.is_empty() {
|
||||
return "(no bridge events)".to_string();
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let header = if batch.total_events == 1 {
|
||||
"Code Bridge event".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Code Bridge events ({} in last {}s)",
|
||||
batch.total_events,
|
||||
BATCH_WINDOW.as_secs()
|
||||
)
|
||||
};
|
||||
lines.push(header);
|
||||
|
||||
for (msg, count) in batch.entries.iter() {
|
||||
let prefix = if *count > 1 {
|
||||
format!("[{count}x] ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let indented = msg.replace('\n', "\n ");
|
||||
lines.push(format!("- {}{}", prefix, indented));
|
||||
}
|
||||
|
||||
if batch.dropped_events > 0 {
|
||||
lines.push(format!(
|
||||
"(dropped {} events beyond batch limit of {})",
|
||||
batch.dropped_events, MAX_EVENTS_PER_BATCH
|
||||
));
|
||||
}
|
||||
|
||||
if batch.truncated_events > 0 {
|
||||
lines.push(format!(
|
||||
"(truncated {} event bodies to {} chars)",
|
||||
batch.truncated_events, MAX_EVENT_SUMMARY_CHARS
|
||||
));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
async fn flush_batch(session: &Arc<Session>, events: Vec<BridgeBatchEvent>) {
|
||||
let batch = coalesce_events(events);
|
||||
if batch.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = format_batch_message(&batch);
|
||||
session.record_bridge_event(message).await;
|
||||
|
||||
if batch.saw_error {
|
||||
session.start_pending_only_turn_if_idle().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_effective_subscription(state: &SubscriptionState) -> Subscription {
|
||||
// Start with defaults
|
||||
let mut effective = default_subscription();
|
||||
@@ -272,7 +437,7 @@ pub(crate) fn spawn_bridge_listener(session: std::sync::Arc<Session>) {
|
||||
Ok(meta) => {
|
||||
last_notice = None;
|
||||
info!("[bridge] host metadata found, connecting");
|
||||
if let Err(err) = connect_and_listen(meta, &session, &cwd).await {
|
||||
if let Err(err) = connect_and_listen(meta, Arc::clone(&session), &cwd).await {
|
||||
warn!("[bridge] connect failed: {err:?}");
|
||||
}
|
||||
}
|
||||
@@ -503,7 +668,7 @@ fn is_meta_stale(meta: &BridgeMeta, path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn connect_and_listen(meta: BridgeMeta, session: &Session, cwd: &Path) -> Result<()> {
|
||||
async fn connect_and_listen(meta: BridgeMeta, session: Arc<Session>, cwd: &Path) -> Result<()> {
|
||||
let (ws, _) = connect_async(&meta.url).await?;
|
||||
let (mut tx, mut rx) = ws.split();
|
||||
|
||||
@@ -571,11 +736,54 @@ async fn connect_and_listen(meta: BridgeMeta, session: &Session, cwd: &Path) ->
|
||||
.await;
|
||||
}
|
||||
|
||||
let (batch_tx, mut batch_rx) = tokio::sync::mpsc::unbounded_channel::<BridgeBatchEvent>();
|
||||
let session_for_batch = Arc::clone(&session);
|
||||
|
||||
let batch_handle = tokio::spawn(async move {
|
||||
let mut buffer: Vec<BridgeBatchEvent> = Vec::new();
|
||||
let mut deadline: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(item) = batch_rx.recv() => {
|
||||
buffer.push(item);
|
||||
|
||||
if buffer.len() >= MAX_EVENTS_PER_BATCH {
|
||||
flush_batch(&session_for_batch, std::mem::take(&mut buffer)).await;
|
||||
deadline = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if deadline.is_none() {
|
||||
deadline = Some(Instant::now() + BATCH_WINDOW);
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(when) = deadline {
|
||||
sleep_until(when).await;
|
||||
}
|
||||
}, if deadline.is_some() => {
|
||||
if !buffer.is_empty() {
|
||||
flush_batch(&session_for_batch, std::mem::take(&mut buffer)).await;
|
||||
}
|
||||
deadline = None;
|
||||
}
|
||||
else => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !buffer.is_empty() {
|
||||
flush_batch(&session_for_batch, buffer).await;
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(msg) = rx.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
let summary = summarize(&text);
|
||||
session.record_bridge_event(summary).await;
|
||||
let event = summarize_event(&text);
|
||||
let _ = batch_tx.send(event);
|
||||
}
|
||||
Ok(Message::Binary(_)) => {}
|
||||
Ok(Message::Close(_)) => break,
|
||||
@@ -588,6 +796,9 @@ async fn connect_and_listen(meta: BridgeMeta, session: &Session, cwd: &Path) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(batch_tx);
|
||||
let _ = batch_handle.await;
|
||||
// clear sender on exit
|
||||
{
|
||||
let mut guard = CONTROL_SENDER.lock().unwrap();
|
||||
@@ -683,4 +894,71 @@ mod tests {
|
||||
assert!(msg.contains("\"type\":\"subscribe\""));
|
||||
assert!(msg.contains("trace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_coalesces_and_truncates() {
|
||||
let long_msg = "a".repeat(MAX_EVENT_SUMMARY_CHARS + 10);
|
||||
let events = vec![
|
||||
BridgeBatchEvent {
|
||||
summary: "alpha".to_string(),
|
||||
level: Some("info".to_string()),
|
||||
truncated: false,
|
||||
},
|
||||
BridgeBatchEvent {
|
||||
summary: "alpha".to_string(),
|
||||
level: Some("info".to_string()),
|
||||
truncated: false,
|
||||
},
|
||||
BridgeBatchEvent {
|
||||
summary: long_msg.clone(),
|
||||
level: Some("error".to_string()),
|
||||
truncated: true,
|
||||
},
|
||||
];
|
||||
|
||||
let batch = coalesce_events(events);
|
||||
assert_eq!(batch.entries.len(), 2);
|
||||
assert_eq!(batch.entries[0].0, "alpha");
|
||||
assert_eq!(batch.entries[0].1, 2);
|
||||
assert!(batch.saw_error);
|
||||
assert_eq!(batch.truncated_events, 1);
|
||||
assert_eq!(batch.dropped_events, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_enforces_limit_and_marks_error() {
|
||||
let mut events = Vec::new();
|
||||
for idx in 0..(MAX_EVENTS_PER_BATCH + 5) {
|
||||
events.push(BridgeBatchEvent {
|
||||
summary: format!("msg-{idx}"),
|
||||
level: Some(if idx == 0 { "error" } else { "info" }.to_string()),
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
let batch = coalesce_events(events);
|
||||
assert_eq!(batch.entries.len(), MAX_EVENTS_PER_BATCH);
|
||||
assert_eq!(batch.dropped_events, 5);
|
||||
assert!(batch.saw_error);
|
||||
assert_eq!(batch.total_events, MAX_EVENTS_PER_BATCH + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_batch_includes_multipliers() {
|
||||
let batch = CoalescedBatch {
|
||||
entries: vec![
|
||||
("one".to_string(), 1),
|
||||
("two".to_string(), 3),
|
||||
],
|
||||
total_events: 4,
|
||||
truncated_events: 0,
|
||||
dropped_events: 0,
|
||||
saw_error: false,
|
||||
};
|
||||
|
||||
let text = format_batch_message(&batch);
|
||||
assert!(text.contains("Code Bridge events (4 in last"));
|
||||
assert!(text.contains("- one"));
|
||||
assert!(text.contains("- [3x] two"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1825,6 +1825,27 @@ impl Session {
|
||||
state.current_task = Some(agent);
|
||||
}
|
||||
|
||||
pub async fn start_pending_only_turn_if_idle(self: &Arc<Self>) -> bool {
|
||||
let should_start = {
|
||||
let state = self.state.lock().unwrap();
|
||||
state.current_task.is_none()
|
||||
};
|
||||
|
||||
if !should_start {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.cleanup_old_status_items().await;
|
||||
let turn_context = self.make_turn_context();
|
||||
let sub_id = self.next_internal_sub_id();
|
||||
let sentinel_input = vec![InputItem::Text {
|
||||
text: PENDING_ONLY_SENTINEL.to_string(),
|
||||
}];
|
||||
let agent = AgentTask::spawn(Arc::clone(self), turn_context, sub_id, sentinel_input);
|
||||
self.set_task(agent);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn replace_history(&self, items: Vec<ResponseItem>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.history.replace(items);
|
||||
|
||||
@@ -20,6 +20,7 @@ use code_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::auto_drive_style::{BorderGradient, ComposerStyle};
|
||||
use crate::chatwidget::AutoReviewIndicatorStatus;
|
||||
use crate::thread_spawner;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
@@ -34,6 +35,8 @@ use std::sync::atomic::Ordering;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
// Dynamic placeholder rendered when the composer is empty.
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
@@ -93,6 +96,19 @@ struct TokenUsageInfo {
|
||||
initial_prompt_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AutoReviewPhase {
|
||||
Reviewing,
|
||||
Resolving,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AutoReviewFooterStatus {
|
||||
pub(crate) status: AutoReviewIndicatorStatus,
|
||||
pub(crate) findings: Option<usize>,
|
||||
pub(crate) phase: AutoReviewPhase,
|
||||
}
|
||||
|
||||
// Format an integer with thousands separators (e.g., 125,654).
|
||||
fn format_with_thousands(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
@@ -140,6 +156,8 @@ pub(crate) struct ChatComposer {
|
||||
footer_notice: Option<(String, std::time::Instant)>,
|
||||
// Persistent hint for specific modes (e.g., standard terminal mode)
|
||||
standard_terminal_hint: Option<String>,
|
||||
// Auto Review status displayed in the footer
|
||||
auto_review_status: Option<AutoReviewFooterStatus>,
|
||||
// Persistent/ephemeral access-mode indicator shown on the left
|
||||
access_mode_label: Option<String>,
|
||||
access_mode_label_expiry: Option<std::time::Instant>,
|
||||
@@ -208,6 +226,7 @@ impl ChatComposer {
|
||||
custom_prompts: Vec::new(),
|
||||
footer_notice: None,
|
||||
standard_terminal_hint: None,
|
||||
auto_review_status: None,
|
||||
access_mode_label: None,
|
||||
access_mode_label_expiry: None,
|
||||
access_mode_hint_expiry: None,
|
||||
@@ -229,6 +248,15 @@ impl ChatComposer {
|
||||
self.using_chatgpt_auth = using;
|
||||
}
|
||||
|
||||
pub(crate) fn set_auto_review_status(&mut self, status: Option<AutoReviewFooterStatus>) {
|
||||
self.auto_review_status = status;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn auto_review_status(&self) -> Option<AutoReviewFooterStatus> {
|
||||
self.auto_review_status
|
||||
}
|
||||
|
||||
/// Returns true if the input starts with a slash command and the cursor
|
||||
/// is positioned within the command head (i.e., before the first
|
||||
/// whitespace on the first line). Used to decide whether to keep the
|
||||
@@ -429,7 +457,9 @@ impl ChatComposer {
|
||||
let lower = technical_message.to_lowercase();
|
||||
|
||||
// Auto Drive manual edit indicator
|
||||
if lower.contains("auto drive goal") {
|
||||
if lower.contains("auto review") {
|
||||
"Auto Review".to_string()
|
||||
} else if lower.contains("auto drive goal") {
|
||||
"Auto Drive Goal".to_string()
|
||||
} else if lower.contains("auto drive") {
|
||||
"Auto Drive".to_string()
|
||||
@@ -2097,6 +2127,83 @@ impl ChatComposer {
|
||||
matches!(normalized, "Esc" | "Enter" | "Tab" | "Space" | "Backspace")
|
||||
}
|
||||
|
||||
fn auto_review_footer_spans(status: AutoReviewFooterStatus) -> Vec<Span<'static>> {
|
||||
let key_hint_style = Style::default().fg(crate::colors::function());
|
||||
let label_style = Style::default().fg(crate::colors::text_dim());
|
||||
|
||||
let hint_spans = vec![
|
||||
Span::from(" • ").style(label_style),
|
||||
Span::styled("Ctrl+A", key_hint_style),
|
||||
Span::from(" show agents").style(label_style),
|
||||
];
|
||||
|
||||
match status.status {
|
||||
AutoReviewIndicatorStatus::Running => {
|
||||
let phase_label = match status.phase {
|
||||
AutoReviewPhase::Resolving => "Auto Review: Resolving",
|
||||
AutoReviewPhase::Reviewing => "Auto Review: Checking",
|
||||
};
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let phase = (now_ms % 1200) as f32 / 1200.0;
|
||||
let pulse = (std::f32::consts::PI * 2.0 * phase).sin();
|
||||
let fade = 0.5 + 0.5 * pulse;
|
||||
let fade_t = 0.65 - 0.35 * fade;
|
||||
let bullet_fg = crate::colors::mix_toward(
|
||||
crate::colors::success(),
|
||||
crate::colors::background(),
|
||||
fade_t,
|
||||
);
|
||||
let mut spans = vec![
|
||||
Span::from(" "),
|
||||
Span::styled("•", Style::default().fg(bullet_fg)),
|
||||
Span::from(" "),
|
||||
Span::styled(phase_label, Style::default().fg(crate::colors::success())),
|
||||
];
|
||||
spans.extend(hint_spans);
|
||||
spans
|
||||
}
|
||||
AutoReviewIndicatorStatus::Clean => {
|
||||
let mut spans = vec![
|
||||
Span::from(" "),
|
||||
Span::styled("✔", Style::default().fg(crate::colors::success())),
|
||||
Span::from(" "),
|
||||
Span::styled("Auto Review: Correct", Style::default().fg(crate::colors::success())),
|
||||
];
|
||||
spans.extend(hint_spans);
|
||||
spans
|
||||
}
|
||||
AutoReviewIndicatorStatus::Fixed => {
|
||||
let text = if let Some(count) = status.findings {
|
||||
let plural = if count == 1 { "Issue" } else { "Issues" };
|
||||
format!("Auto Review: {count} {plural} Fixed")
|
||||
} else {
|
||||
"Auto Review: Issues Fixed".to_string()
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::from(" "),
|
||||
Span::styled("✔", Style::default().fg(crate::colors::success())),
|
||||
Span::from(" "),
|
||||
Span::styled(text, Style::default().fg(crate::colors::success())),
|
||||
];
|
||||
spans.extend(hint_spans);
|
||||
spans
|
||||
}
|
||||
AutoReviewIndicatorStatus::Failed => {
|
||||
let mut spans = vec![
|
||||
Span::from(" "),
|
||||
Span::styled("✖", Style::default().fg(crate::colors::error())),
|
||||
Span::from(" "),
|
||||
Span::styled("Auto Review: Failed", Style::default().fg(crate::colors::error())),
|
||||
];
|
||||
spans.extend(hint_spans);
|
||||
spans
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn footer_height(&self) -> u16 {
|
||||
if self.render_mode == ComposerRenderMode::FooterOnly {
|
||||
return if self.standard_terminal_hint.is_some() { 1 } else { 0 };
|
||||
@@ -2171,7 +2278,17 @@ impl ChatComposer {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut left_spans: Vec<Span> = vec![Span::from(" ")];
|
||||
let mut left_spans: Vec<Span> = Vec::new();
|
||||
if let Some(status) = self.auto_review_status {
|
||||
left_spans.extend(Self::auto_review_footer_spans(status));
|
||||
if self.auto_drive_active {
|
||||
left_spans.push(
|
||||
Span::from(" • ")
|
||||
.style(Style::default().fg(crate::colors::text_dim())),
|
||||
);
|
||||
}
|
||||
}
|
||||
let primary_left = left_spans.clone();
|
||||
let mut right_spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
let show_access_label = if let Some(until) = self.access_mode_label_expiry {
|
||||
@@ -2382,6 +2499,14 @@ impl ChatComposer {
|
||||
hint_spans = build_hints(include_reasoning, include_diff);
|
||||
}
|
||||
|
||||
if self.auto_review_status.is_some()
|
||||
&& left_len + combined_len(&hint_spans, &token_spans, &auth_spans) + trailing_pad
|
||||
> total_width
|
||||
{
|
||||
left_spans = primary_left.clone();
|
||||
left_len = left_spans.iter().map(|s| s.content.chars().count()).sum();
|
||||
}
|
||||
|
||||
if !hint_spans.is_empty() {
|
||||
if !right_spans.is_empty() {
|
||||
right_spans.push(Span::from(" • ").style(label_style));
|
||||
@@ -2683,3 +2808,46 @@ fn lerp_gradient_color(gradient: BorderGradient, ratio: f32) -> Color {
|
||||
};
|
||||
Color::Rgb(mix(lr, rr), mix(lg, rg), mix(lb, rb))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[test]
|
||||
fn auto_review_status_stays_left_with_auto_drive_footer() {
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<AppEvent>();
|
||||
let app_tx = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, app_tx, true, false);
|
||||
|
||||
composer.auto_drive_active = true;
|
||||
composer.standard_terminal_hint = Some("Esc stop\tCtrl+S settings".to_string());
|
||||
composer.set_auto_review_status(Some(AutoReviewFooterStatus {
|
||||
status: AutoReviewIndicatorStatus::Running,
|
||||
findings: None,
|
||||
phase: AutoReviewPhase::Reviewing,
|
||||
}));
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 64,
|
||||
height: 1,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
composer.render_footer(area, &mut buf);
|
||||
|
||||
let line: String = (0..area.width)
|
||||
.map(|x| buf[(area.x + x, area.y)].symbol.clone())
|
||||
.collect();
|
||||
|
||||
let auto_idx = line
|
||||
.find("Auto Review")
|
||||
.expect("footer should show auto review text");
|
||||
let esc_idx = line.find("Esc stop").unwrap_or(line.len());
|
||||
|
||||
assert!(auto_idx < esc_idx, "Auto Review status should be left-most");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(crate) enum CancellationEvent {
|
||||
Handled,
|
||||
}
|
||||
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::{AutoReviewFooterStatus, AutoReviewPhase, ChatComposer};
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
pub(crate) use auto_coordinator_view::{
|
||||
AutoActiveViewModel,
|
||||
@@ -586,6 +586,16 @@ impl BottomPane<'_> {
|
||||
self.composer.standard_terminal_hint()
|
||||
}
|
||||
|
||||
pub(crate) fn set_auto_review_status(&mut self, status: Option<AutoReviewFooterStatus>) {
|
||||
self.composer.set_auto_review_status(status);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn auto_review_status(&self) -> Option<AutoReviewFooterStatus> {
|
||||
self.composer.auto_review_status()
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
self.composer
|
||||
|
||||
+776
-525
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,6 @@ pub(crate) use plain::{
|
||||
plain_message_state_from_paragraphs,
|
||||
plain_role_for_kind,
|
||||
PlainHistoryCell,
|
||||
AutoReviewStatusCell,
|
||||
};
|
||||
pub(crate) use stream::{stream_lines_from_state, StreamingContentCell};
|
||||
pub(crate) use upgrade::UpgradeNoticeCell;
|
||||
@@ -190,6 +189,41 @@ pub(crate) enum HistoryCellType {
|
||||
Loading,
|
||||
}
|
||||
|
||||
fn gutter_symbol_for_kind(kind: HistoryCellType) -> Option<&'static str> {
|
||||
match kind {
|
||||
HistoryCellType::Plain => None,
|
||||
HistoryCellType::User => Some("›"),
|
||||
// Restore assistant gutter icon
|
||||
HistoryCellType::Assistant => Some("•"),
|
||||
HistoryCellType::Reasoning => None,
|
||||
HistoryCellType::Error => Some("✖"),
|
||||
HistoryCellType::Tool { status } => Some(match status {
|
||||
ToolCellStatus::Running => "⚙",
|
||||
ToolCellStatus::Success => "✔",
|
||||
ToolCellStatus::Failed => "✖",
|
||||
}),
|
||||
HistoryCellType::Exec { kind, status } => {
|
||||
// Show ❯ only for Run executions; hide for read/search/list summaries
|
||||
match (kind, status) {
|
||||
(ExecKind::Run, ExecStatus::Error) => Some("✖"),
|
||||
(ExecKind::Run, _) => Some("❯"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
HistoryCellType::Patch { .. } => Some("↯"),
|
||||
// Plan updates supply their own gutter glyph dynamically.
|
||||
HistoryCellType::PlanUpdate => None,
|
||||
HistoryCellType::BackgroundEvent => Some("»"),
|
||||
HistoryCellType::Notice => Some("★"),
|
||||
HistoryCellType::CompactionSummary => Some("📍"),
|
||||
HistoryCellType::Diff => Some("↯"),
|
||||
HistoryCellType::Image => None,
|
||||
HistoryCellType::Context => Some("◆"),
|
||||
HistoryCellType::AnimatedWelcome => None,
|
||||
HistoryCellType::Loading => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExecKind {
|
||||
Read,
|
||||
@@ -373,38 +407,7 @@ pub(crate) trait HistoryCell {
|
||||
/// Returns the gutter symbol for this cell type
|
||||
/// Returns None if no symbol should be displayed
|
||||
fn gutter_symbol(&self) -> Option<&'static str> {
|
||||
match self.kind() {
|
||||
HistoryCellType::Plain => None,
|
||||
HistoryCellType::User => Some("›"),
|
||||
// Restore assistant gutter icon
|
||||
HistoryCellType::Assistant => Some("•"),
|
||||
HistoryCellType::Reasoning => None,
|
||||
HistoryCellType::Error => Some("✖"),
|
||||
HistoryCellType::Tool { status } => Some(match status {
|
||||
ToolCellStatus::Running => "⚙",
|
||||
ToolCellStatus::Success => "✔",
|
||||
ToolCellStatus::Failed => "✖",
|
||||
}),
|
||||
HistoryCellType::Exec { kind, status } => {
|
||||
// Show ❯ only for Run executions; hide for read/search/list summaries
|
||||
match (kind, status) {
|
||||
(ExecKind::Run, ExecStatus::Error) => Some("✖"),
|
||||
(ExecKind::Run, _) => Some("❯"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
HistoryCellType::Patch { .. } => Some("↯"),
|
||||
// Plan updates supply their own gutter glyph dynamically.
|
||||
HistoryCellType::PlanUpdate => None,
|
||||
HistoryCellType::BackgroundEvent => Some("»"),
|
||||
HistoryCellType::Notice => Some("★"),
|
||||
HistoryCellType::CompactionSummary => Some("📍"),
|
||||
HistoryCellType::Diff => Some("↯"),
|
||||
HistoryCellType::Image => None,
|
||||
HistoryCellType::Context => Some("◆"),
|
||||
HistoryCellType::AnimatedWelcome => None,
|
||||
HistoryCellType::Loading => None,
|
||||
}
|
||||
gutter_symbol_for_kind(self.kind())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::history::state::{
|
||||
TextEmphasis,
|
||||
TextTone,
|
||||
};
|
||||
use crate::colors;
|
||||
use crate::theme::{current_theme, Theme};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -53,6 +54,17 @@ pub(crate) struct PlainHistoryCell {
|
||||
}
|
||||
|
||||
impl PlainHistoryCell {
|
||||
fn is_auto_review_notice(&self) -> bool {
|
||||
self.state
|
||||
.header()
|
||||
.map(|h| h.label.to_lowercase().contains("auto review"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn auto_review_bg() -> ratatui::style::Color {
|
||||
// Blend success toward background for a light tint similar to assistant cards
|
||||
colors::mix_toward(colors::success(), colors::background(), 0.82)
|
||||
}
|
||||
pub(crate) fn from_state(state: PlainMessageState) -> Self {
|
||||
let mut kind = history_cell_kind_from_plain(state.kind);
|
||||
if kind == HistoryCellType::User {
|
||||
@@ -125,9 +137,11 @@ impl PlainHistoryCell {
|
||||
};
|
||||
}
|
||||
|
||||
let is_auto_review = self.is_auto_review_notice();
|
||||
let cell_bg = match self.state.kind {
|
||||
HistoryCellType::Assistant => crate::colors::assistant_bg(),
|
||||
HistoryCellType::CompactionSummary => crate::colors::background(),
|
||||
_ if is_auto_review => Self::auto_review_bg(),
|
||||
_ => crate::colors::background(),
|
||||
};
|
||||
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
|
||||
@@ -135,10 +149,17 @@ impl PlainHistoryCell {
|
||||
let trimmed_lines = self.display_lines_trimmed();
|
||||
let text = Text::from(trimmed_lines.clone());
|
||||
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
||||
let height: u16 = paragraph
|
||||
|
||||
let pad_top = if is_auto_review { 1 } else { 0 };
|
||||
let pad_bottom = if is_auto_review { 1 } else { 0 };
|
||||
|
||||
let inner_height: u16 = paragraph
|
||||
.line_count(effective_width)
|
||||
.try_into()
|
||||
.unwrap_or(0);
|
||||
let height = inner_height
|
||||
.saturating_add(pad_top)
|
||||
.saturating_add(pad_bottom);
|
||||
|
||||
if height == 0 {
|
||||
return PlainLayoutCache {
|
||||
@@ -152,7 +173,8 @@ impl PlainHistoryCell {
|
||||
let render_height = height.max(1);
|
||||
let render_area = Rect::new(0, 0, requested_width, render_height);
|
||||
let mut buffer = Buffer::empty(render_area);
|
||||
fill_rect(&mut buffer, render_area, Some(' '), bg_style);
|
||||
// Paint full cell (including padding) with the cell background so tint extends through padding.
|
||||
fill_rect(&mut buffer, render_area, Some(' '), Style::default().bg(cell_bg).fg(crate::colors::text()));
|
||||
|
||||
let paragraph_lines = Text::from(trimmed_lines);
|
||||
if matches!(self.state.kind, HistoryCellType::User) {
|
||||
@@ -171,11 +193,22 @@ impl PlainHistoryCell {
|
||||
.render(render_area, &mut buffer);
|
||||
} else {
|
||||
let block = Block::default().style(Style::default().bg(cell_bg));
|
||||
let inner_area = if pad_top + pad_bottom < render_height {
|
||||
Rect::new(
|
||||
render_area.x,
|
||||
render_area.y.saturating_add(pad_top),
|
||||
render_area.width,
|
||||
render_height.saturating_sub(pad_top + pad_bottom),
|
||||
)
|
||||
} else {
|
||||
render_area
|
||||
};
|
||||
|
||||
Paragraph::new(paragraph_lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.style(Style::default().bg(cell_bg))
|
||||
.render(render_area, &mut buffer);
|
||||
.render(inner_area, &mut buffer);
|
||||
}
|
||||
|
||||
PlainLayoutCache {
|
||||
@@ -216,6 +249,16 @@ impl HistoryCell for PlainHistoryCell {
|
||||
self.state.kind
|
||||
}
|
||||
|
||||
fn gutter_symbol(&self) -> Option<&'static str> {
|
||||
if let Some(header) = self.state.header() {
|
||||
let label = header.label.trim().to_lowercase();
|
||||
if label == "auto review" {
|
||||
return Some("⮂");
|
||||
}
|
||||
}
|
||||
super::gutter_symbol_for_kind(self.kind())
|
||||
}
|
||||
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
let theme = current_theme();
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
@@ -258,11 +301,13 @@ impl HistoryCell for PlainHistoryCell {
|
||||
requested_width
|
||||
};
|
||||
|
||||
let is_auto_review = self.is_auto_review_notice();
|
||||
let cell_bg = match self.state.kind {
|
||||
HistoryCellType::Assistant => crate::colors::assistant_bg(),
|
||||
_ if is_auto_review => Self::auto_review_bg(),
|
||||
_ => crate::colors::background(),
|
||||
};
|
||||
if matches!(self.state.kind, HistoryCellType::Assistant) {
|
||||
if matches!(self.state.kind, HistoryCellType::Assistant) || is_auto_review {
|
||||
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
|
||||
fill_rect(buf, area, Some(' '), bg_style);
|
||||
}
|
||||
@@ -307,83 +352,6 @@ impl HistoryCell for PlainHistoryCell {
|
||||
trim_empty_lines(self.display_lines())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AutoReviewStatusCell {
|
||||
inner: PlainHistoryCell,
|
||||
gutter: &'static str,
|
||||
}
|
||||
|
||||
impl AutoReviewStatusCell {
|
||||
pub(crate) fn new(state: PlainMessageState, gutter: &'static str) -> Self {
|
||||
Self {
|
||||
inner: PlainHistoryCell::from_state(state),
|
||||
gutter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for AutoReviewStatusCell {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn kind(&self) -> HistoryCellType {
|
||||
self.inner.kind()
|
||||
}
|
||||
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
self.inner.display_lines()
|
||||
}
|
||||
|
||||
fn display_lines_trimmed(&self) -> Vec<Line<'static>> {
|
||||
self.inner.display_lines_trimmed()
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.inner.desired_height(width)
|
||||
}
|
||||
|
||||
fn render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
||||
self.inner.render_with_skip(area, buf, skip_rows)
|
||||
}
|
||||
|
||||
fn has_custom_render(&self) -> bool {
|
||||
self.inner.has_custom_render()
|
||||
}
|
||||
|
||||
fn custom_render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.inner.custom_render(area, buf)
|
||||
}
|
||||
|
||||
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
||||
self.inner.custom_render_with_skip(area, buf, skip_rows)
|
||||
}
|
||||
|
||||
fn is_animating(&self) -> bool {
|
||||
self.inner.is_animating()
|
||||
}
|
||||
|
||||
fn is_loading_cell(&self) -> bool {
|
||||
self.inner.is_loading_cell()
|
||||
}
|
||||
|
||||
fn trigger_fade(&self) {
|
||||
self.inner.trigger_fade()
|
||||
}
|
||||
|
||||
fn should_remove(&self) -> bool {
|
||||
self.inner.should_remove()
|
||||
}
|
||||
|
||||
fn gutter_symbol(&self) -> Option<&'static str> {
|
||||
Some(self.gutter)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlainMessageStateBuilder;
|
||||
|
||||
impl PlainMessageStateBuilder {
|
||||
|
||||
Reference in New Issue
Block a user