Skip to content

Commit 61be7e3

Browse files
[multi-team] /team switcher so the agent CLI has a team scope (#15448)
## Description Prerequisite for the client settings-getter migration onto per-window team scope (tech spec #15347, tracking issue REV-2205). **No settings getter is migrated here and no autonomy enforcement path is touched** — this makes the agent CLI's window team-scoped, and lets the user see and change which team that is, so the follow-up migration can read scope there safely. > Rewritten from an earlier `--team` flag design after product feedback. The flag, > `WARP_TEAM`, and the fail-closed refusal are all gone; see *What changed from the > first revision* at the bottom. ### The gap this closes `UserWorkspaces::register_window` has exactly one production caller: `RootView::new` (`app/src/root_view.rs:1889`). The headless TUI has no `RootView`, so its window is absent from `UserWorkspaces::window_team_uids` entirely. It *does* have a real warpui window, so `window_id_for_view` returns a valid `WindowId` and nothing errors, nothing warns, and no test fails — the scope simply resolves to "no team". That matters because the TUI does not merely touch the AI-autonomy enforcement path, it instantiates the whole of it: `crates/warp_tui/src/terminal_session_view.rs:1502-1595` constructs `BlocklistAIActionModel`, `BlocklistAIController` and `CLISubagentController` with `terminal_surface_id = ctx.view_id()`, the same code the GUI runs. If the autonomy getters were migrated today, enterprise autonomy policy would stop being enforced in the agent CLI entirely: today it reads one arbitrary team's policy — wrong, but restrictive — and after, none at all. ### Why reassigning a window's team did not already exist This is the first question worth answering, because the GUI *does* have a team switcher and its absence from this seam looks like an oversight. `app/src/workspace/view.rs:6128-6256` has one: a title-bar pill gated on `can_switch_teams()` plus a dropdown listing the workspace's teams with a check on the active one. But every item dispatches `WorkspaceAction::OpenNewWindowForTeam`, which opens a **new** window scoped to the chosen team rather than re-scoping the current one. **The GUI never needed reassignment because it opens a new window instead**, which is why `register_window` and `set_team_for_window` are both insert-only and why neither has ever overwritten anything. The TUI is one process with one window, so it cannot copy that. `/team` genuinely mutates. ### What this adds - **`/team`** — a TUI-only slash command opening a searchable switcher listing the teams in the current workspace, marking the active one, and re-scoping the window in place. Modelled on `/model` (`SlashCommandKind::Model` + `TuiModelMenuModel`), which is the existing list-and-change-the-active-thing pattern in the TUI. - **`UserWorkspaces::switch_window_to_team`** — the reassignment `/team` needs. Deliberately a separate entry point rather than relaxing `set_team_for_window`: that method's insert-only, first-write-wins semantics are load-bearing for the GUI, and a single greppable overwrite path is worth more than symmetry for a field that decides which team's admin policy applies. It emits `WindowTeamChanged` so scoped consumers resync, and no-ops when the team is unchanged. - **`UserWorkspaces::is_window_registered`** — `team_uid_for_window` returns `None` both for an absent window and a registered teamless one, so it cannot answer "has this window got a team yet". This can, and it replaces what would otherwise be a resolution state machine. - **A remembered team.** The team a session ends on is stored locally and preferred at next startup, following the GUI's `WindowSnapshot::team_uid` precedent (`app/src/persistence/sqlite.rs:987` and `:2683`), including its deliberate tolerance: an unreadable or unparseable stored value degrades to `None` rather than failing. It is local rather than cloud-synced because it records what this machine's TUI was doing. ### Startup, and why the stored team is validated Restored team if the user still belongs to it → otherwise the default a new GUI window gets (`inherited_or_default_team_uid`, i.e. `teams.first()`, or `None` for a user on no teams) → `/team` overrides and persists. There is no state machine. `UserWorkspaces`'s window table is the single source of truth: registration happens on the first `TeamsChanged`, `is_window_registered` makes it idempotent, and a `/team` choice is never clobbered because the same check short-circuits it. The validation on the stored team deserves its own note, because the obvious reading of it is wrong. **It is not for safety.** A stale uid — the user left the team, an admin removed them, they signed in as someone else — cannot leak another team's settings, because `team_from_uid` searches only the current workspace and so resolves it to no team at all. It is for **promptness**, and it is needed because of an ordering inside `update_workspaces`: ```rust *self.workspaces = workspaces; let reassigned_windows = self.reconcile_window_team_assignments(); // the sweep runs here self.notify_and_emit_teams_changed(ctx); // TeamsChanged emitted here ``` Registration happens *on* `TeamsChanged`, so a team written from that handler lands after the sweep that would have corrected it, and waits for the next poll. Without validation a returning user who had left a team would sit on **no team** — degraded policy — for up to a full poll interval. A test pins this (`a_stale_stored_team_is_reconciled_onto_the_default`); it failed before the validation was added, which is how the ordering was found. ### Two consequences worth naming 1. **The "no team matching X" error is gone, along with `TeamResolutionError` entirely.** There is no flag to typo and `/team` picks from a list, so no path can name a team that does not exist. The only remaining unresolvable input is a stale stored uid, which is a silent fall-back to the default rather than an error, because the user did not ask for it. 2. **There is now no non-interactive way to select a team.** Scripts and CI get the default or whatever the last interactive session left behind. That is deliberate for now, but it is a real limitation rather than an oversight. ### Notes for the reviewer - **`oz agent run` is unaffected and deliberately untouched.** It reaches `launch()` → `ai::agent_sdk::run` → `TerminalDriver::create` → `open_new_with_workspace_source` (`app/src/ai/agent_sdk/driver/terminal.rs:201`), which builds a real `RootView`, so it already registers a window. - **An offline start leaves the window unregistered, and the follow-up must not read that as teamless.** With no metadata response nothing registers. That is today's behaviour and not a regression. `team_context` already distinguishes the two states — `None` for a window absent entirely, `Some(TeamContext { team_uid: None })` for one registered with no team — and **the migration that builds on this is required to honour that difference and fail closed on absent**. Tests assert both states so the distinction cannot quietly collapse. - **Drift is now visible and correctable.** `reconcile_window_team_assignments` can still move a window onto `teams.first()` — a teamless user later added to a team, or a logout/login as another account. Previously that was an unobservable landing on an arbitrary team; with `/team` and the indicator stacked on top, the user can see it and change it. - **No workspace-settings fallback is retired by this PR** (per the convention on REV-2205), since it migrates no getter. ## Linked Issue https://linear.app/warpdotdev/issue/REV-2205 — [multi team] Scope team settings on the client to window context - [x] The linked issue is labeled `ready-to-spec` or `ready-to-implement`. - [x] Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes). ## Testing Automated, all added or rewritten in this PR: - `app/src/tui/team_scope_tests.rs` — registration only after a metadata response; the default when nothing is stored; teamless users registered rather than left absent; a stored team preferred over the default; a stale stored team falling back to the default; a `/team` switch moving the window, persisting, and surviving a later poll; an unparseable stored value degrading to the default. These assert through `team_context`, since `team_uid_for_window` cannot tell an absent window from a registered teamless one. - `app/src/workspaces/user_workspaces_tests.rs` — `is_window_registered` distinguishing absent from teamless; `switch_window_to_team` overwriting and emitting `WindowTeamChanged`; the same switch to the current team emitting nothing; `can_switch_teams` gating on more than one team. - `crates/warp_tui/src/team_menu_tests.rs` — the active team marked, and every team selectable so re-picking the active one is a no-op rather than an error. Commands run: - `./script/format` — clean. - `cargo clippy -p warp_tui -p warp --all-targets --tests -- -D warnings` — clean. - `cargo nextest run -p warp --features tui -E 'test(team_scope) or test(user_workspaces) or test(tui::)'` — 89 passed. - `cargo nextest run -p warp_tui --features test-util -E 'test(team_menu) or test(session::tests) or test(input_suggestions_mode) or test(input::view)'` — 135 passed. - [ ] I have manually tested my changes locally with `./script/run` Not manually tested: exercising `/team` needs a logged-in account in a workspace with more than one team, which does not exist today — every workspace currently has exactly one. The menu's rendering is covered by unit tests, but I have not seen it on screen, and I would value a reviewer running it against a multi-team fixture if one exists. ### Screenshots / Videos None — see above. The switcher is a standard inline menu built from the same `TuiInlineMenuSnapshot` vocabulary as `/model`. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode ### What changed from the first revision The first revision added `--team <NAME_OR_ID>` plus `WARP_TEAM`, and refused to start when a user on more than one team gave no flag. Product direction replaced it: a switcher plus a remembered team is better, and it removes the need for the refusal, since the argument for failing closed was that the CLI could neither show nor correct an arbitrary default — and now it can do both. Removed with the flag: `WARP_TEAM`, the blank-value handling, the one-shot flag consumption, and `TeamResolutionError` with its whole `Display` contract. <!-- CHANGELOG-TUI: Added `/team` to switch which team's settings and admin policy the session uses. --> <!-- warp:pr-description-artifacts start --> <!-- warp:pr-description-artifacts end --> --------- Co-authored-by: warp-agent-staging[bot] <240773466+warp-agent-staging[bot]@users.noreply.github.com> Co-authored-by: Oz <oz-agent@warp.dev>
1 parent 19548ae commit 61be7e3

22 files changed

Lines changed: 720 additions & 21 deletions

app/src/search/slash_command_menu/static_commands/commands.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,19 @@ pub static MODEL: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
560560
argument: None,
561561
});
562562

563+
/// TUI-only: the GUI switches teams from the title-bar pill, which opens a new window for the
564+
/// chosen team rather than re-scoping the current one. The TUI has a single window, so it
565+
/// switches in place instead.
566+
pub const TEAM: StaticCommand = StaticCommand {
567+
name: "/team",
568+
description: "Switch the active team",
569+
kind: SlashCommandKind::Team,
570+
supported_surfaces: SlashCommandSurfaces::TuiOnly,
571+
availability: Availability::ALWAYS,
572+
auto_enter_ai_mode: false,
573+
argument: None,
574+
};
575+
563576
pub static HOST: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
564577
name: "/host",
565578
description: "Switch the cloud agent execution host",
@@ -994,6 +1007,7 @@ fn all_commands_for_all_surfaces() -> Vec<StaticCommand> {
9941007
EXPORT_TO_CLIPBOARD,
9951008
COPY_DEBUGGING_ID,
9961009
MODEL.clone(),
1010+
TEAM,
9971011
STATUS,
9981012
VIEW_LOGS,
9991013
VOICE,

app/src/search/slash_command_menu/static_commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ pub enum SlashCommandKind {
9494
New,
9595
Clear,
9696
Model,
97+
Team,
9798
Host,
9899
Harness,
99100
Environment,

app/src/terminal/input/slash_commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,7 @@ impl Input {
13011301
| SlashCommandKind::Exit
13021302
| SlashCommandKind::Logout
13031303
| SlashCommandKind::Clear
1304+
| SlashCommandKind::Team
13041305
| SlashCommandKind::Status => {
13051306
debug_assert!(
13061307
false,

app/src/tui_export.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ pub use crate::search::slash_command_menu::static_commands::{
183183
SlashCommandKind, SlashCommandSurfaces,
184184
};
185185
pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand};
186-
pub use crate::server::ids::SyncId;
186+
pub use crate::server::ids::{ServerId, SyncId};
187187
pub use crate::server::server_api::ServerApiProvider;
188188
#[cfg(feature = "voice_input")]
189189
pub use crate::server::server_api::TranscribeError;
@@ -265,6 +265,7 @@ pub use crate::tui_test_support::{
265265
blocklist_ai_history_model_with_queries, forkable_tui_conversation_for_test,
266266
queue_tui_permission_action, register_tui_input_mode_test_settings,
267267
register_tui_session_view_test_singletons, set_tui_default_team_admin_for_test,
268+
set_tui_workspace_teams_for_test,
268269
};
269270
pub use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
270271
pub use crate::util::image::{

app/src/tui_test_support.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use crate::code_review::git_repo_model::GitRepoModels;
3838
use crate::network::NetworkStatus;
3939
use crate::persistence::PersistenceWriter;
4040
use crate::server::experiments::ServerExperiments;
41+
use crate::server::ids::ServerId;
4142
use crate::server::server_api::ServerApiProvider;
4243
use crate::server::sync_queue::SyncQueue;
4344
#[cfg(feature = "voice_input")]
@@ -288,6 +289,22 @@ pub fn set_tui_default_team_admin_for_test(ctx: &mut AppContext) {
288289
});
289290
}
290291

292+
pub fn set_tui_workspace_teams_for_test(teams: Vec<(ServerId, String)>, ctx: &mut AppContext) {
293+
let teams = teams
294+
.into_iter()
295+
.map(|(uid, name)| Team::from_local_cache(uid, name, None, None, None))
296+
.collect();
297+
let workspace = Workspace::from_local_cache(
298+
"workspace_uid123456789".to_owned().into(),
299+
"test workspace".to_owned(),
300+
Some(teams),
301+
);
302+
let workspace_uid = workspace.uid;
303+
UserWorkspaces::handle(ctx).update(ctx, |workspaces, ctx| {
304+
workspaces.update_workspaces(vec![workspace], ctx);
305+
workspaces.set_current_workspace_uid(workspace_uid, ctx);
306+
});
307+
}
291308
/// Queues an action as the active confirmation request for a TUI view test.
292309
pub fn queue_tui_permission_action(
293310
action_model: &mut BlocklistAIActionModel,

app/src/workspaces/user_workspaces.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,20 @@ impl UserWorkspaces {
413413
self.window_team_uids.get(&window_id).copied().flatten()
414414
}
415415

416+
pub fn switch_window_to_team(
417+
&mut self,
418+
window_id: WindowId,
419+
team_uid: ServerId,
420+
ctx: &mut ModelContext<Self>,
421+
) {
422+
if self.team_uid_for_window(window_id) == Some(team_uid) {
423+
return;
424+
}
425+
self.window_team_uids.insert(window_id, Some(team_uid));
426+
ctx.emit(UserWorkspacesEvent::WindowTeamChanged { window_id });
427+
ctx.notify();
428+
}
429+
416430
/// Returns `true` when the user belongs to more than one team in the current
417431
/// workspace, meaning the team-switcher pill and dropdown should be shown.
418432
/// Single-team and no-workspace users return `false` so their UI is unchanged.

app/src/workspaces/user_workspaces_tests.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::cell::Cell;
2+
use std::rc::Rc;
13
use std::time::Duration;
24

35
use mockall::Sequence;
@@ -1152,6 +1154,113 @@ fn test_window_team_reconciliation_moves_rendering_but_not_a_captured_context()
11521154
})
11531155
}
11541156

1157+
fn team_named(uid: i64, name: &str) -> Team {
1158+
let mut team = team_for_test();
1159+
team.uid = uid.into();
1160+
team.name = name.to_owned();
1161+
team
1162+
}
1163+
1164+
/// Two teams in workspace order, so a test can tell the default apart from a chosen team.
1165+
fn platform_and_security() -> (Team, Team, Workspace) {
1166+
let platform = team_named(123, "Platform");
1167+
let security = team_named(456, "Security");
1168+
let mut workspace = workspace_for_test(&platform);
1169+
workspace.teams.push(security.clone());
1170+
(platform, security, workspace)
1171+
}
1172+
1173+
#[test]
1174+
fn switching_a_window_to_a_team_overwrites_and_announces_it() {
1175+
let (platform, security, workspace) = platform_and_security();
1176+
1177+
App::test((), |mut app| async move {
1178+
initialize_window_team_test_app(&mut app, vec![workspace]);
1179+
1180+
let window_id = WindowId::new();
1181+
let changes = Rc::new(Cell::new(0));
1182+
let changes_for_subscription = changes.clone();
1183+
app.update(|ctx| {
1184+
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), move |_, event, _| {
1185+
if matches!(event, UserWorkspacesEvent::WindowTeamChanged { .. }) {
1186+
changes_for_subscription.set(changes_for_subscription.get() + 1);
1187+
}
1188+
});
1189+
});
1190+
1191+
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
1192+
user_workspaces.register_window(window_id, Some(platform.uid), ctx);
1193+
});
1194+
let changes_after_register = changes.get();
1195+
1196+
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
1197+
user_workspaces.switch_window_to_team(window_id, security.uid, ctx);
1198+
});
1199+
1200+
app.read(|ctx| {
1201+
assert_eq!(
1202+
UserWorkspaces::as_ref(ctx).team_uid_for_window(window_id),
1203+
Some(security.uid),
1204+
"switching must overwrite, unlike the insert-only registration paths"
1205+
);
1206+
});
1207+
assert_eq!(
1208+
changes.get(),
1209+
changes_after_register + 1,
1210+
"the switch must announce itself so scoped consumers resync"
1211+
);
1212+
})
1213+
}
1214+
1215+
#[test]
1216+
fn switching_a_window_to_its_current_team_announces_nothing() {
1217+
let team = team_for_test();
1218+
let workspace = workspace_for_test(&team);
1219+
1220+
App::test((), |mut app| async move {
1221+
initialize_window_team_test_app(&mut app, vec![workspace]);
1222+
1223+
let window_id = WindowId::new();
1224+
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
1225+
user_workspaces.register_window(window_id, Some(team.uid), ctx);
1226+
});
1227+
1228+
let changes = Rc::new(Cell::new(0));
1229+
let changes_for_subscription = changes.clone();
1230+
app.update(|ctx| {
1231+
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), move |_, event, _| {
1232+
if matches!(event, UserWorkspacesEvent::WindowTeamChanged { .. }) {
1233+
changes_for_subscription.set(changes_for_subscription.get() + 1);
1234+
}
1235+
});
1236+
});
1237+
1238+
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
1239+
user_workspaces.switch_window_to_team(window_id, team.uid, ctx);
1240+
});
1241+
1242+
assert_eq!(changes.get(), 0);
1243+
})
1244+
}
1245+
1246+
/// The switcher's own visibility rule, which the TUI indicator reuses rather than
1247+
/// reimplementing so the two front-ends cannot disagree about who counts as multi-team.
1248+
#[test]
1249+
fn can_switch_teams_only_with_more_than_one_team() {
1250+
let (_, _, two_team_workspace) = platform_and_security();
1251+
let one_team_workspace = workspace_for_test(&team_for_test());
1252+
1253+
App::test((), |mut app| async move {
1254+
initialize_window_team_test_app(&mut app, vec![one_team_workspace]);
1255+
app.read(|ctx| assert!(!UserWorkspaces::as_ref(ctx).can_switch_teams()));
1256+
1257+
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
1258+
user_workspaces.update_workspaces(vec![two_team_workspace], ctx);
1259+
});
1260+
app.read(|ctx| assert!(UserWorkspaces::as_ref(ctx).can_switch_teams()));
1261+
})
1262+
}
1263+
11551264
#[test]
11561265
fn test_team_contexts_represent_a_registered_teamless_window() {
11571266
App::test((), |mut app| async move {

crates/warp_tui/src/handoff/tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ fn fixture(app: &mut App) -> Fixture {
3838
window_style: WindowStyle::NotStealFocus,
3939
..Default::default()
4040
},
41-
|_| RootTuiView::new(),
41+
RootTuiView::new,
4242
)
4343
});
4444
let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test());

crates/warp_tui/src/inline_menu.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::rc::Rc;
55

66
use string_offset::CharOffset;
77
use warp::tui_export::{
8-
AcceptSlashCommandOrSavedPrompt, AgentConversationEntryId, LLMId, TuiMcpAction,
8+
AcceptSlashCommandOrSavedPrompt, AgentConversationEntryId, LLMId, ServerId, TuiMcpAction,
99
TuiUpArrowHistoryItemKind,
1010
};
1111
use warp_search_core::inline_menu::{InlineMenuResultsUpdate, InlineMenuSelection};
@@ -27,6 +27,7 @@ use crate::model_menu::TuiModelMenuModel;
2727
use crate::prompt_and_command_history_menu::TuiPromptAndCommandHistoryMenuModel;
2828
use crate::skills_menu::TuiSkillMenuModel;
2929
use crate::slash_commands::TuiSlashCommandModel;
30+
use crate::team_menu::TuiTeamMenuModel;
3031
use crate::tui_builder::TuiUiBuilder;
3132
use crate::tui_column_layout::{
3233
TuiTwoColumnConstraints, TuiTwoColumnLayout, format_tui_first_column, tui_two_column_layout,
@@ -403,6 +404,7 @@ pub(crate) enum TuiInlineMenuAccepted {
403404
SlashCommand(AcceptSlashCommandOrSavedPrompt),
404405
Conversation(AgentConversationEntryId),
405406
Model(LLMId),
407+
Team(ServerId),
406408
Mcp(TuiMcpAction),
407409
McpInstall(TuiMcpInstallFlowAction),
408410
PromptAndCommandHistory {
@@ -877,6 +879,55 @@ impl TuiInlineMenuHandle for ModelHandle<TuiModelMenuModel> {
877879
}
878880
}
879881

882+
impl TuiInlineMenuHandle for ModelHandle<TuiTeamMenuModel> {
883+
fn mode(&self) -> TuiInputSuggestionsMode {
884+
TuiInputSuggestionsMode::TeamSelector
885+
}
886+
fn is_open(&self, ctx: &AppContext) -> bool {
887+
self.as_ref(ctx).is_open(ctx)
888+
}
889+
fn open(&self, ctx: &mut AppContext) {
890+
self.update(ctx, |model, ctx| model.open(ctx));
891+
}
892+
fn input_highlight_range(&self, _ctx: &AppContext) -> Option<Range<CharOffset>> {
893+
None
894+
}
895+
896+
fn input_argument_hint_text(&self, _ctx: &AppContext) -> Option<&'static str> {
897+
None
898+
}
899+
900+
fn select_previous(&self, ctx: &mut AppContext) {
901+
self.update(ctx, |model, ctx| model.select_previous(ctx));
902+
}
903+
904+
fn select_next(&self, ctx: &mut AppContext) {
905+
self.update(ctx, |model, ctx| model.select_next(ctx));
906+
}
907+
908+
fn accept(&self, ctx: &mut AppContext) -> Option<TuiInlineMenuAccepted> {
909+
self.as_ref(ctx)
910+
.accept_selected(ctx)
911+
.map(TuiInlineMenuAccepted::Team)
912+
}
913+
914+
fn dismiss(&self, ctx: &mut AppContext) {
915+
self.update(ctx, |model, ctx| model.dismiss(ctx));
916+
}
917+
918+
fn snapshot(&self, ctx: &AppContext) -> Option<TuiInlineMenuSnapshot> {
919+
self.as_ref(ctx).snapshot(ctx)
920+
}
921+
922+
fn select_by_snapshot_index(&self, index: usize, ctx: &mut AppContext) -> bool {
923+
self.update(ctx, |model, ctx| model.select_at_snapshot_index(index, ctx))
924+
}
925+
926+
fn scroll_by_delta(&self, delta: isize, ctx: &mut AppContext) {
927+
self.update(ctx, |model, ctx| model.scroll_by_delta(delta, ctx));
928+
}
929+
}
930+
880931
impl TuiInlineMenuHandle for ModelHandle<TuiSkillMenuModel> {
881932
fn mode(&self) -> TuiInputSuggestionsMode {
882933
TuiInputSuggestionsMode::SkillMenu

crates/warp_tui/src/input/view.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ pub enum TuiInputViewEvent {
165165
AcceptedConversation(warp::tui_export::AgentConversationEntryId),
166166
/// The user selected a model menu item.
167167
AcceptedModel(LLMId),
168+
AcceptedTeam(warp::tui_export::ServerId),
168169
/// The user selected an action from the MCP menu.
169170
AcceptedMcp(TuiMcpAction),
170171
/// The user advanced the explicit MCP installation flow.
@@ -1366,6 +1367,9 @@ impl TuiInputView {
13661367
TuiInlineMenuAccepted::Model(id) => {
13671368
ctx.emit(TuiInputViewEvent::AcceptedModel(id));
13681369
}
1370+
TuiInlineMenuAccepted::Team(team_uid) => {
1371+
ctx.emit(TuiInputViewEvent::AcceptedTeam(team_uid));
1372+
}
13691373
TuiInlineMenuAccepted::Mcp(action) => {
13701374
ctx.emit(TuiInputViewEvent::AcceptedMcp(action));
13711375
}

0 commit comments

Comments
 (0)