chore(auto-review): rename worktree and footer labels

This commit is contained in:
James Peter
2025-12-03 17:23:34 +10:00
parent 363c5e2605
commit 4bdfe49ecc
7 changed files with 1347 additions and 648 deletions
+284 -6
View File
@@ -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"));
}
}
+21
View File
@@ -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);
+170 -2
View File
@@ -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");
}
}
+11 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+36 -33
View File
@@ -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())
}
}
+49 -81
View File
@@ -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 {