15 KiB
Cloud Mode Input V2 — Tech Spec
Figma: House of Agents, node 7231-53160.
Context
We are reshaping the Cloud Mode composing UI to match the new Figma design. Today (V1), when a user is composing a prompt for a cloud agent, Input renders:
- An optional harness selector row above the input box.
- The editor/input box.
- A separate
AgentInputFooterbelow the input box containing the environment selector, mic/file/voice buttons, chips, model/profile selector, etc.
V2 changes the layout: a new top row above the input box that contains a host selector ("Warp"), the restyled harness selector ("Oz"), and (out of scope for this PR) an MCP-config button; and a taller input box whose control footer is rendered inside the same rounded container, holding the environment selector, voice button, image button, and profile/model selector. The legacy AgentInputFooter is not rendered in V2.
This is gated behind a new feature flag CloudModeInputV2. When the flag is off, V1 behavior is unchanged.
Relevant code
app/src/terminal/input/agent.rs(38-120) —Input::render_agent_input. Assembles the optional V1 harness row,render_input_box, andAgentInputFooterfor ambient-agent composing.app/src/terminal/view.rs(21088-21095, 24871-24938) —TerminalViewisInput's parent. It builds aFlex::columnthat stacksShrinkable::new(1., output_area)(block list, flex=1) on top ofrender_input()at its intrinsic height. That layout pins the input to the bottom of the pane; it is the authoritative reason the V1 input is not centered vertically.app/src/terminal/view/ambient_agent/harness_selector.rs—HarnessSelectorview. The menu styling (header, items, hover bg, border) matches the Figma spec and is reused. The closed-state button usesAgentInputButtonThemetoday and needs a theme override for V2.app/src/ai/blocklist/agent_view/agent_input_footer/environment_selector.rs—EnvironmentSelector. Already matches V2's chip styling (Icon::Globe4+AgentInputButtonTheme). Reused as-is.app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs(183-757, 2326-2398) —AgentInputFooterowns the mic, file/image, profile/model selector views and the theme structs. V2 reuses those view handles via accessors rather than duplicating construction/event wiring.app/src/terminal/input.rs(1598-1600, 2140-2159, 3173-3175, 13462-13529) — whereAgentInputFooterandHarnessSelectorare constructed onInput, and whererender_input_boxlives. V2 adds a newcloud_mode_input_v2field onInput.crates/warp_features/src/lib.rs—FeatureFlagenum +DOGFOOD_FLAGS.crates/warp_core/src/ui/theme/color.rs(485-560) —internal_colors::{neutral_2, neutral_3, fg_overlay_1, text_sub, ...}used for V2 theme tokens.crates/warp_core/src/ui/icons.rs—Icon::{Globe4, GitBranch, Microphone, Image, OzCloud, ChevronDown}already exist. No new icons for this PR.
Figma → theme token mapping
#9b9b9b(button text @ 90%) →theme.sub_text_color(surface_1)#696969(placeholder) →internal_colors::text_subrgba(255,255,255,0.05)(input bg + chip bg) →internal_colors::fg_overlay_1#1e1e1e(input border) →internal_colors::neutral_2#2b2b2b(chip border) →internal_colors::neutral_3#1ca05a/#d22d1e(diff counts) →theme.ansi_fg_green()/theme.ansi_fg_red()#127b9c(caret) →theme.accent()
No hex literals in client code.
Proposed changes
1. Feature flag
crates/warp_features/src/lib.rs: addFeatureFlag::CloudModeInputV2; add toDOGFOOD_FLAGS.app/Cargo.toml: addcloud_mode_input_v2 = ["cloud_mode"]and include it in the default Warp[features]list.app/src/lib.rs: wire#[cfg(feature = "cloud_mode_input_v2")] FeatureFlag::CloudModeInputV2into the compile-time flag list.
Per add-feature-flag skill.
2. HostSelector (new view)
New file app/src/terminal/view/ambient_agent/host_selector.rs. Mirrors HarnessSelector's shape (ActionButton + generic Menu<A> positioned via MenuPositioningProvider), but its menu is currently stubbed with a single "Warp" entry.
pub enum Host { Warp }
pub enum HostSelectorAction { ToggleMenu, SelectHost(Host) }
pub enum HostSelectorEvent { MenuVisibilityChanged { open: bool } }
pub struct HostSelector {
button: ViewHandle<ActionButton>,
menu: ViewHandle<Menu<HostSelectorAction>>,
is_menu_open: bool,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
selected: Host,
}
Co-located in the same file: NakedHeaderButtonTheme — an ActionButtonTheme with no border and no default background (hover → fg_overlay_1). Text color is theme.sub_text_color(surface_1). Used by both HostSelector and (via override) HarnessSelector in V2.
3. HarnessSelector: pluggable closed-state button theme
Add a builder on HarnessSelector:
pub fn with_button_theme(mut self, theme: Arc<dyn ActionButtonTheme>, ctx: &mut ViewContext<Self>) -> Self;
V1 keeps the default AgentInputButtonTheme; V2 passes NakedHeaderButtonTheme. The menu is untouched.
4. CloudModeInputV2 composite view
New file app/src/terminal/view/ambient_agent/input_v2.rs. Owns only the views that actually render in V2, plus clones of the existing shared buttons from AgentInputFooter. Hidden views (MCP config, branch selector) are not constructed.
pub struct CloudModeInputV2 {
host_selector: ViewHandle<HostSelector>, // new
harness_selector: ViewHandle<HarnessSelector>, // existing handle
environment_selector: ViewHandle<EnvironmentSelector>, // existing handle from AgentInputFooter
mic_button: ViewHandle<ActionButton>, // shared
image_button: ViewHandle<ActionButton>, // shared (current `file_button`)
profile_model_selector: ViewHandle<ProfileModelSelector>, // shared
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
}
impl CloudModeInputV2 {
/// Top-row: host_selector + harness_selector on the left. No MCP config.
pub fn render_top_row(&self, app: &AppContext) -> Box<dyn Element>;
/// Bottom row, rendered inside the same rounded input container.
/// Left: env_selector. Right: mic, image, profile/model (adjoined pair).
pub fn render_control_footer(&self, app: &AppContext) -> Box<dyn Element>;
}
AgentInputFooter grows small pub fn mic_button(), pub fn file_button(), pub fn environment_selector(), and pub fn profile_model_selector() accessors that return clones of the existing ViewHandles. V2 consumes those handles — we do not re-add typed action views or duplicate event wiring.
5. Construction: lazy & flag-gated
Input stores cloud_mode_input_v2: Option<ViewHandle<CloudModeInputV2>>, initialized to None. The HostSelector is constructed inside CloudModeInputV2::new, not on Input directly, so it is never built when V2 is off.
Input::render_agent_input lazily constructs the composite the first time V2 is active:
fn ensure_cloud_mode_input_v2(&mut self, ctx: &mut ViewContext<Self>) -> ViewHandle<CloudModeInputV2> {
if let Some(v) = self.cloud_mode_input_v2.clone() { return v; }
let view = ctx.add_typed_action_view(|inner_ctx| {
CloudModeInputV2::new(
self.menu_positioning_provider.clone(),
self.harness_selector.clone(),
self.agent_input_footer.as_ref(inner_ctx).environment_selector().clone(),
self.agent_input_footer.as_ref(inner_ctx).mic_button().clone(),
self.agent_input_footer.as_ref(inner_ctx).file_button().clone(),
self.agent_input_footer.as_ref(inner_ctx).profile_model_selector().clone(),
self.ambient_agent_view_model.clone(),
inner_ctx,
)
});
self.cloud_mode_input_v2 = Some(view.clone());
view
}
Once constructed, the handle is kept (the feature flag is not expected to toggle off mid-session; if it does, we simply stop rendering it — views are cheap and get dropped at Input teardown).
6. Centering — requires a parent-side change in TerminalView
The Figma mock shows the composing UI centered horizontally and vertically in the otherwise-empty pane. Centering cannot be achieved purely inside Input::render_agent_input, because Input's parent (TerminalView::render, app/src/terminal/view.rs:24879-24938) already assigns the input only its intrinsic vertical height: the parent column is structured as
Flex::column
├─ Shrinkable::new(1., output_area) // block list — flex factor 1, fills remaining space
└─ render_input() // intrinsic height, pinned to bottom
That structure is what pins V1's input to the bottom of the pane. Any Align/Expanded we add inside render_agent_input only centers within the slim band the parent grants it.
To vertically center, the parent must yield the remaining vertical space to the input. We modify TerminalView::render to branch on V2-composing:
// app/src/terminal/view.rs, inside TerminalView::render.
let show_cloud_mode_v2 = FeatureFlag::CloudModeInputV2.is_enabled()
&& FeatureFlag::CloudMode.is_enabled()
&& self.ambient_agent_view_model.as_ref(app).is_configuring_ambient_agent();
if show_cloud_mode_v2 {
// During V2 composing:
// - The block list is empty (no blocks until the agent is dispatched;
// the progress overlay is already gated by CloudModeSetupV2).
// - No waterfall gap / alt-screen path is relevant.
// - Input takes the whole pane and centers its own content.
column.add_child(Expanded::new(1., self.render_input()).finish());
} else {
column.add_child(Shrinkable::new(1., output_area).finish());
if model.is_alt_screen_active() && self.should_render_use_agent_footer(&model, app) {
column.add_child(ChildView::new(&self.use_agent_footer).finish());
}
if self.is_input_box_visible(&model, app) {
column.add_child(self.render_input());
} else if /* ... pre-first-exchange ... */ {
column.add_child(ambient_agent::render_loading_footer(appearance));
}
}
With the parent granting all remaining vertical space, render_agent_input centers both axes using Align::new(ConstrainedBox::with_max_width(720.)):
// app/src/terminal/input/agent.rs, inside render_agent_input.
const CLOUD_MODE_V2_MAX_WIDTH: f32 = 720.; // From Figma `Frame 42` width.
if show_cloud_mode_v2 {
let v2 = self.ensure_cloud_mode_input_v2(ctx);
let stacked = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Min)
.with_spacing(10.) // Figma `gap=10`.
.with_child(v2.as_ref(app).render_top_row(app))
.with_child(self.render_cloud_mode_v2_input_box(&v2, appearance, app))
.finish();
// Align centers along both axes because its parent (Expanded in TerminalView)
// gives it the full pane.
column.add_child(
Align::new(
ConstrainedBox::new(stacked)
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
.finish(),
)
.finish(),
);
// Legacy `AgentInputFooter` is NOT rendered in this branch.
} else {
// ...existing V1 body (harness_row + render_input_box + AgentInputFooter)...
}
render_cloud_mode_v2_input_box is a new helper on Input:
Container::new(Flex::column().with_child(editor).with_child(v2.render_control_footer(app))).with_background(fg_overlay_1).with_border(Border::all(1.).with_border_fill(neutral_2)).with_corner_radius(8.)whereeditorcomes from the existingrender_input_boxpath with its padding tweaked sopt=10/pb=8/px=16and a taller max-height (236px, matchingCLI_AGENT_RICH_INPUT_EDITOR_MAX_HEIGHT).
Key layout contract: TerminalView owns where the input lives in the pane; Input owns what the input looks like. The V2 branch is the only place this split is explicit — V1 paths are untouched.
7. Field on Input
Input gains cloud_mode_input_v2: Option<ViewHandle<CloudModeInputV2>>, initialized to None. When the V2 flag is off, nothing is constructed. HostSelector lives entirely inside CloudModeInputV2 so it is also never constructed when V2 is off.
8. Out of scope
- MCP config button: hidden entirely for this PR — not constructed.
- Branch selector: hidden entirely for this PR — not constructed.
- V1 code path: left intact.
Testing and validation
Unit tests are intentionally out of scope for this PR (per explicit decision). Validation is:
cargo check --features cloud_mode_input_v2must pass cleanly, both with the V2 cargo feature enabled and in a default build (V1 path).- Manual verification.
cargo run --features with_local_server,cloud_mode_input_v2, then enter cloud mode composing and visually confirm:- Host + harness row sits above the input, no background/border in default state, hover shows subtle
fg_overlay_1. - Input box is taller, rounded, with
fg_overlay_1bg andneutral_2border. - Env/voice/image/profile+model buttons live inside the input box.
- The whole composing UI is centered horizontally (≤ 720px wide) and vertically in the pane.
- Toggling the flag off restores the previous layout (input at the bottom, block list above, legacy
AgentInputFooterrendered).
- Host + harness row sits above the input, no background/border in default state, hover shows subtle
Risks and mitigations
- Shared view handles: passing
mic_button/file_buttonetc. intoCloudModeInputV2means both the oldAgentInputFooterand the new V2 view can reference them. Because V2 replaces the footer at render time (not both), this is safe — each render produces exactly one parent that renders each handle. Guarded by feature flag +is_configuring_ambient_agent(). - Scope creep: MCP config and branch selector are intentionally deferred; their absence should not regress any existing flow because they did not exist in V1.
- Feature flag fallthrough: both the cargo feature and the runtime flag are required. If only one is set, we fall back to V1.
- Parent-side layout: when V2-composing,
TerminalView::renderbypasses the block list, alt-screen, and use-agent-footer branches and yields the whole pane to the input. If a future change starts inserting content into the block list during composing (e.g. a new inline banner), it will be invisible in V2. Since unit tests are deferred, rely on manual verification + code review to catch regressions here. - Mid-session flag toggle:
CloudModeInputV2is a compile-time feature but may be toggled at runtime in dev builds. Because the V2 composite is lazily constructed, flipping the flag on mid-session is fine. Flipping it off simply stops rendering the composite; its view handle is retained untilInputis dropped.
Follow-ups
- Wire the MCP config button (dialog, JSON validation, persistence).
- Replace the hidden branch selector with a real branch picker tied to repo status.
- Populate
HostSelectorwith real host options once the server contract exists.