Skip to content

Commit 3538604

Browse files
Show the active team in the TUI statusline, with a tri-state
Adds a Team statusline item showing the window's active team, rendered only when the user is on more than one. The condition reuses can_switch_teams -- the predicate gating the GUI's title-bar team pill -- rather than a fresh team-count check, so the two front-ends cannot disagree about who counts as multi-team. Clicking it opens the same switcher /team surfaces. The item is hidden from /statusline entirely below two teams. With one team it renders nothing, so offering a checkbox would offer one that lies. Above two teams it is listed, checked by default, and an explicit uncheck is permanent across any number of team-count changes. `enabled` cannot express that, since absence from it already means "off". Hence show_active_team: Option<bool>, None meaning undecided-so-shown, with is_enabled absorbing the branch. Two properties fall out rather than being arranged: an older saved config has no such field, so serde gives None, so it shows, needing no back-fill; and normalized() cannot sweep the item into enabled because it is never in enabled. The cost is that one item's checkbox reads a different field from the other fifteen. That asymmetry is real -- this is the only item defaulting to on whose availability depends on the user's teams -- and is better visible than hidden behind machinery implying the others work the same way. Two things the tests caught: - Hiding the row dropped Team from the saved order, so normalized() then re-appended it at the end; opening /statusline as a one-team user would have permanently moved the item to the bottom of the catalog. Its position is now restored from the config the picker opened with. - The picker never records a decision it was not offered, so a one-team user cannot silently opt out by opening /statusline once. Resyncs on TeamsChanged and on WindowTeamChanged filtered to this window, matching every other live-valued statusline item. CHANGELOG-TUI: The Warp Agent CLI statusline now shows your active team when you belong to more than one.
1 parent 61be7e3 commit 3538604

7 files changed

Lines changed: 447 additions & 25 deletions

File tree

app/src/settings/ai.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,9 @@ pub enum TuiStatuslineItem {
689689
/// Vim mode indicator (NOR/INS/VIS/V-L/REP); hidden when vim mode is disabled.
690690
VimModeIndicator,
691691
Model,
692+
/// The window's active team. Only rendered for users on more than one team, matching the
693+
/// GUI's team-switcher pill.
694+
Team,
692695
WorkingDirectory,
693696
GitBranch,
694697
GitBranchStatus,
@@ -706,10 +709,11 @@ pub enum TuiStatuslineItem {
706709
}
707710

708711
impl TuiStatuslineItem {
709-
pub const ALL: [Self; 15] = [
712+
pub const ALL: [Self; 16] = [
710713
Self::AutoApprove,
711714
Self::VimModeIndicator,
712715
Self::Model,
716+
Self::Team,
713717
Self::WorkingDirectory,
714718
Self::GitBranch,
715719
Self::GitBranchStatus,
@@ -729,6 +733,7 @@ impl TuiStatuslineItem {
729733
Self::AutoApprove => "Auto-approve indicator",
730734
Self::VimModeIndicator => "Vim mode indicator",
731735
Self::Model => "Model",
736+
Self::Team => "Team",
732737
Self::WorkingDirectory => "Working directory",
733738
Self::GitBranch => "Git branch",
734739
Self::GitBranchStatus => "Git branch status",
@@ -759,6 +764,18 @@ impl TuiStatuslineItem {
759764
pub struct TuiStatuslineConfig {
760765
pub order: Vec<TuiStatuslineItem>,
761766
pub enabled: Vec<TuiStatuslineItem>,
767+
/// Whether to show the active team, as a tri-state that `enabled` cannot express.
768+
///
769+
/// `enabled` conflates "never decided" with "explicitly off", which is fine for the items
770+
/// that are simply on or off but not for this one: it defaults to shown, and only appears
771+
/// in `/statusline` at all when the user is on more than one team, so an absent entry has
772+
/// to keep meaning "has not been asked yet" rather than "turned off".
773+
///
774+
/// `None` is that undecided state and is treated as shown. Keeping it out of `enabled`
775+
/// rather than adding a third list is what makes an older saved config correct for free:
776+
/// it has no such field, serde gives `None`, and `None` already means shown.
777+
#[serde(default)]
778+
pub show_active_team: Option<bool>,
762779
}
763780

764781
impl Default for TuiStatuslineConfig {
@@ -773,6 +790,7 @@ impl Default for TuiStatuslineConfig {
773790
TuiStatuslineItem::GitBranch,
774791
TuiStatuslineItem::GitDiffStatus,
775792
],
793+
show_active_team: None,
776794
}
777795
}
778796
}
@@ -798,11 +816,27 @@ impl TuiStatuslineConfig {
798816
enabled.insert(0, TuiStatuslineItem::VimModeIndicator);
799817
}
800818

801-
Self { order, enabled }
819+
Self {
820+
order,
821+
enabled,
822+
show_active_team: self.show_active_team,
823+
}
802824
}
803825

804826
pub fn is_enabled(&self, item: TuiStatuslineItem) -> bool {
805-
self.enabled.contains(&item)
827+
match item {
828+
// Deliberately reads a different field from the other items. The asymmetry is real
829+
// -- this is the only item whose default is on and whose availability depends on
830+
// the user's teams -- so it is better shown here than hidden behind machinery that
831+
// implies the other fifteen work the same way. See `show_active_team`.
832+
TuiStatuslineItem::Team => self.show_active_team.unwrap_or(true),
833+
_ => self.enabled.contains(&item),
834+
}
835+
}
836+
837+
/// Records an explicit `/statusline` decision about the active-team item.
838+
pub fn set_show_active_team(&mut self, show: bool) {
839+
self.show_active_team = Some(show);
806840
}
807841
}
808842

app/src/settings/ai_tests.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ fn tui_statusline_normalization_preserves_custom_order_and_appends_missing_items
8989
TuiStatuslineItem::Model,
9090
TuiStatuslineItem::ContextWindowUsage,
9191
],
92+
show_active_team: None,
9293
}
9394
.normalized();
9495

@@ -99,6 +100,7 @@ fn tui_statusline_normalization_preserves_custom_order_and_appends_missing_items
99100
TuiStatuslineItem::Model,
100101
TuiStatuslineItem::AutoApprove,
101102
TuiStatuslineItem::VimModeIndicator,
103+
TuiStatuslineItem::Team,
102104
TuiStatuslineItem::WorkingDirectory,
103105
TuiStatuslineItem::GitBranchStatus,
104106
TuiStatuslineItem::GitDiffStatus,
@@ -122,6 +124,72 @@ fn tui_statusline_normalization_preserves_custom_order_and_appends_missing_items
122124
);
123125
}
124126

127+
/// A config saved before the team item existed has no opinion about it, which is the undecided
128+
/// state, which shows. That needs no back-fill: it falls out of the field being absent.
129+
#[test]
130+
fn tui_statusline_an_older_config_shows_the_team_item_without_being_migrated() {
131+
let config = TuiStatuslineConfig {
132+
order: vec![TuiStatuslineItem::Model, TuiStatuslineItem::GitBranch],
133+
enabled: vec![TuiStatuslineItem::Model],
134+
show_active_team: None,
135+
}
136+
.normalized();
137+
138+
assert!(config.order.contains(&TuiStatuslineItem::Team));
139+
assert!(config.is_enabled(TuiStatuslineItem::Team));
140+
assert!(
141+
!config.enabled.contains(&TuiStatuslineItem::Team),
142+
"normalization must not sweep the team item into `enabled`"
143+
);
144+
assert!(
145+
config.enabled.contains(&TuiStatuslineItem::Model),
146+
"the rest of an existing config's enabled set is the user's choice and must not change"
147+
);
148+
}
149+
150+
/// The team item's state is a tri-state kept out of `enabled`, so `enabled` — and therefore
151+
/// `tui_statusline_default_matches_figma` — is untouched by it.
152+
#[test]
153+
fn tui_statusline_team_item_defaults_to_shown_without_entering_the_enabled_list() {
154+
let config = TuiStatuslineConfig::default();
155+
156+
assert!(config.order.contains(&TuiStatuslineItem::Team));
157+
assert_eq!(config.show_active_team, None);
158+
assert!(
159+
config.is_enabled(TuiStatuslineItem::Team),
160+
"undecided means shown"
161+
);
162+
assert!(
163+
!config.enabled.contains(&TuiStatuslineItem::Team),
164+
"the team item must never enter `enabled`, or normalization would start managing it"
165+
);
166+
}
167+
168+
#[test]
169+
fn tui_statusline_team_item_honours_an_explicit_decision() {
170+
let mut config = TuiStatuslineConfig::default();
171+
172+
config.set_show_active_team(false);
173+
assert!(!config.is_enabled(TuiStatuslineItem::Team));
174+
175+
config.set_show_active_team(true);
176+
assert!(config.is_enabled(TuiStatuslineItem::Team));
177+
}
178+
179+
/// An explicit "off" is permanent. It has to survive normalization, since that is what runs on
180+
/// every read and is the path that would otherwise quietly resurrect the item.
181+
#[test]
182+
fn tui_statusline_normalization_preserves_an_explicit_team_decision() {
183+
let mut config = TuiStatuslineConfig::default();
184+
config.set_show_active_team(false);
185+
186+
let normalized = config.normalized();
187+
188+
assert_eq!(normalized.show_active_team, Some(false));
189+
assert!(!normalized.is_enabled(TuiStatuslineItem::Team));
190+
assert!(!normalized.enabled.contains(&TuiStatuslineItem::Team));
191+
}
192+
125193
#[test]
126194
fn tui_statusline_normalization_preserves_explicitly_disabled_vim_indicator() {
127195
let mut config = TuiStatuslineConfig::default();

crates/warp_tui/src/statusline_config_view.rs

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ use warp::settings::{TuiStatuslineConfig, TuiStatuslineItem};
66
use warp::tui_export::{
77
AskUserQuestionAction, AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionSession,
88
AskUserQuestionType, OptionRow, OptionSnapshot, OptionSourceStatus, QuestionDraft,
9+
UserWorkspaces,
910
};
11+
use warpui::SingletonEntity as _;
1012
use warpui_core::elements::tui::{
1113
Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiText,
1214
};
@@ -95,6 +97,25 @@ pub(crate) enum TuiStatuslineConfigEvent {
9597
pub(crate) struct TuiStatuslineConfigView {
9698
session: AskUserQuestionSession,
9799
selector: ViewHandle<TuiOptionSelector>,
100+
/// Whether the active-team row is offered at all. It is not, below two teams: with one
101+
/// team the item renders nothing, so a checkbox for it would be a checkbox that lies.
102+
team_item_listed: bool,
103+
/// The stored tri-state as it was when the picker opened, so that closing the picker
104+
/// without ever having been offered the row cannot silently record "off".
105+
restored_show_active_team: Option<bool>,
106+
/// The catalog order the picker opened with. When the active-team row is not offered it is
107+
/// absent from the rows, and therefore from the saved order, so its position has to be
108+
/// restored from here — otherwise `normalized()` re-appends it at the end and hiding the
109+
/// row silently reorders the catalog.
110+
restored_order: Vec<TuiStatuslineItem>,
111+
}
112+
113+
/// Index of the active-team item within [`TuiStatuslineItem::ALL`], which is what the option
114+
/// rows are keyed by.
115+
fn team_option_index() -> Option<usize> {
116+
TuiStatuslineItem::ALL
117+
.iter()
118+
.position(|item| *item == TuiStatuslineItem::Team)
98119
}
99120

100121
// The next stacked change mounts the picker and consumes these lifecycle helpers.
@@ -103,15 +124,21 @@ impl TuiStatuslineConfigView {
103124
pub(crate) fn new(config: TuiStatuslineConfig, ctx: &mut ViewContext<Self>) -> Self {
104125
let config = config.normalized();
105126
let question = statusline_question();
106-
let selected_option_indices = config
127+
let team_item_listed = UserWorkspaces::as_ref(ctx).can_switch_teams();
128+
let mut selected_option_indices = config
107129
.enabled
108130
.iter()
109131
.filter_map(|item| {
110132
TuiStatuslineItem::ALL
111133
.iter()
112134
.position(|candidate| candidate == item)
113135
})
114-
.collect();
136+
.collect::<HashSet<_>>();
137+
// The team item's checked state lives in its own tri-state rather than in `enabled`,
138+
// so it has to be folded into the selection by hand.
139+
if team_item_listed && config.is_enabled(TuiStatuslineItem::Team) {
140+
selected_option_indices.extend(team_option_index());
141+
}
115142
let drafts = HashMap::from([(
116143
STATUSLINE_QUESTION_ID.to_owned(),
117144
QuestionDraft {
@@ -124,6 +151,9 @@ impl TuiStatuslineConfigView {
124151
let mut view = Self {
125152
session,
126153
selector: selector.clone(),
154+
team_item_listed,
155+
restored_show_active_team: config.show_active_team,
156+
restored_order: config.order.clone(),
127157
};
128158
view.show_options(&config.order, ctx);
129159
ctx.subscribe_to_view(&selector, |view, _, event, ctx| {
@@ -138,8 +168,10 @@ impl TuiStatuslineConfigView {
138168
}
139169

140170
fn show_options(&mut self, order: &[TuiStatuslineItem], ctx: &mut ViewContext<Self>) {
171+
let team_item_listed = self.team_item_listed;
141172
let rows = order
142173
.iter()
174+
.filter(|item| team_item_listed || **item != TuiStatuslineItem::Team)
143175
.filter_map(|item| {
144176
TuiStatuslineItem::ALL
145177
.iter()
@@ -222,31 +254,56 @@ impl TuiStatuslineConfigView {
222254
}
223255

224256
fn current_config(&self, ctx: &AppContext) -> TuiStatuslineConfig {
225-
let order = self
257+
let mut order = self
226258
.selector
227259
.as_ref(ctx)
228260
.ordered_row_ids()
229261
.into_iter()
230262
.filter_map(|id| id.parse::<usize>().ok())
231263
.filter_map(|index| TuiStatuslineItem::ALL.get(index).copied())
232264
.collect::<Vec<_>>();
265+
if !self.team_item_listed {
266+
let position = self
267+
.restored_order
268+
.iter()
269+
.position(|item| *item == TuiStatuslineItem::Team)
270+
.unwrap_or(order.len())
271+
.min(order.len());
272+
order.insert(position, TuiStatuslineItem::Team);
273+
}
233274
let enabled_indices = self
234275
.session
235276
.draft_for_question(0)
236277
.map(|draft| &draft.selected_option_indices);
278+
let is_checked = |item: &TuiStatuslineItem| {
279+
TuiStatuslineItem::ALL
280+
.iter()
281+
.position(|candidate| candidate == item)
282+
.is_some_and(|index| {
283+
enabled_indices.is_some_and(|indices| indices.contains(&index))
284+
})
285+
};
237286
let enabled = order
238287
.iter()
239288
.copied()
240-
.filter(|item| {
241-
TuiStatuslineItem::ALL
242-
.iter()
243-
.position(|candidate| candidate == item)
244-
.is_some_and(|index| {
245-
enabled_indices.is_some_and(|indices| indices.contains(&index))
246-
})
247-
})
289+
// The team item is never in `enabled`; its state is the tri-state below.
290+
.filter(|item| *item != TuiStatuslineItem::Team)
291+
.filter(is_checked)
248292
.collect();
249-
TuiStatuslineConfig { order, enabled }.normalized()
293+
// Only record a decision about the team item when the row was actually offered.
294+
// Otherwise a user below two teams would silently have "off" written for them by
295+
// opening the picker at all, and would then find it off once they joined a second team.
296+
let show_active_team = if self.team_item_listed {
297+
Some(is_checked(&TuiStatuslineItem::Team))
298+
} else {
299+
self.restored_show_active_team
300+
};
301+
TuiStatuslineConfig {
302+
order,
303+
enabled,
304+
show_active_team,
305+
}
306+
.normalized()
250307
}
251308

252309
fn render_footer(&self, app: &AppContext) -> Box<dyn TuiElement> {

crates/warp_tui/src/statusline_config_view_tests.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@ use std::cell::RefCell;
22
use std::rc::Rc;
33

44
use warp::settings::{TuiStatuslineConfig, TuiStatuslineItem};
5-
use warp::tui_export::Appearance;
5+
use warp::tui_export::{Appearance, register_tui_input_mode_test_settings};
66
use warpui::platform::WindowStyle;
77
use warpui::{AddWindowOptions, App};
88
use warpui_core::TypedActionView as _;
99

1010
use super::{TuiStatuslineConfigAction, TuiStatuslineConfigEvent, TuiStatuslineConfigView};
1111

12+
/// The picker asks `UserWorkspaces` whether to offer the active-team row, so the singleton has
13+
/// to exist. This registers it with no teams, which is the case where the row is not offered.
14+
fn register_workspaces(app: &mut App) {
15+
app.update(register_tui_input_mode_test_settings);
16+
}
17+
1218
#[test]
1319
fn default_picker_preserves_figma_selection_and_full_catalog_order() {
1420
App::test((), |mut app| async move {
1521
app.add_singleton_model(|_| Appearance::mock());
22+
register_workspaces(&mut app);
1623
let view = app.update(|ctx| {
1724
ctx.add_tui_window(
1825
AddWindowOptions {
@@ -35,6 +42,7 @@ fn default_picker_preserves_figma_selection_and_full_catalog_order() {
3542
fn toggle_and_reorder_are_reflected_in_saved_config() {
3643
App::test((), |mut app| async move {
3744
app.add_singleton_model(|_| Appearance::mock());
45+
register_workspaces(&mut app);
3846
let view = app.update(|ctx| {
3947
ctx.add_tui_window(
4048
AddWindowOptions {
@@ -81,6 +89,7 @@ fn toggle_and_reorder_are_reflected_in_saved_config() {
8189
TuiStatuslineItem::VimModeIndicator,
8290
TuiStatuslineItem::AutoApprove,
8391
TuiStatuslineItem::Model,
92+
TuiStatuslineItem::Team,
8493
TuiStatuslineItem::WorkingDirectory,
8594
TuiStatuslineItem::GitBranch,
8695
TuiStatuslineItem::GitBranchStatus,

0 commit comments

Comments
 (0)