Skip to content

Commit 54bc607

Browse files
committed
fix(tui): address review feedback on unread feature
CodeRabbit review on agent-of-empires#2088: - Promote unread in `attention_group_key` too, so project-grouped Attention agrees with the flat view (a group with an unread Idle now outranks a group whose best member is a read Error). - Map `v`/`V` to `ToggleUnread` in the context menu's `handle_key`, with a regression test; the quick-pick only fires when the row is present. - Gate the `v` binding behind the feature: a new `Context::UnreadEnabled` drops it from dispatch when off, and the help overlay and command palette skip it, so disabling the setting removes it from every surface (not just a no-op handler). - Clear auto-unread unconditionally (only when an `Auto` flag is present) so a stale marker can't survive a disable/re-enable; `Manual` is still preserved. - Fall back an omitted theme `unread` to that theme's own `accent` instead of Empire's default, via a load-time `fill_unread_from_accent`. - Lighten catppuccin-latte's `unread` so it stays below Waiting in that light theme (waiting > unread > idle). 🤖 Generated with Claude Code
1 parent 7dc71d5 commit 54bc607

9 files changed

Lines changed: 109 additions & 17 deletions

File tree

src/session/groups.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -686,9 +686,15 @@ fn attention_group_key(
686686
// contribute; the real tiebreak is the trailing ASC field. Shape
687687
// mirrors `attention_session_key` so the intent is uniform.
688688
let max_last = members.iter().filter_map(|i| i.last_accessed_at).max();
689+
// Fold the unread promoter in at the group level too, so a project
690+
// containing an unread session floats up just like the flat Attention
691+
// view does (a group with an unread Idle outranks a group whose best
692+
// member is a read Error). Mirrors `attention_session_key`'s rank.
693+
let unread = members.iter().any(|i| i.is_unread());
694+
let rank = attention_rank(min_tier, unread, crate::session::unread_enabled());
689695
(
690696
!urgent_bias,
691-
min_tier,
697+
rank,
692698
!favorite_bias,
693699
max_last.is_none(),
694700
Reverse(None),
@@ -1924,9 +1930,11 @@ mod tests {
19241930

19251931
#[test]
19261932
fn test_attention_group_key_one_active_pulls_group_up() {
1927-
// Mixed group: 1 archived Waiting, 1 active Idle. Group should sort
1928-
// at tier 2 (Idle); the active member pulls it out of the archive
1929-
// tier. This is the auto-unarchive contract for cascade.
1933+
// Mixed group: 1 archived Waiting, 1 active Idle. The active member
1934+
// pulls the group out of the archive tier (99) into the Idle bucket.
1935+
// With the unread promoter enabled (default), a non-unread Idle group
1936+
// encodes to rank 3 (Waiting=0, unread=1, other tiers shifted +1, so
1937+
// Idle tier 2 -> 3); the point is it's well above the archive tier.
19301938
let mut archived_waiting = Instance::new("aw", "/tmp/aw");
19311939
archived_waiting.group_path = "work".to_string();
19321940
archived_waiting.status = crate::session::Status::Waiting;
@@ -1938,8 +1946,31 @@ mod tests {
19381946
let instances = vec![archived_waiting, active_idle];
19391947
let key = attention_group_key("work", None, &instances);
19401948
assert_eq!(
1941-
key.1, 2,
1942-
"group with one active Idle session should sort at tier 2"
1949+
key.1, 3,
1950+
"active Idle group should sort in the Idle bucket, far above archive tier 99"
1951+
);
1952+
}
1953+
1954+
#[test]
1955+
fn test_attention_group_key_promotes_unread_below_waiting() {
1956+
// Group A holds a read Error (tier 1); Group B holds an unread Idle
1957+
// (tier 2). With the unread promoter on (default), B's unread member
1958+
// floats it to rank 1, above A's Error (rank 2), matching how the flat
1959+
// Attention view promotes unread just below Waiting.
1960+
let mut err = Instance::new("err", "/tmp/err");
1961+
err.group_path = "a".to_string();
1962+
err.status = crate::session::Status::Error;
1963+
1964+
let mut unread_idle = Instance::new("ui", "/tmp/ui");
1965+
unread_idle.group_path = "b".to_string();
1966+
unread_idle.status = crate::session::Status::Idle;
1967+
unread_idle.mark_unread_auto();
1968+
1969+
let key_a = attention_group_key("a", None, std::slice::from_ref(&err));
1970+
let key_b = attention_group_key("b", None, std::slice::from_ref(&unread_idle));
1971+
assert!(
1972+
key_b < key_a,
1973+
"group with an unread Idle must outrank a group whose best member is a read Error: b={key_b:?} a={key_a:?}"
19431974
);
19441975
}
19451976

src/tui/components/help.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ fn shortcuts(strict: bool, live_on_enter: bool) -> Vec<(&'static str, Vec<(Strin
5050
let mut other: Vec<(String, String)> = Vec::new();
5151
for b in bindings::BINDINGS {
5252
let Some(help) = &b.help else { continue };
53+
// The unread toggle is fully removed when the feature is off, so the
54+
// help overlay shouldn't advertise a dead key (unlike the sort-gated
55+
// Attention rows, which stay listed because that's a transient view
56+
// state, not a disabled feature).
57+
if b.id == bindings::ActionId::ToggleUnread && !crate::session::unread_enabled() {
58+
continue;
59+
}
5360
let mut label = bindings::label(b.id, strict);
5461
if label.is_empty() {
5562
label = bindings::label(b.id, false);

src/tui/dialogs/command_palette.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ pub fn builtin_commands(serve_enabled: bool, strict_hotkeys: bool) -> Vec<Palett
9494
if meta.serve_only && !serve_enabled {
9595
return None;
9696
}
97+
// Drop the unread toggle entirely when the feature is off, so the
98+
// palette can't invoke a removed binding.
99+
if b.id == bindings::ActionId::ToggleUnread && !crate::session::unread_enabled() {
100+
return None;
101+
}
97102
Some(PaletteCommand {
98103
id: bindings::palette_id(b.id),
99104
title: meta.title.to_string(),

src/tui/dialogs/context_menu.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ impl ContextMenuDialog {
268268
'd' | 'D' => Some(ContextMenuAction::Delete),
269269
'z' | 'Z' => Some(ContextMenuAction::ToggleArchive),
270270
'h' | 'H' => Some(ContextMenuAction::ToggleSnooze),
271+
'v' | 'V' => Some(ContextMenuAction::ToggleUnread),
271272
// `n` opens a new session from whichever new-session entry
272273
// the current menu carries: the group/project menu prefills
273274
// from the row (NewFromGroup), the empty-sidebar menu opens
@@ -505,6 +506,23 @@ mod tests {
505506
assert_eq!(labels, vec!["Rename", "Archive", "Delete"]);
506507
}
507508

509+
#[test]
510+
fn v_hotkey_submits_toggle_unread() {
511+
// The `v` quick-pick mirrors the home-view shortcut and only fires
512+
// when the Unread row is present (feature enabled).
513+
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, Some(false));
514+
assert!(matches!(
515+
menu.handle_key(key(KeyCode::Char('v'))),
516+
DialogResult::Submit(ContextMenuAction::ToggleUnread)
517+
));
518+
// With no Unread row (feature off), `v` is inert.
519+
let mut off = ContextMenuDialog::for_session((0, 0), false, None, None);
520+
assert!(matches!(
521+
off.handle_key(key(KeyCode::Char('v'))),
522+
DialogResult::Continue
523+
));
524+
}
525+
508526
#[test]
509527
fn h_hotkey_submits_toggle_snooze() {
510528
let mut menu = ContextMenuDialog::for_session((0, 0), false, Some(false), None);

src/tui/home/bindings.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ pub enum Context {
111111
SearchActive,
112112
/// The cursor is on a real (non-synthetic) project header in project view.
113113
ProjectGroupSelected,
114+
/// The unread-session feature is enabled (`session.unread_indicator`). When
115+
/// off, the binding is removed from dispatch so the key isn't swallowed by
116+
/// a dead action; help and the command palette skip it separately.
117+
UnreadEnabled,
114118
}
115119

116120
/// Help-overlay section. Ordering mirrors `components/help.rs`.
@@ -169,6 +173,7 @@ fn context_holds(context: Context, ctx: &Ctx) -> bool {
169173
Context::AttentionSort => ctx.sort_order == SortOrder::Attention,
170174
Context::SearchActive => ctx.has_search,
171175
Context::ProjectGroupSelected => ctx.project_group_selected,
176+
Context::UnreadEnabled => crate::session::unread_enabled(),
172177
}
173178
}
174179

@@ -610,7 +615,7 @@ pub static BINDINGS: &[Binding] = &[
610615
id: ActionId::ToggleUnread,
611616
non_strict: &[k('v')],
612617
strict: &[k('V')],
613-
context: Context::Always,
618+
context: Context::UnreadEnabled,
614619
help: Some(HelpMeta {
615620
section: HelpSection::Actions,
616621
desc: "Mark read/unread (toggle)",

src/tui/home/mod.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4858,11 +4858,16 @@ impl HomeView {
48584858
}
48594859

48604860
/// Clear an auto-unread marker because the user viewed the session
4861-
/// (Tab into live-send, Enter to attach). No-op when the unread feature
4862-
/// is disabled; a manual flag is left in place (it clears only via the
4863-
/// manual toggle). Persists via `apply_user_action`.
4861+
/// (Tab into live-send, Enter to attach). A manual flag is left in place
4862+
/// (it clears only via the manual toggle). Runs regardless of the feature
4863+
/// flag so a stale `Auto` marker can't survive a disable/re-enable and
4864+
/// reappear later; only writes when there's actually an `Auto` flag to
4865+
/// clear, so a viewed read session doesn't churn the storage flock.
48644866
pub(crate) fn clear_auto_unread(&mut self, id: &str) {
4865-
if crate::session::unread_enabled() {
4867+
let has_auto = self
4868+
.get_instance(id)
4869+
.is_some_and(|i| i.unread == Some(crate::session::UnreadKind::Auto));
4870+
if has_auto {
48664871
let _ = self.apply_user_action(id, |i| i.mark_read_auto());
48674872
}
48684873
}

src/tui/styles/mod.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,41 @@ fn load_custom_theme(path: &std::path::Path) -> Option<Theme> {
131131
};
132132

133133
match toml::from_str::<Theme>(&content) {
134-
Ok(theme) => Some(theme),
134+
Ok(theme) => Some(fill_unread_from_accent(&content, theme)),
135135
Err(e) => {
136136
warn!("Failed to parse theme file {}: {}", path.display(), e);
137137
None
138138
}
139139
}
140140
}
141141

142+
/// A theme TOML that omits `unread` should inherit that theme's own `accent`,
143+
/// not Empire's default blue. The container `#[serde(default)]` seeds every
144+
/// omitted field from `Theme::default()` (= Empire), and serde can't tell an
145+
/// omitted key from one explicitly set to Empire's value, so we detect the
146+
/// omission from the raw table and fall back to the parsed theme's accent.
147+
fn fill_unread_from_accent(content: &str, mut theme: Theme) -> Theme {
148+
let omitted = content
149+
.parse::<toml::Table>()
150+
.map(|t| !t.contains_key("unread"))
151+
.unwrap_or(false);
152+
if omitted {
153+
theme.unread = theme.accent;
154+
}
155+
theme
156+
}
157+
142158
/// Parse a builtin's embedded TOML. Builtin TOMLs are committed to the repo
143159
/// and embedded at build time; a parse failure here is a developer bug, not
144160
/// user input. The `all_builtins_parse_with_expected_anchors` test guards
145161
/// against that landing in main.
146162
fn parse_builtin(builtin: &BuiltinTheme) -> Theme {
147-
toml::from_str(builtin.source)
148-
.unwrap_or_else(|e| panic!("builtin theme '{}' failed to parse: {}", builtin.name, e))
163+
let theme = toml::from_str(builtin.source)
164+
.unwrap_or_else(|e| panic!("builtin theme '{}' failed to parse: {}", builtin.name, e));
165+
// All builtins define `unread`, so this is a no-op for them today, but
166+
// keep the same fallback as custom themes so a future builtin that omits
167+
// it inherits its own accent rather than Empire's.
168+
fill_unread_from_accent(builtin.source, theme)
149169
}
150170

151171
pub fn load_theme(name: &str) -> Theme {

src/tui/styles/themes.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ pub struct Theme {
9090
/// user hasn't viewed, or a manual "flag for later"). Applied to resting
9191
/// rows (Idle/Unknown) in place of the decaying idle color so unread work
9292
/// stands out without being as loud as Waiting/Error. Gated behind the
93-
/// `session.unread_indicator` config toggle (on by default). Defaults to
94-
/// the theme's accent if a custom TOML omits it.
93+
/// `session.unread_indicator` config toggle (on by default). A theme TOML
94+
/// that omits this key inherits that theme's own `accent` (filled at load
95+
/// time by `fill_unread_from_accent`), not Empire's default.
9596
#[serde(with = "hex_color")]
9697
pub unread: Color,
9798
#[serde(with = "hex_color")]

themes/builtin/catppuccin-latte.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ running = "#40a02b"
1616
waiting = "#fe640b"
1717
fresh_idle = "#df8e1d"
1818
idle = "#9ca0b0"
19-
unread = "#04a5e5"
19+
unread = "#7287fd"
2020
error = "#d20f39"
2121
terminal_active = "#1e66f5"
2222
group = "#179299"

0 commit comments

Comments
 (0)