mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge remote-tracking branch 'origin/main' into fix/unread-badge-on-reattach
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1607,6 +1607,7 @@ mod tests {
|
||||
rich: true,
|
||||
cwd: Some("/work/api".into()),
|
||||
activity: 3,
|
||||
turns: 1,
|
||||
},
|
||||
}])),
|
||||
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -5841,6 +5841,7 @@ mod tests {
|
||||
rich: true,
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
}))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
|
||||
+305
-8
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user