Merge remote-tracking branch 'origin/main' into fix/unread-badge-on-reattach

This commit is contained in:
l0ng-ai
2026-09-15 23:03:29 +08:00
9 changed files with 585 additions and 11 deletions
+39
View File
@@ -714,6 +714,12 @@ pub struct AgentSessionState {
pub cwd: Option<std::path::PathBuf>,
#[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;
+1
View File
@@ -1607,6 +1607,7 @@ mod tests {
rich: true,
cwd: Some("/work/api".into()),
activity: 3,
turns: 1,
},
}])),
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
+1
View File
@@ -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!(
+1
View File
@@ -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 }),
+206 -1
View File
@@ -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<Option<(u32, u32, Option<u32>)>> =
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::<crate::terminal::palette::ActivePalette>() {
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::<Config>()
.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 {
+1
View File
@@ -5841,6 +5841,7 @@ mod tests {
rich: true,
cwd: None,
activity: 0,
turns: 0,
}))
.encode(&mut daemon_side)
.unwrap();
+305 -8
View File
@@ -191,6 +191,26 @@ pub struct ShellParts {
pub(crate) owner: Option<crate::core::session::WorkspaceId>,
}
/// 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<String>, Option<Vec<String>>),
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<std::path::PathBuf>,
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::<AgentReadMarks>().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::<AgentReadMarks>() {
return;
}
let key = (self.host_id, self.pane_id);
if let Some(mark) = cx.global_mut::<AgentReadMarks>().0.get_mut(&key) {
mark.unread = self.agent_result_unread;
}
}
pub fn git_status(&self, cx: &App) -> Option<crate::terminal::git_status::GitStatus> {
@@ -2980,6 +3041,8 @@ impl TerminalView {
}
fn flash_bell(&mut self, cx: &mut Context<Self>) {
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::<AgentReadMarks>()
.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<TerminalView>,
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<TerminalView>,
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<TerminalView>,
pane: &gpui::Entity<TerminalView>,
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);
+1 -1
View File
@@ -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();
}
});
+30 -1
View File
@@ -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<u32> {
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
}