diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 952831e8..c4340f67 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -714,6 +714,12 @@ pub struct AgentSessionState { pub cwd: Option, #[serde(default)] pub activity: u64, + /// How many turns this session has finished: bumped each time it settles + /// into `Done`. The status alone cannot tell a client that attaches to a + /// `Done` pane whether that is the turn it already showed the reader or a + /// later one that finished while nobody was watching (#870). + #[serde(default)] + pub turns: u64, } impl AgentStatus { @@ -767,6 +773,9 @@ impl AgentSessionState { } } AgentEventKind::Stop => { + if self.status != AgentStatus::Done { + self.turns = self.turns.wrapping_add(1); + } self.status = AgentStatus::Done; self.message = ev.message.clone(); } @@ -1273,6 +1282,36 @@ mod tests { assert_eq!(s.activity, 4); } + #[test] + fn each_finished_turn_is_counted_once() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + assert_eq!(s.turns, 0, "a turn starting has not finished anything"); + + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1, "a repeated stop is the same turn"); + s.apply_event(&ev(AgentEventKind::Notification)); + assert_eq!(s.turns, 1); + + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 2, "a second turn is a second count"); + + s.apply_event(&ev(AgentEventKind::SessionEnd)); + assert_eq!(s.turns, 2); + } + #[test] fn session_state_tracks_and_releases_the_agent_cwd() { use std::path::PathBuf; diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index d8b01dd4..dc47d25c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1607,6 +1607,7 @@ mod tests { rich: true, cwd: Some("/work/api".into()), activity: 3, + turns: 1, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 29a0e542..7b6add69 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -5125,6 +5125,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 8bdad16f..0538c114 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1840,6 +1840,7 @@ mod tests { rich: true, cwd: Some("/repo/.claude/worktrees/fix-x".into()), activity: 12, + turns: 4, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/src/terminal/element.rs b/src/terminal/element.rs index c1a5b372..2d05b999 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -249,11 +249,46 @@ fn snapshot_cell( rc.selected = true; } if flags.contains(Flags::DIM) { - rc.fg.a *= DIM_OPACITY; + rc.fg = dim_fg(rc.fg, bgc, colors.legible_dim); } rc } +/// SGR 2's colour: the ink at `DIM_OPACITY` over its cell, or — on a light +/// cell where that fade would be illegible — the opaque ink +/// `presets::legible_dim` solved for. `legible` is Settings → Appearance's +/// palette switch; off keeps the plain fade everywhere. +fn dim_fg(fg: Hsla, under: Rgb, legible: bool) -> Hsla { + let mut faded = fg; + faded.a *= DIM_OPACITY; + if !legible { + return faded; + } + // A faint run is one (ink, background) pair repeated across the row, and + // the rescue is a contrast bisection — remember the last answer so a + // screen of dim text costs one solve per colour change, not one per cell. + thread_local! { + static LAST: std::cell::Cell)>> = + const { std::cell::Cell::new(None) }; + } + let (ink, bg) = (pack_rgb(super::palette::hsla_to_rgb(fg)), pack_rgb(under)); + let solved = LAST.with(|last| match last.get() { + Some((i, b, s)) if i == ink && b == bg => s, + _ => { + let s = crate::ui::presets::legible_dim(ink, bg, DIM_OPACITY); + last.set(Some((ink, bg, s))); + s + } + }); + match solved { + Some(c) => Hsla { + a: fg.a, + ..to_hsla(unpack_rgb(c)) + }, + None => faded, + } +} + fn active_selection_bg(cx: &gpui::App) -> Rgb { match cx.try_global::() { Some(a) => a.sel_bg, @@ -305,6 +340,9 @@ pub(super) struct PaintColors { current_match_bg: Hsla, fg_rgb: Rgb, bg_rgb: Rgb, + /// Whether faint text on a light cell is held at the text floor (see + /// `dim_fg`). Mirrors `theme_legible_palette`. + legible_dim: bool, } /// The under-colour a dimmed pane blends its content toward: the window @@ -444,6 +482,9 @@ impl PaintColors { current_match_bg, fg_rgb, bg_rgb, + legible_dim: cx + .try_global::() + .is_none_or(|c| c.theme_legible_palette), } } @@ -466,6 +507,7 @@ impl PaintColors { current_match_bg: blend_toward(self.current_match_bg, dim, under), fg_rgb: self.fg_rgb, bg_rgb: self.bg_rgb, + legible_dim: self.legible_dim, } } } @@ -2412,6 +2454,7 @@ mod tests { selection_bg: Hsla::default(), match_bg: Hsla::default(), current_match_bg: Hsla::default(), + legible_dim: true, fg_rgb: Rgb { r: 17, g: 17, @@ -3600,6 +3643,7 @@ mod tests { current_match_bg: wash(0.85), fg_rgb: fg, bg_rgb: bg, + legible_dim: true, } } @@ -3722,6 +3766,167 @@ mod tests { ); } + /// What a cell's foreground lands on screen as: its rgb composited over + /// the cell background by its alpha, the way the glyph is actually drawn. + fn on_screen(fg: Hsla, under: Rgb) -> u32 { + let c = Rgba::from(fg); + let ch = |v: f32, u: u8| ((v * c.a + (u as f32 / 255.) * (1. - c.a)) * 255.).round() as u32; + ch(c.r, under.r) << 16 | ch(c.g, under.g) << 8 | ch(c.b, under.b) + } + + /// The colours a pane on builtin `t` resolves cells with. + fn builtin_colors(t: &crate::ui::presets::Theme) -> (PaintColors, [Rgb; 256]) { + let (fg, bg) = (unpack_rgb(t.foreground), unpack_rgb(t.background_color())); + let mut colors = test_colors(); + colors.default_fg = to_hsla(fg); + colors.default_bg = to_hsla(bg); + colors.fg_rgb = fg; + colors.bg_rgb = bg; + let mut palette = super::super::palette::build(); + palette[..16].copy_from_slice(&t.active_palette(true).ansi16); + (colors, palette) + } + + /// Every colour faint text is commonly written in: the default foreground, + /// the sixteen palette slots, the 256-colour greys apps reach for as + /// "muted", and a truecolour grey. + fn faint_samples() -> Vec<(String, AnsiColor)> { + let mut v = vec![("fg".to_string(), AnsiColor::Named(NamedColor::Foreground))]; + for i in 0..16u8 { + v.push((format!("ansi{i}"), AnsiColor::Indexed(i))); + } + for i in [240u8, 244, 248, 250] { + v.push((format!("256:{i}"), AnsiColor::Indexed(i))); + } + v.push(( + "#999999".to_string(), + AnsiColor::Spec(Rgb { + r: 153, + g: 153, + b: 153, + }), + )); + v + } + + /// (plain, faint) on-screen colours of `color` as a pane on `t` paints them. + fn plain_and_faint( + colors: &PaintColors, + palette: &[Rgb; 256], + color: AnsiColor, + ) -> (RenderCell, RenderCell) { + let point = AlacPoint::new(AlacLine(0), AlacColumn(0)); + let mut cell = Cell { + c: 'x', + fg: color, + ..Cell::default() + }; + let plain = snapshot_cell(&cell, point, palette, colors, None); + cell.flags = Flags::DIM; + let faint = snapshot_cell(&cell, point, palette, colors, None); + (plain, faint) + } + + #[test] + fn faint_text_keeps_the_text_floor_on_every_light_builtin() { + // #858: SGR 2 painted the ink at 66% over the cell, which on a light + // background took Catppuccin Latte's foreground to 3.2:1 and every + // bright-black the palette rescue had lifted to 4.5:1 back to ~2.5:1. + // Faint text on a light cell must now clear 4.5:1 — or, for an ink + // that never cleared it undimmed, stay at the ink's own ratio. + use crate::ui::presets::{builtins, contrast}; + let mut failures = Vec::new(); + eprintln!("| theme | colour | plain | faint |"); + for t in builtins().into_iter().filter(|t| !t.dark) { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + eprintln!("| {} | {name} | {plain:.2} | {faint:.2} |", t.id); + if faint < 4.5_f32.min(plain) - 0.05 { + failures.push(format!( + "{}/{name}: faint {faint:.2}:1 (plain {plain:.2}:1)", + t.id + )); + } + } + } + assert!( + failures.is_empty(), + "faint text under the floor:\n{}", + failures.join("\n") + ); + } + + #[test] + fn faint_text_on_dark_builtins_is_the_plain_fade() { + // The floor is a light-background rescue: every dark builtin must + // keep painting SGR 2 exactly as it did, as the ink at DIM_OPACITY. + for t in crate::ui::presets::builtins() + .into_iter() + .filter(|t| t.dark) + { + let (colors, palette) = builtin_colors(&t); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!( + faint.fg, fade, + "{}/{name}: dark theme faint text changed", + t.id + ); + } + } + } + + #[test] + fn faint_text_stays_fainter_than_plain_text() { + // The rescue buys legibility, not a restyle: faint text never gains + // contrast over its own ink, and an ink with room to spare above the + // floor still reads visibly fainter. + use crate::ui::presets::{builtins, contrast}; + for t in builtins() { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + assert!( + faint <= plain + 0.01, + "{}/{name}: faint {faint:.2} > plain {plain:.2}", + t.id + ); + if plain >= 6.0 { + assert!( + faint <= plain * 0.85, + "{}/{name}: faint {faint:.2} no longer reads fainter than {plain:.2}", + t.id + ); + } + } + } + } + + #[test] + fn faint_text_is_the_plain_fade_with_the_legibility_switch_off() { + // Settings → Appearance's palette switch off renders colours as + // authored; that includes faint text. + for t in crate::ui::presets::builtins() { + let (mut colors, palette) = builtin_colors(&t); + colors.legible_dim = false; + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!(faint.fg, fade, "{}/{name}: switch off still rescued", t.id); + } + } + } + #[test] fn blend_toward_mixes_in_rgb_space_and_keeps_alpha() { let under = Rgba { diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 22355864..69832f65 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -5841,6 +5841,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 95ebfea1..93803eab 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -191,6 +191,26 @@ pub struct ShellParts { pub(crate) owner: Option, } +/// What the reader was last shown of each pane's finished agent turn, kept for +/// the life of the app rather than of a view. +/// +/// A view is thrown away and built again over the same daemon pane whenever a +/// workspace is switched out and back or a window is reopened from the tray, +/// and a fresh view sees a `Done` agent arrive from nothing — exactly what a +/// turn finishing live looks like. This is how the new view tells the two apart +/// (#870). +#[derive(Default)] +struct AgentReadMarks(std::collections::HashMap<(crate::ui::host_ops::HostId, u64), AgentReadMark>); + +impl gpui::Global for AgentReadMarks {} + +#[derive(Clone)] +struct AgentReadMark { + session: (Option, Option>), + turns: u64, + unread: bool, +} + #[derive(Clone, Copy)] struct DragScroll { overshoot: f32, @@ -381,6 +401,9 @@ pub struct TerminalView { /// time, however fast the pane is printing. pub(super) search_scan_armed: bool, pub bell_flash: bool, + /// Bumped by every bell, so only the timer armed by the latest one clears + /// the flash: a burst of bells holds one steady flash instead of strobing. + bell_epoch: u64, pub report_mouse: bool, last_at_prompt: bool, last_typeahead_blocked: bool, @@ -393,6 +416,9 @@ pub struct TerminalView { agent_was_rich: bool, agent_result_unread: bool, keep_unread_on_focus: bool, + /// Whether this view has seen its pane's agent status move at all. The + /// first move is where a rebuilt view consults [`AgentReadMarks`]. + agent_status_seen: bool, git_status_cwd: Option, last_agent_activity: u64, cmd: CmdEditor, @@ -1428,6 +1454,7 @@ impl TerminalView { view.keep_unread_on_focus = false; } else { view.agent_result_unread = false; + view.note_agent_result_unread(cx); } view.report_focus_change(true); cx.notify(); @@ -1577,6 +1604,7 @@ impl TerminalView { search_scan_epoch: 0, search_scan_armed: false, bell_flash: false, + bell_epoch: 0, last_at_prompt: false, last_typeahead_blocked: false, running_since: None, @@ -1588,6 +1616,7 @@ impl TerminalView { agent_was_rich: false, agent_result_unread: false, keep_unread_on_focus: false, + agent_status_seen: false, git_status_cwd: None, last_agent_activity: 0, cmd: CmdEditor::new(), @@ -1867,9 +1896,41 @@ impl TerminalView { self.agent_result_unread } - pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) { + pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool, cx: &mut App) { self.agent_result_unread = true; self.keep_unread_on_focus = refocus_incoming; + self.note_agent_result_unread(cx); + } + + /// Leave what the reader has seen of this pane's agent where the pane's + /// next view will look for it — see [`AgentReadMarks`]. Anything but a + /// finished turn drops the mark: whatever finishes next is news. + fn record_agent_read_mark(&self, turns: u64, cx: &mut App) { + let key = (self.host_id, self.pane_id); + let marks = &mut cx.default_global::().0; + if self.last_agent_status == Some(crate::core::cli_agent::AgentStatus::Done) { + marks.insert( + key, + AgentReadMark { + session: self.last_agent_session.clone(), + turns, + unread: self.agent_result_unread, + }, + ); + } else { + marks.remove(&key); + } + } + + /// Carry a change to the badge alone into the mark the last status left. + fn note_agent_result_unread(&self, cx: &mut App) { + if !cx.has_global::() { + return; + } + let key = (self.host_id, self.pane_id); + if let Some(mark) = cx.global_mut::().0.get_mut(&key) { + mark.unread = self.agent_result_unread; + } } pub fn git_status(&self, cx: &App) -> Option { @@ -2980,6 +3041,8 @@ impl TerminalView { } fn flash_bell(&mut self, cx: &mut Context) { + self.bell_epoch += 1; + let epoch = self.bell_epoch; self.bell_flash = true; cx.notify(); cx.spawn(async move |this, cx| { @@ -2987,6 +3050,12 @@ impl TerminalView { .timer(std::time::Duration::from_millis(150)) .await; let _ = this.update(cx, |view, cx| { + // A bell rung since this one owns the flash now. Holding + // Backspace on an empty bash prompt rings at key-repeat rate, + // and clearing here would blank it every few frames (#874). + if view.bell_epoch != epoch { + return; + } view.bell_flash = false; cx.notify(); }); @@ -3920,6 +3989,30 @@ impl TerminalView { return false; } let prev = std::mem::replace(&mut self.last_agent_status, status); + let first_sight = !std::mem::replace(&mut self.agent_status_seen, true); + let turns = session.as_ref().map_or(0, |s| s.turns); + + // A view built over a pane that already holds a finished turn sees + // `Done` arrive from nothing, the same as a turn finishing now. If the + // pane's previous view left a mark for this session at this turn count, + // it is the turn the reader was already shown: take their badge back as + // they left it instead of raising a new one (#870). A turn that + // finished after the old view went has no such mark, or a lower count. + if first_sight + && status == Some(AgentStatus::Done) + && let Some(mark) = cx + .try_global::() + .and_then(|marks| marks.0.get(&(self.host_id, self.pane_id))) + .filter(|mark| mark.session == self.last_agent_session && mark.turns == turns) + .cloned() + { + self.agent_result_unread = mark.unread && !self.focus_handle.is_focused(window); + self.keep_unread_on_focus = false; + self.record_agent_read_mark(turns, cx); + cx.notify(); + return false; + } + let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done); match status { @@ -3939,6 +4032,7 @@ impl TerminalView { self.keep_unread_on_focus = false; } } + self.record_agent_read_mark(turns, cx); let rich = session.as_ref().is_some_and(|s| s.rich); let agent_name = self @@ -9897,6 +9991,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(daemon) .unwrap(); @@ -9952,6 +10047,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); @@ -9996,10 +10092,21 @@ mod gpui_tests { pane: &gpui::Entity, cx: &mut TestAppContext, daemon: &mut Stream, + ) { + report_agent_turn(status, 0, pane, cx, daemon); + } + + /// The same, for a session that has finished `turns` turns so far. + fn report_agent_turn( + status: crate::core::cli_agent::AgentStatus, + turns: u64, + pane: &gpui::Entity, + cx: &mut TestAppContext, + daemon: &mut Stream, ) { use crate::core::cli_agent::AgentSessionState; - DaemonMsg::AgentStatus(Some(AgentSessionState { + let state = AgentSessionState { status, message: None, session_id: Some("sid-abc".into()), @@ -10007,13 +10114,13 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, - })) - .encode(daemon) - .unwrap(); + turns, + }; + DaemonMsg::AgentStatus(Some(state.clone())) + .encode(daemon) + .unwrap(); for _ in 0..200 { - if cx.update(|cx| pane.read(cx).terminal.agent_session().map(|s| s.status)) - == Some(status) - { + if cx.update(|cx| pane.read(cx).terminal.agent_session()) == Some(state.clone()) { return; } std::thread::sleep(std::time::Duration::from_millis(5)); @@ -10021,6 +10128,161 @@ mod gpui_tests { panic!("the agent status never reached the pane"); } + /// Poll `pane`'s agent status inside `window` and read its badge back. + fn poll_unread( + window: gpui::WindowHandle, + pane: &gpui::Entity, + cx: &mut TestAppContext, + ) -> bool { + // Through the untyped handle: the typed one leases the root view, and + // `pane` may be that view. + cx.update_window(window.into(), |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + pane.agent_result_unread() + }) + }) + .unwrap() + } + + /// Switching workspaces, or reopening a window from the tray, throws the + /// pane's view away and builds a new one on the same daemon pane (#870). + /// The new view's first look at a `Done` agent is not a turn finishing — + /// the reader watched that one finish before the old view went. + #[gpui::test] + fn a_rebuilt_pane_does_not_re_badge_a_turn_the_reader_already_saw(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!( + !poll_unread(window, &before, cx), + "the reader watched it finish" + ); + + // The same daemon pane, rebuilt the way `tabs_from_session` rebuilds it, + // with the reader's focus somewhere else. + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + !poll_unread(window, &after, cx), + "rebuilding the pane is not a turn finishing" + ); + } + + /// What the rebuild must not swallow: a turn that was still running when + /// the view went away and finished before the new one arrived. + #[gpui::test] + fn a_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Working, 0, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "nobody saw this turn finish" + ); + } + + /// Nor a whole later turn: the reader saw turn one, the agent was sent + /// another and finished it while the pane was away. The status reads + /// `Done` both times; only the turn count tells them apart. + #[gpui::test] + fn a_later_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 2, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "the second turn finished unseen" + ); + } + + /// And a badge the reader had not cleared yet comes back with the pane. + #[gpui::test] + fn an_unread_turn_is_still_unread_after_the_pane_is_rebuilt(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + // Building another pane takes the window's focus off `before`. + let (_elsewhere, _elsewhere_daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(5, window, cx)) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(poll_unread(window, &before, cx), "nobody was looking"); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "rebuilding the pane is not reading it" + ); + } + /// The badge answers "did the reader see this?", so it has to read the /// window's live focus rather than anything a focus callback left behind. /// @@ -10240,6 +10502,7 @@ mod gpui_tests { rich: true, cwd: Some(working_in.clone()), activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); @@ -14459,6 +14722,40 @@ mod gpui_tests { .unwrap(); } + /// Holding Backspace on an empty bash prompt (or Tab with nothing to + /// complete) rings the bell at key-repeat rate. Every flash used to arm its + /// own clear timer, so the first bell's timer blanked a flash the fifth bell + /// had just re-lit, and the pane strobed for as long as the key was held + /// (#874). + #[gpui::test] + fn a_bell_rung_at_key_repeat_rate_holds_one_steady_flash(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + let lit = + |cx: &mut TestAppContext| window.update(cx, |view, _, _| view.bell_flash).unwrap(); + + let repeat = std::time::Duration::from_millis(33); + let mut dark = Vec::new(); + for i in 0..30 { + window + .update(cx, |view, _, cx| view.handle_event(AlacEvent::Bell, cx)) + .unwrap(); + cx.executor().advance_clock(repeat); + cx.run_until_parked(); + if !lit(cx) { + dark.push(i); + } + } + assert!( + dark.is_empty(), + "the flash went dark between bells after repeats {dark:?}" + ); + + cx.executor() + .advance_clock(std::time::Duration::from_millis(300)); + cx.run_until_parked(); + assert!(!lit(cx), "the flash outlived the last bell"); + } + #[gpui::test] fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); diff --git a/src/ui/app.rs b/src/ui/app.rs index 54d5a7eb..254ec1f3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4834,7 +4834,7 @@ impl Tty7App { refocus.as_ref().map(|s| s.entity_id()) == Some(leaf.entity_id()); leaf.update(cx, |view, cx| { if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) { - view.mark_agent_result_unread(refocus_incoming); + view.mark_agent_result_unread(refocus_incoming, cx); cx.notify(); } }); diff --git a/src/ui/presets.rs b/src/ui/presets.rs index f2f0a72f..2da3e61d 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -523,12 +523,41 @@ fn channel_distance(a: u32, b: u32) -> u32 { d(16).max(d(8)).max(d(0)) } -fn contrast(a: u32, b: u32) -> f32 { +pub(crate) fn contrast(a: u32, b: u32) -> f32 { let (l1, l2) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) }; (hi + 0.05) / (lo + 0.05) } +/// The opaque ink SGR 2 (faint) text is painted in on a light cell, when the +/// plain fade would drop it under the text floor — `None` when the fade is +/// already legible and should stay a fade. +/// +/// Faint text is `opacity` of its ink over the cell. That costs a fixed share +/// of the ink's luminance distance, and on a light background the ratio that +/// distance buys collapses fast: Catppuccin Latte's own foreground fades from +/// 7.1:1 to 3.2:1, and every bright-black the palette rescue lifted to 4.5:1 +/// fades back to 2.5:1 — the "illegible secondary text" of #858. So the fade +/// is walked back toward the ink until it clears `TEXT_FLOOR` again, capped at +/// the ink's own ratio: text that was never above the floor is left as dim as +/// it would have been undimmed, not darkened past what the app asked for. +/// +/// Light backgrounds only. Dark themes read the same faint text at the same +/// ratios without complaint, and keeping them byte-for-byte as they were is +/// worth more than a symmetric rule nobody asked for. The test is the cell's +/// own background, so a light cell inside a dark theme is rescued too. +pub(crate) fn legible_dim(ink: u32, bg: u32, opacity: f32) -> Option { + if is_dark(bg) { + return None; + } + let faded = mix(bg, ink, opacity); + let floor = TEXT_FLOOR.min(contrast(ink, bg)); + if contrast(faded, bg) >= floor { + return None; + } + Some(bisect_contrast(faded, ink, bg, floor)) +} + fn is_dark(bg: u32) -> bool { relative_luminance(bg) < 0.5 }