From bc1a9f360b081ab6779fb2e89b430ae9f2377f73 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 07:41:56 -0400 Subject: [PATCH 01/40] add reusable TUI editor view Co-Authored-By: Oz --- crates/editor/src/render/model/mod.rs | 31 +- crates/editor/src/render/model/mod_tests.rs | 12 + crates/warp_tui/src/editor_element.rs | 5 +- crates/warp_tui/src/editor_interaction.rs | 559 +++++++++++++ crates/warp_tui/src/editor_view.rs | 202 +++++ crates/warp_tui/src/editor_view_tests.rs | 351 +++++++++ crates/warp_tui/src/input/kill_buffer.rs | 44 -- crates/warp_tui/src/input/mod.rs | 5 +- crates/warp_tui/src/input/view.rs | 735 ++---------------- crates/warp_tui/src/input/view_tests.rs | 202 ++++- crates/warp_tui/src/keybindings.rs | 45 +- crates/warp_tui/src/lib.rs | 4 + .../code-1822-tui-generic-editor-view/TECH.md | 177 +++++ 13 files changed, 1598 insertions(+), 774 deletions(-) create mode 100644 crates/warp_tui/src/editor_interaction.rs create mode 100644 crates/warp_tui/src/editor_view.rs create mode 100644 crates/warp_tui/src/editor_view_tests.rs delete mode 100644 crates/warp_tui/src/input/kill_buffer.rs create mode 100644 specs/code-1822-tui-generic-editor-view/TECH.md diff --git a/crates/editor/src/render/model/mod.rs b/crates/editor/src/render/model/mod.rs index 1dee0938e68..bb019bf7414 100644 --- a/crates/editor/src/render/model/mod.rs +++ b/crates/editor/src/render/model/mod.rs @@ -859,6 +859,18 @@ impl CharCellState { self.scroll_offset.get() } + /// Clamps the retained viewport offset to the current display-row count. + pub fn clamp_scroll_offset( + &self, + cursor_char_offset: CharOffset, + viewport_rows: u32, + hidden_line_ranges: &[Range], + ) { + let (_, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges); + let (offset, _) = self.clamped_scroll_window(total_rows, viewport_rows); + self.scroll_offset.set(offset); + } + /// Scrolls the viewport by `rows` display rows (negative scrolls toward /// the top), clamped to `[0, total_rows - visible_rows]`. Independent of /// the cursor: wheel scrolling must not snap the viewport back to it. @@ -892,14 +904,7 @@ impl CharCellState { ) { let (cursor_row, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges); - let visible_rows = total_rows.min(viewport_rows).max(1); - // A stale offset can point past the last remaining row (e.g. after a - // deletion shrank the content); clamp it so the visible window always - // overlaps real rows before following the cursor. - let mut offset = self - .scroll_offset - .get() - .min(total_rows.saturating_sub(visible_rows)); + let (mut offset, visible_rows) = self.clamped_scroll_window(total_rows, viewport_rows); let Some(cursor_row) = cursor_row else { self.scroll_offset.set(offset); return; @@ -912,6 +917,16 @@ impl CharCellState { self.scroll_offset.set(offset); } + /// Returns the clamped first row and visible-row count for a viewport. + fn clamped_scroll_window(&self, total_rows: u32, viewport_rows: u32) -> (u32, u32) { + let visible_rows = total_rows.min(viewport_rows).max(1); + let offset = self + .scroll_offset + .get() + .min(total_rows.saturating_sub(visible_rows)); + (offset, visible_rows) + } + /// The cursor's display row and the total display-row count — including /// the deferred-wrap phantom row the cursor sits on when a logical line /// exactly fills the terminal width, which the lattice's rows never count diff --git a/crates/editor/src/render/model/mod_tests.rs b/crates/editor/src/render/model/mod_tests.rs index 66d707baa86..eee335cd3e4 100644 --- a/crates/editor/src/render/model/mod_tests.rs +++ b/crates/editor/src/render/model/mod_tests.rs @@ -2086,4 +2086,16 @@ mod char_cell_scroll { state.follow_cursor(CharOffset::zero(), 2, &[]); assert_eq!(state.scroll_offset(), 0); } + + #[test] + fn clamp_scroll_offset_repairs_stale_offset_without_following_cursor() { + let state = CharCellState::new(3, None); + state.update_text("abcdef"); + state.scroll_by(100, 1, CharOffset::from(6), &[]); + assert_eq!(state.scroll_offset(), 2); + + state.set_terminal_width(10); + state.clamp_scroll_offset(CharOffset::from(6), 1, &[]); + assert_eq!(state.scroll_offset(), 0); + } } diff --git a/crates/warp_tui/src/editor_element.rs b/crates/warp_tui/src/editor_element.rs index 39e534b59f1..17d30955c51 100644 --- a/crates/warp_tui/src/editor_element.rs +++ b/crates/warp_tui/src/editor_element.rs @@ -45,7 +45,7 @@ const WHEEL_STEP: isize = 2; /// owning view translates them into its own typed actions and applies them to /// the editor model (mirroring how the GUI's element dispatches into its view). #[derive(Debug, Clone)] -pub(crate) enum TuiEditorAction { +pub enum TuiEditorAction { /// Insert a printable character (only emitted when the element is /// [`editable`](TuiEditorElement::editable)). InsertChar(char), @@ -316,6 +316,9 @@ impl TuiEditorElement { let chars: Vec = self.text.chars().collect(); let cursor_offset = CharOffset::from(self.cursor_offset.as_usize().saturating_sub(1)); + if let Some(viewport_rows) = self.viewport_rows { + char_cell.clamp_scroll_offset(cursor_offset, viewport_rows, &hidden); + } // The first visible row is model-side scroll state; unwindowed // consumers always render from the top. let first_visible_row = if self.viewport_rows.is_some() { diff --git a/crates/warp_tui/src/editor_interaction.rs b/crates/warp_tui/src/editor_interaction.rs new file mode 100644 index 00000000000..6aad5b1e26c --- /dev/null +++ b/crates/warp_tui/src/editor_interaction.rs @@ -0,0 +1,559 @@ +use std::ops::Range; + +use string_offset::CharOffset; +use warp::editor::CodeEditorModel; +use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; +use warp_editor::render::model::CharCellState; +use warp_editor::selection::{TextDirection, TextUnit}; +use warpui_core::text::word_boundaries::WordBoundariesPolicy; +use warpui_core::{AppContext, ModelHandle}; + +use crate::editor_element::TuiEditorAction; + +/// Editing commands shared by TUI text fields. +#[derive(Clone, Copy, Debug)] +pub enum TuiEditorCommand { + InsertNewline, + Backspace, + DeleteForward, + DeleteWordBackward, + DeleteWordForward, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveWordLeft, + MoveWordRight, + MoveToLineStart, + MoveToLineEnd, + SelectLeft, + SelectRight, + SelectUp, + SelectDown, + SelectWordLeft, + SelectWordRight, + SelectAll, + KillToLineEnd, + KillToLineStart, + Yank, + Undo, + Redo, +} + +/// Controls whether an editor accepts hard line breaks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorLineMode { + SingleLine, + Multiline, +} + +/// Editing behavior supplied by each TUI editor consumer. +#[derive(Clone, Copy, Debug)] +pub(crate) struct TuiEditorBehavior { + line_mode: TuiEditorLineMode, + viewport_rows: u32, +} + +impl TuiEditorBehavior { + /// Configures a one-row editor that rejects text after the first line. + pub(crate) const fn single_line() -> Self { + Self { + line_mode: TuiEditorLineMode::SingleLine, + viewport_rows: 1, + } + } + + /// Configures a multiline editor with a bounded viewport. + pub(crate) const fn multiline(viewport_rows: u32) -> Self { + Self { + line_mode: TuiEditorLineMode::Multiline, + viewport_rows, + } + } + + /// Returns the number of visible editor rows. + pub(crate) const fn viewport_rows(self) -> u32 { + self.viewport_rows + } + + /// Applies this editor's line policy to inserted or replacement text. + pub(crate) fn normalize_text(self, text: &str) -> &str { + match self.line_mode { + TuiEditorLineMode::SingleLine => text.lines().next().unwrap_or_default(), + TuiEditorLineMode::Multiline => text, + } + } + + /// Returns whether hard newlines are accepted. + const fn accepts_newlines(self) -> bool { + matches!(self.line_mode, TuiEditorLineMode::Multiline) + } +} + +/// Viewport work required after applying an editor interaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorInteractionOutcome { + FollowCursor, + PreserveViewport, +} + +/// Selects stable user-configurable names for each binding consumer. +#[derive(Clone, Copy)] +pub(crate) enum TuiEditorBindingTarget { + Input, + Editor, +} + +struct EditorBindingSpec { + command: TuiEditorCommand, + input_name: Option<&'static str>, + editor_name: Option<&'static str>, + description: &'static str, + keys: &'static [&'static str], +} + +/// One target-specific editor binding definition. +#[derive(Clone, Copy)] +pub(crate) struct TuiEditorBindingSpec { + pub(crate) command: TuiEditorCommand, + pub(crate) name: &'static str, + pub(crate) description: &'static str, + pub(crate) keys: &'static [&'static str], +} + +const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[ + EditorBindingSpec { + command: TuiEditorCommand::InsertNewline, + input_name: Some("tui:input:insert_newline"), + editor_name: None, + description: "Insert a newline", + keys: &["shift-enter", "ctrl-j", "alt-enter"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Backspace, + input_name: Some("tui:input:backspace"), + editor_name: Some("tui:editor:backspace"), + description: "Delete the previous character", + keys: &["backspace", "shift-backspace", "ctrl-h"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteForward, + input_name: Some("tui:input:delete_forward"), + editor_name: Some("tui:editor:delete_forward"), + description: "Delete the next character", + keys: &["delete", "ctrl-d"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteWordBackward, + input_name: Some("tui:input:delete_word_backward"), + editor_name: Some("tui:editor:delete_word_backward"), + description: "Delete the previous word", + keys: &["ctrl-w", "ctrl-backspace", "alt-backspace"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteWordForward, + input_name: Some("tui:input:delete_word_forward"), + editor_name: Some("tui:editor:delete_word_forward"), + description: "Delete the next word", + keys: &["alt-d", "alt-delete", "ctrl-delete"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveLeft, + input_name: Some("tui:input:move_left"), + editor_name: Some("tui:editor:move_left"), + description: "Move cursor left", + keys: &["left", "ctrl-b"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveRight, + input_name: Some("tui:input:move_right"), + editor_name: Some("tui:editor:move_right"), + description: "Move cursor right", + keys: &["right", "ctrl-f"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveUp, + input_name: Some("tui:input:move_up"), + editor_name: None, + description: "Move cursor up", + keys: &["up", "ctrl-p"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveDown, + input_name: Some("tui:input:move_down"), + editor_name: None, + description: "Move cursor down", + keys: &["down", "ctrl-n"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveWordLeft, + input_name: Some("tui:input:move_word_left"), + editor_name: Some("tui:editor:move_word_left"), + description: "Move cursor one word left", + keys: &["alt-left", "alt-b", "ctrl-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveWordRight, + input_name: Some("tui:input:move_word_right"), + editor_name: Some("tui:editor:move_word_right"), + description: "Move cursor one word right", + keys: &["alt-right", "alt-f", "ctrl-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveToLineStart, + input_name: Some("tui:input:move_to_line_start"), + editor_name: Some("tui:editor:move_to_line_start"), + description: "Move cursor to start of line", + keys: &["home", "ctrl-a"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveToLineEnd, + input_name: Some("tui:input:move_to_line_end"), + editor_name: Some("tui:editor:move_to_line_end"), + description: "Move cursor to end of line", + keys: &["end", "ctrl-e"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectLeft, + input_name: Some("tui:input:select_left"), + editor_name: Some("tui:editor:select_left"), + description: "Extend selection left", + keys: &["shift-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectRight, + input_name: Some("tui:input:select_right"), + editor_name: Some("tui:editor:select_right"), + description: "Extend selection right", + keys: &["shift-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectUp, + input_name: Some("tui:input:select_up"), + editor_name: None, + description: "Extend selection up", + keys: &["shift-up"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectDown, + input_name: Some("tui:input:select_down"), + editor_name: None, + description: "Extend selection down", + keys: &["shift-down"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectWordLeft, + input_name: Some("tui:input:select_word_left"), + editor_name: Some("tui:editor:select_word_left"), + description: "Extend selection one word left", + keys: &["ctrl-shift-left", "alt-shift-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectWordRight, + input_name: Some("tui:input:select_word_right"), + editor_name: Some("tui:editor:select_word_right"), + description: "Extend selection one word right", + keys: &["ctrl-shift-right", "alt-shift-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectAll, + input_name: Some("tui:input:select_all"), + editor_name: Some("tui:editor:select_all"), + description: "Select all text", + keys: &["ctrl-shift-A"], + }, + EditorBindingSpec { + command: TuiEditorCommand::KillToLineEnd, + input_name: Some("tui:input:kill_to_line_end"), + editor_name: Some("tui:editor:kill_to_line_end"), + description: "Delete to end of line", + keys: &["ctrl-k"], + }, + EditorBindingSpec { + command: TuiEditorCommand::KillToLineStart, + input_name: Some("tui:input:kill_to_line_start"), + editor_name: Some("tui:editor:kill_to_line_start"), + description: "Delete to start of line", + keys: &["ctrl-u"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Yank, + input_name: Some("tui:input:yank"), + editor_name: Some("tui:editor:yank"), + description: "Paste the last deleted text", + keys: &["ctrl-y"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Undo, + input_name: Some("tui:input:undo"), + editor_name: Some("tui:editor:undo"), + description: "Undo", + keys: &["ctrl-z"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Redo, + input_name: Some("tui:input:redo"), + editor_name: Some("tui:editor:redo"), + description: "Redo", + keys: &["ctrl-shift-Z"], + }, +]; + +/// Returns the editor binding metadata applicable to one consumer. +pub(crate) fn editor_binding_specs( + target: TuiEditorBindingTarget, +) -> impl Iterator { + SHARED_EDITOR_BINDINGS.iter().filter_map(move |spec| { + let name = match target { + TuiEditorBindingTarget::Input => spec.input_name, + TuiEditorBindingTarget::Editor => spec.editor_name, + }?; + Some(TuiEditorBindingSpec { + command: spec.command, + name, + description: spec.description, + keys: spec.keys, + }) + }) +} + +/// Mutable editing state shared by TUI editor views. +#[derive(Debug, Default)] +pub(crate) struct TuiEditorState { + kill_buffer: String, +} + +impl TuiEditorState { + /// Applies a keybound command to a char-cell editor model. + pub(crate) fn apply_command( + &mut self, + model: &ModelHandle, + command: TuiEditorCommand, + behavior: TuiEditorBehavior, + ctx: &mut AppContext, + ) -> TuiEditorInteractionOutcome { + match command { + TuiEditorCommand::InsertNewline => { + if behavior.accepts_newlines() { + model.update(ctx, |model, ctx| model.user_insert("\n", ctx)); + } + } + TuiEditorCommand::Backspace => { + model.update(ctx, |model, ctx| model.backspace(ctx)); + } + TuiEditorCommand::DeleteForward => { + model.update(ctx, |model, ctx| { + model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx); + }); + } + TuiEditorCommand::DeleteWordBackward => { + model.update(ctx, |model, ctx| { + model.delete( + TextDirection::Backwards, + TextUnit::Word(WordBoundariesPolicy::Default), + false, + ctx, + ); + }); + } + TuiEditorCommand::DeleteWordForward => { + model.update(ctx, |model, ctx| { + model.delete( + TextDirection::Forwards, + TextUnit::Word(WordBoundariesPolicy::Default), + false, + ctx, + ); + }); + } + TuiEditorCommand::MoveLeft => { + model.update(ctx, |model, ctx| model.move_left(ctx)); + } + TuiEditorCommand::MoveRight => { + model.update(ctx, |model, ctx| model.move_right(ctx)); + } + TuiEditorCommand::MoveUp => { + model.update(ctx, |model, ctx| model.move_up(ctx)); + } + TuiEditorCommand::MoveDown => { + model.update(ctx, |model, ctx| model.move_down(ctx)); + } + TuiEditorCommand::MoveWordLeft => { + model.update(ctx, |model, ctx| { + model.backward_word_with_unit( + false, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::MoveWordRight => { + model.update(ctx, |model, ctx| { + model.forward_word_with_unit( + false, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::MoveToLineStart => { + model.update(ctx, |model, ctx| model.move_to_line_start(ctx)); + } + TuiEditorCommand::MoveToLineEnd => { + model.update(ctx, |model, ctx| model.move_to_line_end(ctx)); + } + TuiEditorCommand::SelectLeft => { + model.update(ctx, |model, ctx| model.select_left(ctx)); + } + TuiEditorCommand::SelectRight => { + model.update(ctx, |model, ctx| model.select_right(ctx)); + } + TuiEditorCommand::SelectUp => { + model.update(ctx, |model, ctx| model.select_up(ctx)); + } + TuiEditorCommand::SelectDown => { + model.update(ctx, |model, ctx| model.select_down(ctx)); + } + TuiEditorCommand::SelectWordLeft => { + model.update(ctx, |model, ctx| { + model.backward_word_with_unit( + true, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::SelectWordRight => { + model.update(ctx, |model, ctx| { + model.forward_word_with_unit( + true, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::SelectAll => { + model.update(ctx, |model, ctx| model.select_all(ctx)); + } + TuiEditorCommand::KillToLineEnd => { + if let Some(killed) = model.update(ctx, |model, ctx| { + model.kill_to_char_cell_visual_row_end(ctx) + }) { + self.kill_buffer = killed; + } + } + TuiEditorCommand::KillToLineStart => { + if let Some(killed) = model.update(ctx, |model, ctx| { + model.kill_to_char_cell_visual_row_start(ctx) + }) { + self.kill_buffer = killed; + } + } + TuiEditorCommand::Yank => { + if !self.kill_buffer.is_empty() { + model.update(ctx, |model, ctx| { + model.user_insert(&self.kill_buffer, ctx); + }); + } + } + TuiEditorCommand::Undo => { + model.update(ctx, |model, ctx| model.undo(ctx)); + } + TuiEditorCommand::Redo => { + model.update(ctx, |model, ctx| model.redo(ctx)); + } + } + TuiEditorInteractionOutcome::FollowCursor + } +} + +/// Applies an element-originated action and reports the required viewport work. +pub(crate) fn apply_editor_action( + model: &ModelHandle, + action: &TuiEditorAction, + behavior: TuiEditorBehavior, + ctx: &mut AppContext, +) -> TuiEditorInteractionOutcome { + match action { + TuiEditorAction::InsertChar(c) => { + model.update(ctx, |model, ctx| model.user_insert(&c.to_string(), ctx)); + } + TuiEditorAction::InsertText(text) => { + let text = behavior.normalize_text(text); + model.update(ctx, |model, ctx| model.user_insert(text, ctx)); + } + TuiEditorAction::SelectionStartAt { offset } => { + model.update(ctx, |model, ctx| model.select_at(*offset, false, ctx)); + } + TuiEditorAction::SelectionExtendTo { offset } => { + model.update(ctx, |model, ctx| { + model.set_last_selection_head(*offset, ctx) + }); + } + TuiEditorAction::SelectWordAt { offset } => { + model.update(ctx, |model, ctx| model.select_word_at(*offset, false, ctx)); + } + TuiEditorAction::SelectLineAt { offset } => { + model.update(ctx, |model, ctx| model.select_line_at(*offset, false, ctx)); + } + TuiEditorAction::SelectionUpdateTo { offset } => { + model.update(ctx, |model, ctx| { + model.update_pending_selection(*offset, ctx) + }); + } + TuiEditorAction::SelectionEnd => { + model.update(ctx, |model, ctx| model.end_selection(ctx)); + } + TuiEditorAction::Scroll { rows } => { + scroll_editor_viewport(model, *rows, behavior, ctx); + return TuiEditorInteractionOutcome::PreserveViewport; + } + } + TuiEditorInteractionOutcome::FollowCursor +} + +/// Scrolls a char-cell viewport just enough to keep its primary cursor visible. +pub(crate) fn follow_editor_cursor( + model: &ModelHandle, + behavior: TuiEditorBehavior, + ctx: &AppContext, +) { + with_editor_viewport(model, ctx, |char_cell, cursor_offset, hidden| { + char_cell.follow_cursor(cursor_offset, behavior.viewport_rows(), hidden); + }); +} + +/// Scrolls a char-cell viewport without moving its primary cursor. +fn scroll_editor_viewport( + model: &ModelHandle, + rows: isize, + behavior: TuiEditorBehavior, + ctx: &AppContext, +) { + with_editor_viewport(model, ctx, |char_cell, cursor_offset, hidden| { + char_cell.scroll_by(rows, behavior.viewport_rows(), cursor_offset, hidden); + }); +} + +/// Reads the primary cursor and hidden rows for a char-cell viewport operation. +fn with_editor_viewport( + model: &ModelHandle, + ctx: &AppContext, + f: impl FnOnce(&CharCellState, CharOffset, &[Range]), +) { + let model = model.as_ref(ctx); + let cursor_offset = model + .selection_model() + .as_ref(ctx) + .cursors(ctx) + .into_iter() + .next() + .unwrap_or_default(); + let render = model.render_state().as_ref(ctx); + let Some(char_cell) = render.char_cell() else { + return; + }; + let cursor_offset = CharOffset::from(cursor_offset.as_usize().saturating_sub(1)); + let hidden = char_cell.hidden_line_ranges(ctx); + f(char_cell, cursor_offset, &hidden); +} diff --git a/crates/warp_tui/src/editor_view.rs b/crates/warp_tui/src/editor_view.rs new file mode 100644 index 00000000000..53a2cc2c65e --- /dev/null +++ b/crates/warp_tui/src/editor_view.rs @@ -0,0 +1,202 @@ +//! Generic focusable TUI text field over the shared editor model and element. +//! +//! Unlike [`crate::input::TuiInputView`], this view owns no prompt submission, +//! input-mode, inline-menu, or form policy. Embedding views provide that chrome +//! and behavior while reusing model-backed editing and focus handling. + +use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; +use warp_editor::model::CoreEditorModel; +use warpui_core::elements::tui::{TuiElement, TuiHoverable}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{ + AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, + ViewContext, +}; + +use crate::editor_element::{TuiEditorAction, TuiEditorElement}; +use crate::editor_interaction::{ + apply_editor_action, follow_editor_cursor, TuiEditorBehavior, TuiEditorCommand, + TuiEditorInteractionOutcome, TuiEditorState, +}; + +/// Events emitted when the editor content changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorViewEvent { + Changed(String), +} + +/// Actions raised by the shared editor element or editor chrome. +#[derive(Clone, Debug)] +pub(crate) enum TuiEditorViewAction { + FocusRequested, + Editor(TuiEditorAction), + Command(TuiEditorCommand), +} + +/// A reusable single-line editor view. +pub(crate) struct TuiEditorView { + model: ModelHandle, + editor_state: TuiEditorState, + editor_behavior: TuiEditorBehavior, + focused: bool, + /// Target text of an in-flight programmatic replacement. Content events + /// are suppressed until the model reaches this value (including an + /// intermediate clear event). + suppressed_text_target: Option, + mouse_state: MouseStateHandle, +} + +impl TuiEditorView { + /// Creates an empty single-line editor backed by a char-cell model. + pub(crate) fn single_line(ctx: &mut ViewContext) -> Self { + let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(1, ctx)); + ctx.subscribe_to_model(&model, |editor, _, event, ctx| { + if !matches!(event, CodeEditorModelEvent::ContentChanged { .. }) { + return; + } + let text = editor.text(ctx); + if let Some(target) = &editor.suppressed_text_target { + if &text == target { + editor.suppressed_text_target = None; + } + } else { + ctx.emit(TuiEditorViewEvent::Changed(text)); + } + ctx.notify(); + }); + Self { + model, + editor_state: TuiEditorState::default(), + editor_behavior: TuiEditorBehavior::single_line(), + focused: false, + suppressed_text_target: None, + mouse_state: MouseStateHandle::default(), + } + } + + /// Returns the current editor text. + pub(crate) fn text(&self, ctx: &AppContext) -> String { + let model = self.model.as_ref(ctx); + let buffer = model.content().as_ref(ctx); + if buffer.is_empty() { + String::new() + } else { + buffer.text().into_string() + } + } + + /// Returns whether the editor owns focus. + pub(crate) fn is_focused(&self) -> bool { + self.focused + } + + /// Replaces editor content without emitting `Changed`. + pub(crate) fn set_text(&mut self, text: impl Into, ctx: &mut ViewContext) { + let text = text.into(); + let text = self.editor_behavior.normalize_text(&text).to_string(); + if self.text(ctx) == text { + return; + } + self.suppressed_text_target = Some(text.clone()); + self.model.update(ctx, |model, ctx| { + model.clear_buffer(ctx); + model.user_insert(&text, ctx); + }); + ctx.notify(); + } + + /// Renders the shared editor configured as a one-row field. + fn render_editor(&self, ctx: &AppContext) -> Box { + TuiEditorElement::new(&self.model, ctx) + .editable() + .with_view_focused(self.focused) + .with_viewport_rows(self.editor_behavior.viewport_rows()) + .on_action(|action, event_ctx| { + event_ctx.dispatch_typed_action(TuiEditorViewAction::Editor(action)); + }) + .finish() + } + + /// Applies an editor action using the same model operations as `TuiInputView`. + fn handle_editor_action( + &mut self, + action: &TuiEditorAction, + ctx: &mut ViewContext, + ) -> TuiEditorInteractionOutcome { + if matches!( + action, + TuiEditorAction::SelectionStartAt { .. } + | TuiEditorAction::SelectionExtendTo { .. } + | TuiEditorAction::SelectWordAt { .. } + | TuiEditorAction::SelectLineAt { .. } + ) { + ctx.focus_self(); + } + apply_editor_action(&self.model, action, self.editor_behavior, ctx) + } + + /// Applies a keybound editor command to the shared editor model. + fn handle_command( + &mut self, + command: TuiEditorCommand, + ctx: &mut ViewContext, + ) -> TuiEditorInteractionOutcome { + self.editor_state + .apply_command(&self.model, command, self.editor_behavior, ctx) + } +} + +impl Entity for TuiEditorView { + type Event = TuiEditorViewEvent; +} + +impl TuiView for TuiEditorView { + fn ui_name() -> &'static str { + "TuiEditorView" + } + + fn render(&self, app: &AppContext) -> Box { + TuiHoverable::new(self.mouse_state.clone(), self.render_editor(app)) + .on_click(|event_ctx, _| { + event_ctx.dispatch_typed_action(TuiEditorViewAction::FocusRequested); + }) + .finish() + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() { + self.focused = true; + ctx.notify(); + } + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiEditorView { + type Action = TuiEditorViewAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + let outcome = match action { + TuiEditorViewAction::FocusRequested => { + ctx.focus_self(); + TuiEditorInteractionOutcome::FollowCursor + } + TuiEditorViewAction::Editor(action) => self.handle_editor_action(action, ctx), + TuiEditorViewAction::Command(command) => self.handle_command(*command, ctx), + }; + if outcome == TuiEditorInteractionOutcome::FollowCursor { + follow_editor_cursor(&self.model, self.editor_behavior, ctx); + } + ctx.notify(); + } +} + +#[cfg(test)] +#[path = "editor_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/editor_view_tests.rs b/crates/warp_tui/src/editor_view_tests.rs new file mode 100644 index 00000000000..0a7bbf4bb6b --- /dev/null +++ b/crates/warp_tui/src/editor_view_tests.rs @@ -0,0 +1,351 @@ +use std::collections::HashSet; + +use string_offset::CharOffset; +use warp::tui_export::Appearance; +use warp_editor::model::CoreEditorModel; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityIdMap}; +use warpui_core::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiConstraint, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::keymap::Trigger; +use warpui_core::{App, TuiView as _, TypedActionView as _}; + +use super::{TuiEditorView, TuiEditorViewAction}; +use crate::editor_element::TuiEditorAction; +use crate::editor_interaction::TuiEditorCommand; +use crate::test_fixtures::TestHostView; + +/// Renders an editor view to trimmed lines. +fn render_lines(app: &App, editor: &warpui_core::ViewHandle) -> Vec { + render_lines_at_width(app, editor, 30) +} + +#[test] +fn layout_clamps_stale_scroll_after_resize_and_text_replacement() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines_at_width(&app, &editor, 3); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcdef".to_string())), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 2); + + assert_eq!(render_lines_at_width(&app, &editor, 30)[0], "abcdef"); + assert_eq!(scroll_offset(&app, &editor), 0); + + render_lines_at_width(&app, &editor, 3); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveToLineEnd), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 2); + + editor.update(&mut app, |editor, ctx| editor.set_text("x", ctx)); + assert_eq!(scroll_offset(&app, &editor), 2); + assert_eq!(render_lines_at_width(&app, &editor, 3)[0], "x"); + assert_eq!(scroll_offset(&app, &editor), 0); + }); +} + +/// Renders an editor view at a fixed width. +fn render_lines_at_width( + app: &App, + editor: &warpui_core::ViewHandle, + width: u16, +) -> Vec { + app.read(|ctx| { + let mut element = editor.as_ref(ctx).render(ctx); + let mut rendered_views = EntityIdMap::default(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = element.layout( + TuiConstraint::loose(TuiSize::new(width, 4)), + &mut layout_ctx, + ctx, + ); + let area = TuiRect::new(0, 0, size.width, size.height); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_string()) + .collect() + }) +} + +/// Returns the editor's first visible char-cell row. +fn scroll_offset(app: &App, editor: &warpui_core::ViewHandle) -> u32 { + editor.read(app, |editor, ctx| { + editor + .model + .as_ref(ctx) + .render_state() + .as_ref(ctx) + .char_cell() + .expect("TUI editor model is char-cell") + .scroll_offset() + }) +} + +#[test] +fn single_line_paste_discards_later_lines() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText( + "first\nsecond".to_string(), + )), + ctx, + ); + }); + + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "first"); + editor.update(&mut app, |editor, ctx| { + editor.set_text("third\nfourth", ctx); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::InsertNewline), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "third"); + }); +} + +#[test] +fn kill_and_yank_are_shared_with_the_generic_editor() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines(&app, &editor); + + editor.update(&mut app, |editor, ctx| { + editor.set_text("abcd", ctx); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::KillToLineEnd), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "ab"); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action(&TuiEditorViewAction::Command(TuiEditorCommand::Yank), ctx); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "abcd"); + }); +} + +#[test] +fn editor_follows_cursor_within_its_one_row_viewport() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines_at_width(&app, &editor, 3); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcd".to_string())), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 1); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 0); + }); +} + +#[test] +fn focus_hooks_update_editor_focus_without_changing_text() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + editor.update(&mut app, |editor, ctx| { + editor.set_text("gen", ctx); + ctx.focus_self(); + }); + assert!(editor.read(&app, |editor, _| editor.is_focused())); + assert_eq!(render_lines(&app, &editor)[0], "gen"); + + let other = app.update(|ctx| ctx.add_tui_view(window_id, |_| TestHostView)); + other.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(!editor.read(&app, |editor, _| editor.is_focused())); + assert_eq!(render_lines(&app, &editor)[0], "gen"); + }); +} + +#[test] +fn keybinding_initializer_registers_line_start_for_input_and_editor() { + App::test((), |mut app| async move { + app.update(crate::keybindings::init); + + let triggers_for = |name: &str| { + app.read(|ctx| { + ctx.get_key_bindings() + .filter(|binding| binding.name == name) + .filter_map(|binding| match binding.trigger { + Trigger::Keystrokes(keys) => keys.first().map(|key| key.normalized()), + Trigger::Empty | Trigger::Standard(_) | Trigger::Custom(_) => None, + }) + .collect::>() + }) + }; + let expected = HashSet::from(["home".to_string(), "ctrl-a".to_string()]); + assert_eq!(triggers_for("tui:input:move_to_line_start"), expected); + assert_eq!(triggers_for("tui:editor:move_to_line_start"), expected); + assert_eq!( + triggers_for("tui:editor:kill_to_line_end"), + HashSet::from(["ctrl-k".to_string()]) + ); + assert_eq!( + triggers_for("tui:input:insert_newline"), + HashSet::from([ + "shift-enter".to_string(), + "ctrl-j".to_string(), + "alt-enter".to_string(), + ]) + ); + assert!(triggers_for("tui:editor:insert_newline").is_empty()); + assert!(app.read(|ctx| ctx.get_binding_by_name("tui:editor:move_up").is_none())); + }); +} + +#[test] +fn mouse_selection_action_focuses_the_editor() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + let other = app.update(|ctx| ctx.add_tui_view(window_id, |_| TestHostView)); + other.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(!editor.read(&app, |editor, _| editor.is_focused())); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::SelectionStartAt { + offset: CharOffset::from(1), + }), + ctx, + ); + }); + assert!(editor.read(&app, |editor, _| editor.is_focused())); + }); +} + +#[test] +fn actions_edit_the_single_line_buffer() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("gen".to_string())), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::Backspace), + ctx, + ); + }); + // Line navigation is visual-row-aware; layout establishes the real + // terminal width before the command runs. + render_lines(&app, &editor); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveToLineStart), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertChar('X')), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "Xge"); + }); +} diff --git a/crates/warp_tui/src/input/kill_buffer.rs b/crates/warp_tui/src/input/kill_buffer.rs deleted file mode 100644 index ad31e61c44c..00000000000 --- a/crates/warp_tui/src/input/kill_buffer.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// A single-entry kill buffer for the TUI input view. -/// -/// Stores the last text killed by `Ctrl+K`, `Ctrl+U`, `Ctrl+W`, or `Alt+D`. -/// `Ctrl+Y` yanks (pastes) the stored text back into the input. -/// -/// This is intentionally simple — a kill ring (multi-entry yank cycle) is -/// listed as a follow-up in `specs/tui-input-view/TECH.md`. -#[derive(Debug, Default)] -pub struct KillBuffer { - content: String, -} - -impl KillBuffer { - /// Store `text` as the killed content, replacing any previous entry. - pub fn kill(&mut self, text: impl Into) { - self.content = text.into(); - } - - /// Append `text` to the current kill buffer content. - /// Used when multiple consecutive kills are combined (e.g. `Ctrl+K` at - /// the end of one line followed immediately by another `Ctrl+K`). - pub fn kill_append(&mut self, text: impl Into) { - self.content.push_str(&text.into()); - } - - /// Return the killed text for yanking, if any. - pub fn yank(&self) -> Option<&str> { - if self.content.is_empty() { - None - } else { - Some(&self.content) - } - } - - /// Return whether the kill buffer is empty. - pub fn is_empty(&self) -> bool { - self.content.is_empty() - } - - /// Clear the kill buffer. - pub fn clear(&mut self) { - self.content.clear(); - } -} diff --git a/crates/warp_tui/src/input/mod.rs b/crates/warp_tui/src/input/mod.rs index 26302a549c7..8b06687d808 100644 --- a/crates/warp_tui/src/input/mod.rs +++ b/crates/warp_tui/src/input/mod.rs @@ -5,10 +5,9 @@ //! by a [`warp::editor::CodeEditorModel`] in char-cell mode //! - [`view::TuiInputViewEvent`] — events emitted by the view (e.g. `Submitted`) //! -//! TUI-specific session state (kill buffer, scroll offset, terminal width) lives on -//! the view, not on a separate model. See `specs/tui-input-view/TECH.md` for details. +//! TUI-specific prompt policy lives on the view. See +//! `specs/tui-input-view/TECH.md` for details. -pub mod kill_buffer; pub mod view; pub use view::{init, TuiInputView, TuiInputViewEvent}; diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index 08b004b683f..d6e562e2c45 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -4,7 +4,7 @@ //! //! - Holds a [`ModelHandle`] constructed in `LayoutMode::CharCell`. //! - Renders the core [`TuiEditorElement`] verbatim (editable, scroll-windowed). -//! - Owns the kill buffer and the `!` shell-mode composition. +//! - Owns prompt submission and the `!` shell-mode composition. //! - Dispatches keystrokes as [`TuiInputAction`] typed actions. //! - Emits [`TuiInputViewEvent::Submitted`] when the user presses Enter. //! @@ -17,7 +17,7 @@ //! model-side, mirroring the GUI split: viewport scroll state on the char-cell //! render state (`CharCellState`), drag-selection state on the selection model, //! visual-row kill edits on `CodeEditorModel`. What stays here is input policy: -//! the readline keybinding table, the kill buffer, submit, and shell mode. +//! prompt-only keybindings, submit, inline menus, and shell mode. //! //! See `specs/tui-input-view/TECH.md` for the full keybinding table. @@ -29,20 +29,21 @@ use warp::tui_export::{ AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, InputType, InputTypeAutoDetectionSource, LLMId, TuiMcpAction, }; -use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; -use warp_editor::selection::TextUnit; +use warp_editor::model::CoreEditorModel; use warpui_core::elements::tui::{TuiContainer, TuiElement, TuiFlex, TuiHoverable, TuiText}; use warpui_core::elements::MouseStateHandle; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, EditableBinding}; -use warpui_core::text::word_boundaries::WordBoundariesPolicy; use warpui_core::{ AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, ViewContext, }; -use super::kill_buffer::KillBuffer; use crate::editor_element::{TuiEditorAction, TuiEditorElement, TuiEditorStyles}; +use crate::editor_interaction::{ + apply_editor_action, follow_editor_cursor, TuiEditorBehavior, TuiEditorCommand, + TuiEditorInteractionOutcome, TuiEditorState, +}; use crate::inline_menu::{active_inline_menu, TuiInlineMenu, TuiInlineMenuAccepted}; use crate::input_mode_policy::{self, AI_LOCKED_CONFIG, SHELL_LOCKED_CONFIG}; use crate::input_suggestions_mode::{TuiInputSuggestionsMode, TuiInputSuggestionsModeModel}; @@ -74,7 +75,7 @@ const INPUT_HANDLES_ESCAPE_FLAG: &str = "TuiInputHandlesEscape"; /// [`TuiEditorElement`]'s event dispatch, matching the GUI. pub fn init(app: &mut AppContext) { app.register_editable_bindings([ - // ── Submit / newline ───────────────────────────────────────── + // Submit and contextual Escape are prompt policy, not editor policy. EditableBinding::new( "tui:input:submit", "Submit the input", @@ -83,30 +84,6 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("TuiInputView")) .with_group(TUI_BINDING_GROUP) .with_key_binding("enter"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-enter"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-j"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-enter"), EditableBinding::new( "tui:input:handle_escape", "Handle contextual input escape", @@ -115,347 +92,6 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("TuiInputView") & id!(INPUT_HANDLES_ESCAPE_FLAG)) .with_group(TUI_BINDING_GROUP) .with_key_binding("escape"), - // ── Deletion ─────────────────────────────────────────────────── - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("backspace"), - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-backspace"), - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-h"), - EditableBinding::new( - "tui:input:delete_forward", - "Delete the next character", - TuiInputAction::DeleteForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("delete"), - EditableBinding::new( - "tui:input:delete_forward", - "Delete the next character", - TuiInputAction::DeleteForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-d"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-w"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-backspace"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-backspace"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-d"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-delete"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-delete"), - // ── Cursor movement ───────────────────────────────────────────── - EditableBinding::new( - "tui:input:move_left", - "Move cursor left", - TuiInputAction::MoveLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("left"), - EditableBinding::new( - "tui:input:move_left", - "Move cursor left", - TuiInputAction::MoveLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-b"), - EditableBinding::new( - "tui:input:move_right", - "Move cursor right", - TuiInputAction::MoveRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("right"), - EditableBinding::new( - "tui:input:move_right", - "Move cursor right", - TuiInputAction::MoveRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-f"), - EditableBinding::new( - "tui:input:move_up", - "Move cursor up", - TuiInputAction::MoveUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("up"), - EditableBinding::new( - "tui:input:move_up", - "Move cursor up", - TuiInputAction::MoveUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-p"), - EditableBinding::new( - "tui:input:move_down", - "Move cursor down", - TuiInputAction::MoveDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("down"), - EditableBinding::new( - "tui:input:move_down", - "Move cursor down", - TuiInputAction::MoveDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-n"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-left"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-b"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-left"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-right"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-f"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-right"), - EditableBinding::new( - "tui:input:move_to_line_start", - "Move cursor to start of line", - TuiInputAction::MoveToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("home"), - EditableBinding::new( - "tui:input:move_to_line_start", - "Move cursor to start of line", - TuiInputAction::MoveToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-a"), - EditableBinding::new( - "tui:input:move_to_line_end", - "Move cursor to end of line", - TuiInputAction::MoveToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("end"), - EditableBinding::new( - "tui:input:move_to_line_end", - "Move cursor to end of line", - TuiInputAction::MoveToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-e"), - // ── Selection ──────────────────────────────────────────────────────────────── - EditableBinding::new( - "tui:input:select_left", - "Extend selection left", - TuiInputAction::SelectLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-left"), - EditableBinding::new( - "tui:input:select_right", - "Extend selection right", - TuiInputAction::SelectRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-right"), - EditableBinding::new( - "tui:input:select_up", - "Extend selection up", - TuiInputAction::SelectUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-up"), - EditableBinding::new( - "tui:input:select_down", - "Extend selection down", - TuiInputAction::SelectDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-down"), - EditableBinding::new( - "tui:input:select_word_left", - "Extend selection one word left", - TuiInputAction::SelectWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-left"), - EditableBinding::new( - "tui:input:select_word_left", - "Extend selection one word left", - TuiInputAction::SelectWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-shift-left"), - EditableBinding::new( - "tui:input:select_word_right", - "Extend selection one word right", - TuiInputAction::SelectWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-right"), - EditableBinding::new( - "tui:input:select_word_right", - "Extend selection one word right", - TuiInputAction::SelectWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-shift-right"), - EditableBinding::new( - "tui:input:select_all", - "Select all text", - TuiInputAction::SelectAll, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-A"), - // ── Kill / yank ───────────────────────────────────────────────── - EditableBinding::new( - "tui:input:kill_to_line_end", - "Delete to end of line", - TuiInputAction::KillToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-k"), - EditableBinding::new( - "tui:input:kill_to_line_start", - "Delete to start of line", - TuiInputAction::KillToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-u"), - EditableBinding::new( - "tui:input:yank", - "Paste the last deleted text", - TuiInputAction::Yank, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-y"), - // ── Undo / redo ───────────────────────────────────────────────── - EditableBinding::new("tui:input:undo", "Undo", TuiInputAction::Undo) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-z"), - EditableBinding::new("tui:input:redo", "Redo", TuiInputAction::Redo) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-Z"), ]); } @@ -482,87 +118,22 @@ pub enum TuiInputViewEvent { // Typed action enum // ───────────────────────────────────────────────────────────────────────────── -/// All editing operations dispatched from [`TuiEditorElement`]. +/// Prompt policy plus shared editor actions dispatched to [`TuiInputView`]. /// /// Each variant corresponds to one or more keybindings from the spec keybinding table. #[derive(Debug, Clone)] pub enum TuiInputAction { - /// Insert a character (`Char(c)` key events). - InsertChar(char), - /// Insert one complete bracketed-paste payload without submitting it. - InsertText(String), - /// Insert a hard newline (`Shift+Enter`, `Ctrl+J`, `Alt+Enter`). - InsertNewline, + /// Apply input emitted by the shared editor element. + Editor(TuiEditorAction), /// Submit the current input (`Enter`). Submit, /// Handle contextual input Escape behavior, prioritizing an open inline menu. HandleEscape, - /// Delete the character before the cursor (`Backspace`, `Ctrl+H`). - Backspace, - /// Delete the character after the cursor (`Delete`, `Ctrl+D`). - DeleteForward, - /// Move cursor left one char (`←`, `Ctrl+B`). - MoveLeft, - /// Move cursor right one char (`→`, `Ctrl+F`). - MoveRight, - /// Move cursor up one visual row (`↑`, `Ctrl+P`). - MoveUp, - /// Move cursor down one visual row (`↓`, `Ctrl+N`). - MoveDown, - /// Move cursor one word backward (`Alt+←`, `Alt+B`, `Ctrl+←`). - MoveWordLeft, - /// Move cursor one word forward (`Alt+→`, `Alt+F`, `Ctrl+→`). - MoveWordRight, - /// Move cursor to start of visual line (`Home`, `Ctrl+A`). - MoveToLineStart, - /// Move cursor to end of visual line (`End`, `Ctrl+E`). - MoveToLineEnd, - /// Extend selection left (`Shift+←`). - SelectLeft, - /// Extend selection right (`Shift+→`). - SelectRight, - /// Extend selection up (`Shift+↑`). - SelectUp, - /// Extend selection down (`Shift+↓`). - SelectDown, - /// Extend selection one word left (`Ctrl+Shift+←`, `Alt+Shift+←`). - SelectWordLeft, - /// Extend selection one word right (`Ctrl+Shift+→`, `Alt+Shift+→`). - SelectWordRight, - /// Select all text (`Ctrl+Shift+A` / `Meta+A`). - SelectAll, - /// Delete word backward (`Ctrl+W`, `Alt+Backspace`, `Ctrl+Backspace`). - DeleteWordBackward, - /// Delete word forward (`Alt+D`, `Alt+Delete`, `Ctrl+Delete`). - DeleteWordForward, - /// Kill from cursor to end of visual line (`Ctrl+K`). - KillToLineEnd, - /// Kill from cursor to start of visual line (`Ctrl+U`). - KillToLineStart, - /// Yank last killed text (`Ctrl+Y`). - Yank, - /// Undo (`Ctrl+Z`). - Undo, - /// Redo (`Ctrl+Shift+Z`). - Redo, - /// Place the cursor / begin a character selection at `offset` (single click). - SelectionStartAt { offset: CharOffset }, - /// Extend the active selection's head to `offset` (shift-click). - SelectionExtendTo { offset: CharOffset }, - /// Select the word at `offset` (double click). - SelectWordAt { offset: CharOffset }, - /// Select the line at `offset` (triple click). - SelectLineAt { offset: CharOffset }, - /// Update the in-progress drag selection to `offset` (mouse drag). - SelectionUpdateTo { offset: CharOffset }, - /// Finish the in-progress drag selection (mouse up). - SelectionEnd, + /// Apply an editing command shared with generic TUI editors. + EditorCommand(TuiEditorCommand), /// Place the cursor at `offset` without starting a drag selection /// (the `!` gutter click). SetCursor { offset: CharOffset }, - /// Scroll the viewport by `rows` visual rows without moving the cursor - /// (negative scrolls toward the top). Driven by the mouse wheel. - Scroll { rows: isize }, } // ───────────────────────────────────────────────────────────────────────────── @@ -581,10 +152,10 @@ pub struct TuiInputView { suggestions_mode: ModelHandle, /// Generalized inline menus used to route prioritized menu actions. inline_menus: Vec, - /// Single-entry kill buffer for `Ctrl+K` / `Ctrl+U` / `Ctrl+Y`. - kill_buffer: KillBuffer, - /// Maximum number of visible rows before the input scrolls. - max_visible_rows: u32, + /// Shared editor session state, including the single-entry kill buffer. + editor_state: TuiEditorState, + /// Multiline insertion and six-row viewport policy. + editor_behavior: TuiEditorBehavior, /// Mouse state for the shell-mode `!` gutter; created once here (not inline /// during render) so mouse tracking survives per-frame element rebuilds. prefix_mouse_state: MouseStateHandle, @@ -633,8 +204,8 @@ impl TuiInputView { input_mode, suggestions_mode, inline_menus, - kill_buffer: KillBuffer::default(), - max_visible_rows: 6, + editor_state: TuiEditorState::default(), + editor_behavior: TuiEditorBehavior::multiline(6), prefix_mouse_state: MouseStateHandle::default(), focused: false, } @@ -685,10 +256,10 @@ impl TuiInputView { let mut element = TuiEditorElement::new(&self.model, ctx) .editable() .with_view_focused(self.focused) - .with_viewport_rows(self.max_visible_rows) + .with_viewport_rows(self.editor_behavior.viewport_rows()) .with_styles(styles) .on_action(|action, event_ctx| { - event_ctx.dispatch_typed_action(TuiInputAction::from(action)) + event_ctx.dispatch_typed_action(TuiInputAction::Editor(action)) }); if let Some(hint_text) = self .inline_menus @@ -719,6 +290,7 @@ impl TuiInputView { self.render_element(ctx).finish() } pub(crate) fn set_text(&mut self, text: &str, ctx: &mut ViewContext) { + let text = self.editor_behavior.normalize_text(text); self.model.update(ctx, |m, ctx| { m.clear_buffer(ctx); m.user_insert(text, ctx); @@ -783,22 +355,6 @@ impl TuiView for TuiInputView { } } -impl From for TuiInputAction { - fn from(action: TuiEditorAction) -> Self { - match action { - TuiEditorAction::InsertChar(c) => Self::InsertChar(c), - TuiEditorAction::InsertText(text) => Self::InsertText(text), - TuiEditorAction::SelectionStartAt { offset } => Self::SelectionStartAt { offset }, - TuiEditorAction::SelectionExtendTo { offset } => Self::SelectionExtendTo { offset }, - TuiEditorAction::SelectWordAt { offset } => Self::SelectWordAt { offset }, - TuiEditorAction::SelectLineAt { offset } => Self::SelectLineAt { offset }, - TuiEditorAction::SelectionUpdateTo { offset } => Self::SelectionUpdateTo { offset }, - TuiEditorAction::SelectionEnd => Self::SelectionEnd, - TuiEditorAction::Scroll { rows } => Self::Scroll { rows }, - } - } -} - fn input_keymap_context(input_handles_escape: bool) -> keymap::Context { let mut context = keymap::Context::default(); context.set.insert(TuiInputView::ui_name()); @@ -814,11 +370,11 @@ impl TypedActionView for TuiInputView { if self.handle_inline_menu_action(action, ctx) { return; } - match action { - TuiInputAction::InsertChar(c) => { + let outcome = match action { + TuiInputAction::Editor(editor_action) => { // A `!` typed at the very start of the input enters shell mode // instead of inserting (matching the GUI's typed-only trigger). - if *c == '!' + if matches!(editor_action, TuiEditorAction::InsertChar('!')) && !self.is_shell_mode(ctx) && self.is_cursor_at_start(ctx) && !self @@ -827,46 +383,26 @@ impl TypedActionView for TuiInputView { .is_terminal_use_active_or_pending() { self.enter_shell_mode(ctx); + TuiEditorInteractionOutcome::FollowCursor } else { - let s = c.to_string(); - self.model.update(ctx, |m, ctx| m.user_insert(&s, ctx)); + apply_editor_action(&self.model, editor_action, self.editor_behavior, ctx) } } - TuiInputAction::InsertText(text) => { - self.model.update(ctx, |m, ctx| m.user_insert(text, ctx)); - } - TuiInputAction::InsertNewline => { - self.model.update(ctx, |m, ctx| m.user_insert("\n", ctx)); + TuiInputAction::Submit => { + self.submit(ctx); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Submit => self.submit(ctx), TuiInputAction::HandleEscape => { self.handle_escape(ctx); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Backspace => { - // With nothing left to delete, backspace removes the `!` - // affordance instead; typed text is preserved. - if self.is_shell_mode(ctx) && self.is_cursor_at_start(ctx) { - self.exit_shell_mode(ctx); - } else { - self.model.update(ctx, |m, ctx| m.backspace(ctx)); - } - } - TuiInputAction::DeleteForward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Forwards, - TextUnit::Character, - false, - ctx, - ) - }); - } - TuiInputAction::MoveLeft => { + TuiInputAction::EditorCommand(command) => { // Only open the conversation list from normal agent input; in // `!` shell mode the `!` prefix is not part of `plain_text`, so // an empty shell command would otherwise trip this branch and // open the picker while the input stayed shell-mode. - if !self.is_shell_mode(ctx) + if matches!(*command, TuiEditorCommand::MoveLeft) + && !self.is_shell_mode(ctx) && self.plain_text(ctx).is_empty() && self.is_cursor_at_start(ctx) { @@ -877,161 +413,35 @@ impl TypedActionView for TuiInputView { { menu.open(ctx); } + TuiEditorInteractionOutcome::FollowCursor + // With nothing left to delete, backspace removes the `!` + // affordance instead; typed text is preserved. + } else if matches!(*command, TuiEditorCommand::Backspace) + && self.is_shell_mode(ctx) + && self.is_cursor_at_start(ctx) + { + self.exit_shell_mode(ctx); + TuiEditorInteractionOutcome::FollowCursor } else { - self.model.update(ctx, |m, ctx| m.move_left(ctx)); - } - } - TuiInputAction::MoveRight => { - self.model.update(ctx, |m, ctx| m.move_right(ctx)); - } - TuiInputAction::MoveUp => { - self.model.update(ctx, |m, ctx| m.move_up(ctx)); - } - TuiInputAction::MoveDown => { - self.model.update(ctx, |m, ctx| m.move_down(ctx)); - } - TuiInputAction::MoveWordLeft => { - self.model.update(ctx, |m, ctx| { - m.backward_word_with_unit( - false, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::MoveWordRight => { - self.model.update(ctx, |m, ctx| { - m.forward_word_with_unit( - false, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::MoveToLineStart => { - self.model.update(ctx, |m, ctx| m.move_to_line_start(ctx)); - } - TuiInputAction::MoveToLineEnd => { - self.model.update(ctx, |m, ctx| m.move_to_line_end(ctx)); - } - TuiInputAction::SelectLeft => { - self.model.update(ctx, |m, ctx| m.select_left(ctx)); - } - TuiInputAction::SelectRight => { - self.model.update(ctx, |m, ctx| m.select_right(ctx)); - } - TuiInputAction::SelectUp => { - self.model.update(ctx, |m, ctx| m.select_up(ctx)); - } - TuiInputAction::SelectDown => { - self.model.update(ctx, |m, ctx| m.select_down(ctx)); - } - TuiInputAction::SelectWordLeft => { - self.model.update(ctx, |m, ctx| { - m.backward_word_with_unit( - true, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::SelectWordRight => { - self.model.update(ctx, |m, ctx| { - m.forward_word_with_unit( - true, - TextUnit::Word(WordBoundariesPolicy::Default), + self.editor_state.apply_command( + &self.model, + *command, + self.editor_behavior, ctx, ) - }); - } - TuiInputAction::SelectAll => { - self.model.update(ctx, |m, ctx| m.select_all(ctx)); - } - TuiInputAction::DeleteWordBackward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Backwards, - TextUnit::Word(WordBoundariesPolicy::Default), - false, - ctx, - ) - }); - } - TuiInputAction::DeleteWordForward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Forwards, - TextUnit::Word(WordBoundariesPolicy::Default), - false, - ctx, - ) - }); - } - TuiInputAction::KillToLineEnd => { - if let Some(killed) = self - .model - .update(ctx, |m, ctx| m.kill_to_char_cell_visual_row_end(ctx)) - { - self.kill_buffer.kill(killed); - } - } - TuiInputAction::KillToLineStart => { - if let Some(killed) = self - .model - .update(ctx, |m, ctx| m.kill_to_char_cell_visual_row_start(ctx)) - { - self.kill_buffer.kill(killed); } } - TuiInputAction::Yank => self.yank(ctx), - TuiInputAction::Undo => { - self.model.update(ctx, |m, ctx| m.undo(ctx)); - } - TuiInputAction::Redo => { - self.model.update(ctx, |m, ctx| m.redo(ctx)); - } - TuiInputAction::SelectionStartAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_at(*offset, false, ctx)); - } - TuiInputAction::SelectionExtendTo { offset } => { - self.model - .update(ctx, |m, ctx| m.set_last_selection_head(*offset, ctx)); - } - TuiInputAction::SelectWordAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_word_at(*offset, false, ctx)); - } - TuiInputAction::SelectLineAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_line_at(*offset, false, ctx)); - } - // Both are model-side no-ops unless a drag selection is pending - // (begun by a mouse-down on the element), so no gating is needed. - TuiInputAction::SelectionUpdateTo { offset } => { - self.model - .update(ctx, |m, ctx| m.update_pending_selection(*offset, ctx)); - } - TuiInputAction::SelectionEnd => { - self.model.update(ctx, |m, ctx| m.end_selection(ctx)); - } TuiInputAction::SetCursor { offset } => { self.model.update(ctx, |m, ctx| { m.select_at(*offset, false, ctx); m.end_selection(ctx); }); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Scroll { rows } => { - // Wheel scrolling moves the viewport only; it must NOT snap back - // to the cursor, so it returns early (skipping the follow-cursor - // tail below). - self.scroll_viewport_by(*rows, ctx); - ctx.notify(); - return; - } + }; + if outcome == TuiEditorInteractionOutcome::FollowCursor { + self.follow_cursor(ctx); } - - self.follow_cursor(ctx); ctx.notify(); } } @@ -1091,32 +501,12 @@ impl TuiInputView { // The scroll offset and its clamping/follow policy live on the char-cell // render state (`CharCellState`); these helpers gather the inputs the // mechanism needs — the primary cursor and the model-derived hidden line - // ranges — and apply the input's viewport policy (`max_visible_rows`). + // ranges — and apply the input's viewport policy. /// Scrolls the viewport the minimal amount needed to keep the cursor /// visible. fn follow_cursor(&self, ctx: &AppContext) { - let model = self.model.as_ref(ctx); - let render = model.render_state().as_ref(ctx); - let Some(char_cell) = render.char_cell() else { - return; - }; - let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); - let hidden = char_cell.hidden_line_ranges(ctx); - char_cell.follow_cursor(cursor_offset, self.max_visible_rows, &hidden); - } - - /// Scrolls the viewport by `rows` display rows (negative scrolls toward - /// the top) without moving the cursor. - fn scroll_viewport_by(&self, rows: isize, ctx: &AppContext) { - let model = self.model.as_ref(ctx); - let render = model.render_state().as_ref(ctx); - let Some(char_cell) = render.char_cell() else { - return; - }; - let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); - let hidden = char_cell.hidden_line_ranges(ctx); - char_cell.scroll_by(rows, self.max_visible_rows, cursor_offset, &hidden); + follow_editor_cursor(&self.model, self.editor_behavior, ctx); } // ── Shell mode ──────────────────────────────────────────────────────────── @@ -1174,8 +564,7 @@ impl TuiInputView { ) -> bool { if !matches!( action, - TuiInputAction::MoveUp - | TuiInputAction::MoveDown + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp | TuiEditorCommand::MoveDown) | TuiInputAction::Submit | TuiInputAction::HandleEscape ) { @@ -1186,10 +575,10 @@ impl TuiInputView { }; match action { - TuiInputAction::MoveUp => { + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp) => { inline_menu.select_previous(ctx); } - TuiInputAction::MoveDown => { + TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown) => { inline_menu.select_next(ctx); } TuiInputAction::Submit => { @@ -1241,18 +630,6 @@ impl TuiInputView { ctx, ) } - - // ── Kill / yank ─────────────────────────────────────────────────────────── - // - // The kill *edits* (visual-row range computation and deletion) live on - // `CodeEditorModel::kill_to_char_cell_visual_row_end` / `_start`; the view - // owns only the kill buffer the deleted text lands in. - - fn yank(&mut self, ctx: &mut ViewContext) { - if let Some(text) = self.kill_buffer.yank().map(str::to_owned) { - self.model.update(ctx, |m, ctx| m.user_insert(&text, ctx)); - } - } } #[cfg(test)] diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index 2c73b6d6597..75031520e7c 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -34,7 +34,8 @@ use super::{ input_keymap_context, TuiInputAction, TuiInputView, TuiInputViewEvent, INPUT_HANDLES_ESCAPE_FLAG, }; -use crate::editor_element::TuiEditorElement; +use crate::editor_element::{TuiEditorAction, TuiEditorElement}; +use crate::editor_interaction::TuiEditorCommand; use crate::inline_menu::{ TuiInlineMenu, TuiInlineMenuAccepted, TuiInlineMenuHandle, TuiInlineMenuHeader, TuiInlineMenuSnapshot, TuiInlineMenuStatus, @@ -453,10 +454,18 @@ fn inline_menu_navigation_routes_before_editor_navigation() { let (view, menu_model, ids) = build_view_with_inline_menu(ctx); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[0])); - dispatch(&view, ctx, &[TuiInputAction::MoveDown]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown)], + ); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[1])); - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[0])); }); }); @@ -575,7 +584,9 @@ fn multiline_paste_inserts_without_submitting_until_enter() { dispatch( &view, ctx, - &[TuiInputAction::InsertText(payload.to_owned())], + &[TuiInputAction::Editor(TuiEditorAction::InsertText( + payload.to_owned(), + ))], ); }); app.read(|ctx| { @@ -621,7 +632,10 @@ fn clear_selection_collapses_to_head_without_changing_text() { } fn type_str(view: &ViewHandle, ctx: &mut AppContext, s: &str) { - let actions: Vec = s.chars().map(TuiInputAction::InsertChar).collect(); + let actions: Vec = s + .chars() + .map(|c| TuiInputAction::Editor(TuiEditorAction::InsertChar(c))) + .collect(); dispatch(view, ctx, &actions); } @@ -699,7 +713,11 @@ fn move_left_on_empty_buffer_opens_conversation_menu() { let (view, menu_model, inline_menu) = build_view_with_conversation_menu(ctx); assert!(!menu_model.as_ref(ctx).is_open); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!(menu_model.as_ref(ctx).is_open); let lines = render_element_lines( @@ -727,7 +745,11 @@ fn move_left_on_non_empty_buffer_only_moves_cursor() { type_str(&view, ctx, "ab"); assert_eq!(cursor_and_height(&view, ctx).0, Some((2, 0))); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!(!menu_model.as_ref(ctx).is_open); assert_eq!(cursor_and_height(&view, ctx).0, Some((1, 0))); @@ -790,12 +812,12 @@ fn navigation_on_empty_buffer_does_not_panic() { &view, ctx, &[ - TuiInputAction::MoveToLineStart, - TuiInputAction::MoveToLineEnd, - TuiInputAction::MoveLeft, - TuiInputAction::MoveRight, - TuiInputAction::MoveUp, - TuiInputAction::MoveDown, + TuiInputAction::EditorCommand(TuiEditorCommand::MoveToLineStart), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveToLineEnd), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveRight), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown), ], ); let (cursor, height) = cursor_and_height(&view, ctx); @@ -827,7 +849,13 @@ fn cursor_renders_at_start_of_new_line() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "ab"); - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); let (cursor, height) = cursor_and_height(&view, ctx); assert_eq!(cursor, Some((0, 1)), "cursor should be at row 1, col 0"); assert!(height >= 2, "two visual rows expected, got height {height}"); @@ -847,7 +875,10 @@ fn interior_empty_line_does_not_collapse() { dispatch( &view, ctx, - &[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + ], ); type_str(&view, ctx, "b"); let (cursor, height) = cursor_and_height(&view, ctx); @@ -868,11 +899,18 @@ fn move_up_through_empty_line_positions_cursor() { dispatch( &view, ctx, - &[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + ], ); type_str(&view, ctx, "b"); // Cursor on row 2 ("b"); move up to the empty row 1. - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); let (cursor, height) = cursor_and_height(&view, ctx); assert_eq!(height, 3); assert_eq!( @@ -896,9 +934,18 @@ fn kill_to_line_end_from_midline() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); assert_eq!(text(&view, ctx), "ab"); }); }); @@ -911,7 +958,13 @@ fn kill_to_line_end_at_eol_is_noop() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "abcd"); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], + ); assert_eq!(text(&view, ctx), "abcd"); }); }); @@ -928,9 +981,18 @@ fn kill_to_line_start_from_midline() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineStart, + )], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineStart]); assert_eq!(text(&view, ctx), "cd"); }); }); @@ -946,10 +1008,23 @@ fn kill_then_yank_round_trips() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); // kills "cd" -> "ab" - dispatch(&view, ctx, &[TuiInputAction::Yank]); // yanks "cd" -> "abcd" + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], + ); // kills "cd" -> "ab" + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Yank)], + ); // yanks "cd" -> "abcd" assert_eq!(text(&view, ctx), "abcd"); }); }); @@ -982,7 +1057,13 @@ fn select_word_left_selects_previous_word() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "hello world"); - dispatch(&view, ctx, &[TuiInputAction::SelectWordLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::SelectWordLeft, + )], + ); assert_eq!(selected_text(&view, ctx).as_deref(), Some("world")); }); }); @@ -995,8 +1076,20 @@ fn select_word_right_selects_next_word() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "hello world"); - dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]); - dispatch(&view, ctx, &[TuiInputAction::SelectWordRight]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineStart, + )], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::SelectWordRight, + )], + ); assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello")); }); }); @@ -1009,12 +1102,30 @@ fn move_to_line_start_and_end_multiline() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "abc"); - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); type_str(&view, ctx, "def"); // Cursor is at end of "def" (row 1, col 3). - dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineStart, + )], + ); assert_eq!(cursor_and_height(&view, ctx).0, Some((0, 1))); - dispatch(&view, ctx, &[TuiInputAction::MoveToLineEnd]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineEnd, + )], + ); assert_eq!(cursor_and_height(&view, ctx).0, Some((3, 1))); }); }); @@ -1178,7 +1289,13 @@ fn scroll_wheel(x: u16, y: u16, delta_rows: isize) -> TuiEvent { fn type_lines(view: &ViewHandle, ctx: &mut AppContext, n: usize) { for i in 0..n { if i > 0 { - dispatch(view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); } type_str(view, ctx, &i.to_string()); } @@ -1230,7 +1347,7 @@ fn mouse(view: &ViewHandle, ctx: &mut AppContext, event: &TuiEvent }; match action { Some(action) => { - dispatch(view, ctx, &[TuiInputAction::from(action)]); + dispatch(view, ctx, &[TuiInputAction::Editor(action)]); true } None => false, @@ -1376,13 +1493,23 @@ fn drag_past_last_visible_row_autoscrolls() { // 10 logical lines, exceeding the 6-row viewport. for i in 0..10 { if i > 0 { - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); } type_str(&view, ctx, &i.to_string()); } // Scroll back to the top. for _ in 0..9 { - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); } assert_eq!(scroll_offset(&view, ctx), 0); @@ -1708,9 +1835,10 @@ fn shell_mode_offsets_mouse_mapping_by_gutter() { let event_ctx = TuiEventContext::new(scene, &mut rendered_views); element .mouse_action(&left_down(2 + 3, 0, 1, false), &event_ctx, ctx) - .map(TuiInputAction::from) + .map(TuiInputAction::Editor) }; - let Some(TuiInputAction::SelectionStartAt { offset }) = action else { + let Some(TuiInputAction::Editor(TuiEditorAction::SelectionStartAt { offset })) = action + else { panic!("expected SelectionStartAt, got {action:?}"); }; // Screen column 5 = content column 3 = gap offset 4 (1-based). diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 82598db19d4..27d1cc76986 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -19,9 +19,15 @@ //! would otherwise match everywhere and, for multi-keystroke chords, swallow //! prefix keys via a pending match. -use warpui_core::keymap::{BindingLens, IsBindingValid, Trigger}; -use warpui_core::AppContext; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{ + BindingLens, ContextPredicate, EditableBinding, IsBindingValid, Trigger, +}; +use warpui_core::{Action, AppContext}; +use crate::editor_interaction::{editor_binding_specs, TuiEditorBindingTarget, TuiEditorCommand}; +use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; +use crate::input::view::TuiInputAction; use crate::input::TuiInputView; use crate::root_view::RootTuiView; use crate::terminal_session_view::TuiTerminalSessionView; @@ -37,16 +43,51 @@ pub(crate) fn init(app: &mut AppContext) { crate::root_view::init(app); crate::terminal_session_view::init(app); crate::input::init(app); + register_editor_bindings( + app, + TuiEditorBindingTarget::Input, + id!("TuiInputView"), + TuiInputAction::EditorCommand, + ); + register_editor_bindings( + app, + TuiEditorBindingTarget::Editor, + id!("TuiEditorView"), + TuiEditorViewAction::Command, + ); register_binding_validators(app); } +/// Registers one editor binding target from interaction-owned metadata. +fn register_editor_bindings( + app: &mut AppContext, + target: TuiEditorBindingTarget, + context: ContextPredicate, + action_for: impl Fn(TuiEditorCommand) -> A, +) where + A: Action, +{ + let action_for = &action_for; + let bindings = editor_binding_specs(target).flat_map(|spec| { + let context = context.clone(); + spec.keys.iter().map(move |key| { + EditableBinding::new(spec.name, spec.description, action_for(spec.command)) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding(key) + }) + }); + app.register_editable_bindings(bindings); +} + /// Debug-time guard (no-op in release): every keystroke binding that matches a /// TUI view's default keymap context must be TUI-owned. fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 633d7e8cdcb..59cabd5fc5a 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -22,6 +22,10 @@ mod ui; mod conversation_menu; mod conversation_selection; mod editor_element; +mod editor_interaction; +// The option-selector slice consumes this reusable editor and removes the allow. +#[allow(dead_code)] +mod editor_view; mod exit_confirmation; mod inline_menu; mod input_mode_policy; diff --git a/specs/code-1822-tui-generic-editor-view/TECH.md b/specs/code-1822-tui-generic-editor-view/TECH.md new file mode 100644 index 00000000000..6498d1dc6c7 --- /dev/null +++ b/specs/code-1822-tui-generic-editor-view/TECH.md @@ -0,0 +1,177 @@ +# TECH: Generic TUI editor view and shared editing behavior + +## Context + +The TUI already renders and hit-tests editor content through `TuiEditorElement`, +backed by `CodeEditorModel` in char-cell mode +(`crates/warp_tui/src/editor_element.rs` and +`specs/tui-editor-element/TECH.md`). Before this slice, editable behavior lived in +`TuiInputView`, which also owns prompt submission, shell mode, inline menus, and +other application-input policy. Reusing that view for selector search or custom +text would couple generic fields to prompt behavior. + +This slice adds `TuiEditorView::single_line` and extracts editing behavior shared +with `TuiInputView` into `editor_interaction.rs`. The option-selector slice stacked +above uses the generic view for search and custom-text fields; see +`specs/code-1822-tui-option-selector/TECH.md`. + +The ownership boundary is: + +- `CodeEditorModel` and `CharCellState`: text, selection, undo/redo, wrap geometry, + hidden lines, and retained viewport offset. +- `TuiEditorElement`: painting, cursor/selection geometry, hit-testing, printable + input, paste, mouse selection, and wheel events + (`crates/warp_tui/src/editor_element.rs` (44-79)). +- `editor_interaction.rs`: shared command definitions and bindings, model mutations, + behavior configuration, kill/yank state, selection action handling, and viewport + follow/scroll helpers (`crates/warp_tui/src/editor_interaction.rs` (15-559)). +- `TuiEditorView`: generic field focus, one-row configuration, content events, + and programmatic text replacement + (`crates/warp_tui/src/editor_view.rs` (37-202)). +- `TuiInputView`: prompt submission, contextual Escape, + shell-mode interception, inline-menu routing, and prompt focus + (`crates/warp_tui/src/input/view.rs` (66-604)). + +## Proposed changes + +### Shared command and binding layer + +`crates/warp_tui/src/editor_interaction.rs` owns `TuiEditorCommand`, which represents +model-backed editing semantics: + +- character/word deletion; +- hard-newline insertion for multiline consumers; +- horizontal, vertical, word, and visual-line navigation; +- character, vertical, word, and whole-buffer selection; +- visual-row kill-to-start/end plus yank; +- undo and redo. + +The same module owns pure binding metadata for either +`TuiEditorBindingTarget::Input` or `Editor`. Each target keeps stable +user-configurable names (`tui:input:*` and `tui:editor:*`) while sharing default +keys and command semantics. `keybindings.rs` converts that metadata into +`EditableBinding`s, owns the TUI binding group, and registers both targets. + +Vertical movement and selection are commands shared by both consumers, but their +bindings remain input-only. A generic single-line field is embedded in a selector +whose host owns Up/Down navigation and focus handoff, so registering +`tui:editor:move_up`, `move_down`, `select_up`, or `select_down` would consume keys +that must propagate. The metadata marks those bindings input-only while both +consumers still use the shared command variants. + +`crates/warp_tui/src/keybindings.rs` remains the TUI-wide aggregation and +cross-surface validation layer. It registers both editor targets and binding +validators but does not define editor commands or key metadata +(`crates/warp_tui/src/keybindings.rs` (22-115)). + +### Shared editor state and action application + +Each editable view owns a `TuiEditorState`. Its single-entry kill buffer backs +Ctrl-K, Ctrl-U, and Ctrl-Y for both the prompt and generic fields. Kill range +calculation and deletion remain model semantics on `CodeEditorModel`; the shared +state only retains the deleted text for yank +(`crates/warp_tui/src/editor_interaction.rs` (322-468)). + +`TuiEditorBehavior` is the single consumer configuration for line policy and +viewport height. `single_line()` rejects hard newlines and uses one visible row; +`multiline(6)` accepts full text/newline insertion and uses the prompt's six-row +viewport. The same behavior value is used for rendering, action application, +commands, programmatic replacement, scrolling, and cursor following. + +`apply_editor_action` applies every `TuiEditorAction` emitted by +`TuiEditorElement`: + +- printable insertion; +- paste normalized through `TuiEditorBehavior`; +- click, shift-click, word/line selection, drag updates, and drag completion; +- wheel scrolling without moving the cursor. + +Single-line behavior inserts only the first pasted or programmatically supplied +line and ignores `InsertNewline`. Multiline behavior inserts the complete payload +and accepts `InsertNewline`. The mutation path is shared even though consumer +configuration differs. + +The action helper returns `TuiEditorInteractionOutcome::FollowCursor` for +insertions/selection changes and `PreserveViewport` for wheel scrolling, so callers +cannot confuse an opaque boolean result. Shared mutation helpers take +`&mut AppContext`; they do not depend on a concrete view type. + +### Generic editor view + +`TuiEditorView::single_line` creates a char-cell `CodeEditorModel`, owns a +`TuiEditorState` and persistent `MouseStateHandle`, and renders +`TuiEditorElement` with a one-row viewport. It tracks focus through +`TuiView::on_focus`/`on_blur`, and mouse-originated selection focuses the field +before applying the shared action. + +The view exposes: + +- `text`; +- `set_text`, which suppresses the resulting `Changed` event; +- `is_focused`; +- `TuiEditorViewEvent::Changed` for user edits. + +Dispatched editor actions and commands call `follow_editor_cursor` after mutation. +Programmatic `set_text` does not follow immediately: it can run before layout has +pushed the real terminal width into `CharCellState`. `TuiEditorElement::build` +clamps retained scroll state after applying the real width, so replacement or +resize cannot leave a windowed editor scrolled past its content. User actions and +commands then follow the cursor unless the interaction explicitly preserves the +viewport. + +### Prompt input integration + +`TuiInputView` embeds the same `TuiEditorElement`, `TuiEditorState`, command +executor, action executor, and viewport helpers. It adds only prompt policy: + +- `!` at the buffer start enters shell mode before generic character insertion; +- Backspace at the empty shell-mode boundary exits shell mode before delegation; +- Enter submits; the shared input-only newline command handles + Shift-Enter/Ctrl-J/Alt-Enter; +- Up/Down route to an open inline menu before editor navigation; +- contextual Escape dismisses the nearest prompt-owned mode; +- the prompt chooses multiline paste and a six-row viewport. + +## Data flow + +```mermaid +flowchart LR + Keys["TUI key events"] --> Bindings["keybindings registration
from editor_interaction metadata"] + Bindings --> Command["TuiEditorCommand"] + Element["TuiEditorElement"] --> ElementAction["TuiEditorAction
text + mouse + scroll"] + + Generic["TuiEditorView
SingleLine · 1 row · focus/events"] --> SharedAction["shared action executor"] + Prompt["TuiInputView
Multiline · 6 rows"] --> SharedAction + ElementAction --> Generic + ElementAction --> Prompt + + Command --> SharedState["TuiEditorState
model mutations + kill/yank"] + SharedState --> Model["CodeEditorModel"] + SharedAction --> Model + Model --> Viewport["CharCellState
follow cursor / scroll"] + + PromptPolicy["prompt-only policy
submit · menus · shell · focus"] -. intercepts .-> Prompt +``` + +## Testing and validation + +`crates/warp_tui/src/editor_view_tests.rs` covers: + +- single-line paste truncation; +- single-line programmatic replacement and newline rejection; +- shared Ctrl-K registration and kill/yank behavior; +- cursor following plus stale-offset clamping after resize/replacement; +- shared command editing; +- focus and mouse-selection behavior; +- programmatic text replacement. + +Existing `crates/warp_tui/src/input/view_tests.rs` continues to cover multiline +paste, command navigation/selection, kill/yank, shell-mode overrides, inline-menu +routing, mouse selection, and six-row viewport behavior through the shared layer. + +Validation commands: + +- `./script/format` +- `cargo nextest run --no-fail-fast -p warp_tui -E 'test(editor_view) or test(input::view::tests)'` +- `cargo nextest run --no-fail-fast -p warp_tui` +- `cargo clippy -p warp_tui --tests -- -D warnings` From 7385e20a6a1242b5b2789b1a2b754f6f9f8bdcf2 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 19:01:19 -0400 Subject: [PATCH 02/40] address comments --- crates/warp_tui/src/editor_element.rs | 7 ++++++- crates/warp_tui/src/editor_element_tests.rs | 23 +++++++++++++++++++++ crates/warp_tui/src/editor_view.rs | 18 +++------------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/crates/warp_tui/src/editor_element.rs b/crates/warp_tui/src/editor_element.rs index 17d30955c51..f4f8b170d77 100644 --- a/crates/warp_tui/src/editor_element.rs +++ b/crates/warp_tui/src/editor_element.rs @@ -312,12 +312,17 @@ impl TuiEditorElement { 0 }; let content_width = full_width.saturating_sub(self.gutter_cols); + let width_changed = char_cell.terminal_width() != content_width; char_cell.set_terminal_width(content_width); let chars: Vec = self.text.chars().collect(); let cursor_offset = CharOffset::from(self.cursor_offset.as_usize().saturating_sub(1)); if let Some(viewport_rows) = self.viewport_rows { - char_cell.clamp_scroll_offset(cursor_offset, viewport_rows, &hidden); + if width_changed { + char_cell.follow_cursor(cursor_offset, viewport_rows, &hidden); + } else { + char_cell.clamp_scroll_offset(cursor_offset, viewport_rows, &hidden); + } } // The first visible row is model-side scroll state; unwindowed // consumers always render from the top. diff --git a/crates/warp_tui/src/editor_element_tests.rs b/crates/warp_tui/src/editor_element_tests.rs index 935f4cfbfd5..84671cd8ee2 100644 --- a/crates/warp_tui/src/editor_element_tests.rs +++ b/crates/warp_tui/src/editor_element_tests.rs @@ -310,3 +310,26 @@ fn scroll_windows_the_visible_rows() { }); }); } + +#[test] +fn width_change_follows_cursor_after_reflow() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let model = model(ctx, "abcde"); + model.update(ctx, |model, ctx| { + model.select_at(CharOffset::from(6), false, ctx); + model.end_selection(ctx); + }); + + let wide = TuiEditorElement::new(&model, ctx).with_viewport_rows(1); + assert_eq!(render_lines(ctx, wide, 10, 10), vec!["abcde"]); + + let narrow = TuiEditorElement::new(&model, ctx).with_viewport_rows(1); + assert_eq!(render_lines(ctx, narrow, 3, 10), vec!["de"]); + let render = model.as_ref(ctx).render_state().as_ref(ctx); + let char_cell = render.char_cell().expect("char-cell model"); + assert_eq!(char_cell.scroll_offset(), 1); + }); + }); +} diff --git a/crates/warp_tui/src/editor_view.rs b/crates/warp_tui/src/editor_view.rs index 53a2cc2c65e..8a1ffad1662 100644 --- a/crates/warp_tui/src/editor_view.rs +++ b/crates/warp_tui/src/editor_view.rs @@ -5,6 +5,7 @@ //! and behavior while reusing model-backed editing and focus handling. use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; +use warp_editor::content::buffer::InitialBufferState; use warp_editor::model::CoreEditorModel; use warpui_core::elements::tui::{TuiElement, TuiHoverable}; use warpui_core::elements::MouseStateHandle; @@ -39,10 +40,6 @@ pub(crate) struct TuiEditorView { editor_state: TuiEditorState, editor_behavior: TuiEditorBehavior, focused: bool, - /// Target text of an in-flight programmatic replacement. Content events - /// are suppressed until the model reaches this value (including an - /// intermediate clear event). - suppressed_text_target: Option, mouse_state: MouseStateHandle, } @@ -55,13 +52,7 @@ impl TuiEditorView { return; } let text = editor.text(ctx); - if let Some(target) = &editor.suppressed_text_target { - if &text == target { - editor.suppressed_text_target = None; - } - } else { - ctx.emit(TuiEditorViewEvent::Changed(text)); - } + ctx.emit(TuiEditorViewEvent::Changed(text)); ctx.notify(); }); Self { @@ -69,7 +60,6 @@ impl TuiEditorView { editor_state: TuiEditorState::default(), editor_behavior: TuiEditorBehavior::single_line(), focused: false, - suppressed_text_target: None, mouse_state: MouseStateHandle::default(), } } @@ -97,10 +87,8 @@ impl TuiEditorView { if self.text(ctx) == text { return; } - self.suppressed_text_target = Some(text.clone()); self.model.update(ctx, |model, ctx| { - model.clear_buffer(ctx); - model.user_insert(&text, ctx); + model.reset_content(InitialBufferState::plain_text(&text), ctx); }); ctx.notify(); } From ee6779e72b3a48bb33b961a36540526509e4369e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 19:09:44 -0400 Subject: [PATCH 03/40] Fix failing tests --- crates/warp_tui/src/input/view_tests.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index 75031520e7c..75af9b7c611 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -773,7 +773,11 @@ fn move_left_in_shell_mode_does_not_open_conversation_menu() { "the `!` prefix is not buffered" ); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!( !menu_model.as_ref(ctx).is_open, @@ -1620,7 +1624,11 @@ fn explicit_shell_mode_survives_deleting_the_buffer() { let view = build_view(ctx); type_str(&view, ctx, "!cargo"); for _ in 0.."cargo".chars().count() { - dispatch(&view, ctx, &[TuiInputAction::Backspace]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Backspace)], + ); } assert_eq!(text(&view, ctx), ""); From 1660f1cb778b33092303b6566e1d0faed26365a8 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 07:44:06 -0400 Subject: [PATCH 04/40] add reusable TUI option selector Co-Authored-By: Oz --- crates/warp_tui/src/lib.rs | 6 +- crates/warp_tui/src/option_selector.rs | 1198 +++++++++++++++++ crates/warp_tui/src/option_selector_tests.rs | 1042 ++++++++++++++ crates/warp_tui/src/tui_builder.rs | 9 + .../src/runtime/event_conversion.rs | 2 +- .../src/runtime/event_conversion_tests.rs | 8 + specs/code-1822-tui-option-selector/TECH.md | 167 +++ 7 files changed, 2429 insertions(+), 3 deletions(-) create mode 100644 crates/warp_tui/src/option_selector.rs create mode 100644 crates/warp_tui/src/option_selector_tests.rs create mode 100644 specs/code-1822-tui-option-selector/TECH.md diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 59cabd5fc5a..62f551e9e94 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -23,8 +23,6 @@ mod conversation_menu; mod conversation_selection; mod editor_element; mod editor_interaction; -// The option-selector slice consumes this reusable editor and removes the allow. -#[allow(dead_code)] mod editor_view; mod exit_confirmation; mod inline_menu; @@ -33,6 +31,10 @@ mod input_suggestions_mode; mod keybindings; mod mcp_menu; mod model_menu; +// Not consumed yet: the TUI orchestration card slice embeds this selector +// and removes the allow. +#[allow(dead_code)] +mod option_selector; mod resume; mod skills_menu; mod slash_commands; diff --git a/crates/warp_tui/src/option_selector.rs b/crates/warp_tui/src/option_selector.rs new file mode 100644 index 00000000000..f137628f560 --- /dev/null +++ b/crates/warp_tui/src/option_selector.rs @@ -0,0 +1,1198 @@ +//! [`TuiOptionSelector`]: a reusable single-select option list for TUI +//! permission prompts, rendered from a frontend-neutral +//! [`OptionSnapshot`]. One configuration page shows a header (title, +//! "n of m" position, question), a selectable option list with viewport +//! scrolling, optional Loading/Failed/Empty status rows, and an optional +//! custom-text footer editor. +//! +//! Enter, Numpad Enter, arrows, viewport-relative digits, printable +//! characters, clicks, and wheel scrolling are handled at the element level +//! since the selector is only rendered while its host is the active blocking +//! interaction. Escape remains host policy, with an element-level fallback +//! through [`TuiOptionSelector::handle_back`]. + +use warp::tui_export::{OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus}; +use warp_search_core::inline_menu::InlineMenuSelection; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiFlex, + TuiHoverable, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiParentElement, + TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, TuiText, +}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{ + AppContext, BlurContext, Entity, EntityId, FocusContext, TuiView, TypedActionView, ViewContext, + ViewHandle, +}; + +use crate::editor_view::{TuiEditorView, TuiEditorViewEvent}; +use crate::inline_menu::keep_selected_visible; +use crate::tui_builder::TuiUiBuilder; + +/// Maximum option rows visible at once; longer lists scroll. +pub(crate) const MAX_VISIBLE_OPTION_ROWS: usize = 6; + +/// Validation copy shown when the custom-text editor is submitted empty. +const CUSTOM_TEXT_EMPTY_ERROR: &str = "Enter a value to continue."; + +/// Renderable fields for one selector page. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct OptionSelectorPage { + /// Short field label shown in the header row. + pub(crate) field_label: String, + /// One-based position in the current page sequence: `(current, total)`. + pub(crate) position: (usize, usize), + /// Full prompt shown above the available options. + pub(crate) prompt: String, + /// Options and catalog status rendered on this page. + pub(crate) snapshot: OptionSnapshot, + /// Whether this page offers label filtering. + pub(crate) searchable: bool, +} + +impl Default for OptionSelectorPage { + fn default() -> Self { + Self { + field_label: String::new(), + position: (0, 0), + prompt: String::new(), + snapshot: OptionSnapshot { + rows: Vec::new(), + selected_id: None, + status: OptionSourceStatus::Ready, + footer: None, + }, + searchable: false, + } + } +} + +/// Events emitted to the embedding card view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiOptionSelectorEvent { + /// An enabled option row was confirmed. + Confirmed { id: String }, + /// The custom-text footer editor was submitted with a valid value. + CustomTextSubmitted { value: String }, + /// The Retry affordance of a `Failed` catalog was activated. + RetryRequested, + /// The selector asked to be dismissed (element-level Escape fallback for + /// hosts without their own Escape binding). + Dismissed, + /// The selector's intrinsic height changed. `ctx.notify()` rerenders this + /// view, but the block list may reuse a stable-width cached rich-content + /// height. The host forwards this event so the containing rich-content + /// item is marked dirty and remeasured. + LayoutInvalidated, +} + +/// User interactions dispatched from the selector's element tree. +#[derive(Clone, Debug)] +pub(crate) enum TuiOptionSelectorAction { + /// Confirm the currently selected item. + ConfirmSelected, + MoveUp, + MoveDown, + /// Select the viewport-relative item and confirm it when enabled. + SelectNumberedOption(u8), + /// Select the item at an absolute index and confirm it when enabled. + /// Dispatched by row clicks. + SelectItem(usize), + /// Scroll the viewport by whole rows without moving the selection. + ScrollBy(isize), + /// Move focus from the option list to search and seed its query. + FocusSearchAndInsert(char), + /// Element-level Escape fallback (see [`TuiOptionSelectorEvent::Dismissed`]). + Dismiss, +} + +/// One navigable entry in the selector, in display order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectorItem { + /// Index into `snapshot.rows`. + Row(usize), + /// The Retry affordance shown for a `Failed` catalog. + Retry, + /// The custom-text footer entry point. + CustomText, +} + +/// Editing phase for the custom-text footer. +#[derive(Default)] +enum CustomTextEditingState { + #[default] + Inactive, + Active { + error_visible: bool, + }, +} + +impl CustomTextEditingState { + /// Returns the active editor's validation state. + fn error_visible(&self) -> Option { + match self { + Self::Inactive => None, + Self::Active { error_visible } => Some(*error_visible), + } + } +} + +/// Editor, committed value, and editing phase for a custom-text footer. +struct CustomTextState { + editor: ViewHandle, + committed_value: Option, + editing: CustomTextEditingState, +} + +impl CustomTextState { + /// Creates inactive custom-text state around its editor view. + fn new(editor: ViewHandle) -> Self { + Self { + editor, + committed_value: None, + editing: CustomTextEditingState::Inactive, + } + } + + /// Whether the custom-text editor currently owns the interaction. + fn is_editing(&self) -> bool { + self.editing.error_visible().is_some() + } + + /// Whether the active editor shows its validation error. + fn error_is_visible(&self) -> bool { + self.editing.error_visible() == Some(true) + } + + /// Restores the committed value encoded by a snapshot. + fn sync_committed_value(&mut self, snapshot: &OptionSnapshot) { + self.committed_value = match (&snapshot.footer, &snapshot.selected_id) { + (Some(OptionFooter::CustomText { .. }), Some(selected_id)) + if !snapshot.rows.iter().any(|row| row.id == *selected_id) => + { + Some(selected_id.clone()) + } + ( + Some(OptionFooter::CustomText { .. } | OptionFooter::CreateNewAuthSecret) | None, + Some(_) | None, + ) => None, + }; + } + + /// Resets editing and synchronizes the editor with the committed value. + fn reset_editor(&mut self, ctx: &mut ViewContext) { + self.editing = CustomTextEditingState::Inactive; + let value = self.committed_value.clone().unwrap_or_default(); + self.editor + .update(ctx, |editor, ctx| editor.set_text(value, ctx)); + } + + /// Activates the editor with the last committed value. + fn begin_editing(&mut self, ctx: &mut ViewContext) { + let value = self.committed_value.clone().unwrap_or_default(); + self.editor + .update(ctx, |editor, ctx| editor.set_text(value, ctx)); + self.editing = CustomTextEditingState::Active { + error_visible: false, + }; + } + + /// Cancels editing and returns whether a validation row was removed. + fn cancel_editing(&mut self) -> Option { + let error_visible = self.editing.error_visible()?; + self.editing = CustomTextEditingState::Inactive; + Some(error_visible) + } + + /// Shows the validation error and returns whether layout changed. + fn show_validation_error(&mut self) -> bool { + if self.error_is_visible() { + return false; + }; + self.editing = CustomTextEditingState::Active { + error_visible: true, + }; + true + } + /// Clears a visible validation error and returns whether layout changed. + fn clear_validation_error(&mut self) -> bool { + if !self.error_is_visible() { + return false; + } + self.editing = CustomTextEditingState::Active { + error_visible: false, + }; + true + } + + /// Commits a value and returns whether a validation row was removed. + fn commit(&mut self, value: String) -> bool { + let error_visible = self.error_is_visible(); + self.editing = CustomTextEditingState::Inactive; + self.committed_value = Some(value); + error_visible + } +} + +/// Transient list and editor state reset when a page is replaced. +#[derive(Default)] +struct SelectorInteractionState { + selection: InlineMenuSelection, + scroll_offset: usize, + search_query: String, +} + +/// A reusable single-select option list view. See the module docs. +pub(crate) struct TuiOptionSelector { + page: OptionSelectorPage, + interaction: SelectorInteractionState, + search_field: Option>, + custom_text: CustomTextState, + /// Whether the selector itself (the list zone) is focused. + focused: bool, + /// Per-item mouse state, indexed like [`Self::items`]. Owned here (not + /// created inline during render) so hover/click state survives + /// element-tree rebuilds. + item_mouse_states: Vec, +} + +impl TuiOptionSelector { + /// Creates an empty selector; hosts call [`Self::set_page`] before render. + pub(crate) fn new(ctx: &mut ViewContext) -> Self { + let custom_text_editor = ctx.add_typed_action_tui_view(TuiEditorView::single_line); + ctx.subscribe_to_view(&custom_text_editor, |me, _, event, ctx| { + let TuiEditorViewEvent::Changed(_) = event; + if me.custom_text.clear_validation_error() { + me.invalidate_layout(ctx); + } else { + ctx.notify(); + } + }); + + Self { + page: OptionSelectorPage::default(), + interaction: SelectorInteractionState::default(), + search_field: None, + custom_text: CustomTextState::new(custom_text_editor), + focused: false, + item_mouse_states: Vec::new(), + } + } + + /// Creates and subscribes to the optional search editor. + fn add_search_field(ctx: &mut ViewContext) -> ViewHandle { + let search_field = ctx.add_typed_action_tui_view(TuiEditorView::single_line); + ctx.subscribe_to_view(&search_field, |me, _, event, ctx| { + let TuiEditorViewEvent::Changed(query) = event; + me.interaction.search_query = query.clone(); + me.interaction.selection.clear(); + me.interaction.scroll_offset = 0; + me.sync_after_items_changed(); + me.invalidate_layout(ctx); + }); + search_field + } + + /// Notifies this view and any host that caches its intrinsic height. + fn invalidate_layout(&self, ctx: &mut ViewContext) { + ctx.emit(TuiOptionSelectorEvent::LayoutInvalidated); + ctx.notify(); + } + /// Whether the optional search editor currently owns focus. + fn search_field_is_focused(&self, ctx: &AppContext) -> bool { + self.search_field + .as_ref() + .is_some_and(|field| field.as_ref(ctx).is_focused()) + } + + /// Resets all transient interaction state for the newly installed page. + fn reset_interaction_for_page(&mut self, ctx: &mut ViewContext) { + self.interaction = SelectorInteractionState::default(); + if let Some(search_field) = self.search_field.as_ref() { + search_field.update(ctx, |editor, ctx| editor.set_text("", ctx)); + } + self.custom_text.reset_editor(ctx); + self.select_id(self.page.snapshot.selected_id.clone()); + self.sync_after_items_changed(); + ctx.focus_self(); + } + + /// Replaces the current page and resets its transient interaction state. + pub(crate) fn set_page(&mut self, page: OptionSelectorPage, ctx: &mut ViewContext) { + if page.searchable && self.search_field.is_none() { + self.search_field = Some(Self::add_search_field(ctx)); + } + self.page = page; + self.custom_text.sync_committed_value(&self.page.snapshot); + self.reset_interaction_for_page(ctx); + self.invalidate_layout(ctx); + } + + /// Refreshes the snapshot in place after a live catalog change, + /// preserving the active selection when it still exists and falling back + /// to the snapshot's committed selection otherwise. + pub(crate) fn refresh_snapshot( + &mut self, + snapshot: OptionSnapshot, + ctx: &mut ViewContext, + ) { + let selected = self.selected_row_id(); + self.page.snapshot = snapshot; + self.custom_text.sync_committed_value(&self.page.snapshot); + let target = selected + .filter(|id| self.page.snapshot.rows.iter().any(|row| &row.id == id)) + .or_else(|| self.page.snapshot.selected_id.clone()); + if self.search_field_is_focused(ctx) { + self.interaction.selection.clear(); + } else { + self.select_id(target); + } + self.sync_after_items_changed(); + // A refreshed catalog can change the row count and thus the height. + self.invalidate_layout(ctx); + } + + /// Scrolls to keep `selected` visible, announcing the scroll change (it + /// toggles overflow markers, so the height may change) to the host. + fn scroll_to_keep_visible( + &mut self, + items_len: usize, + selected: usize, + ctx: &mut ViewContext, + ) { + let before = self.interaction.scroll_offset; + keep_selected_visible( + items_len, + selected, + MAX_VISIBLE_OPTION_ROWS, + &mut self.interaction.scroll_offset, + ); + if self.interaction.scroll_offset != before { + ctx.emit(TuiOptionSelectorEvent::LayoutInvalidated); + } + } + + /// Confirms the selected item (Enter): enabled rows emit + /// [`TuiOptionSelectorEvent::Confirmed`]; disabled rows are kept + /// selected so their reason stays visible. While the + /// custom-text editor is active, Enter validates and submits it instead + ///. + pub(crate) fn confirm_selected(&mut self, ctx: &mut ViewContext) -> bool { + if self.custom_text.is_editing() { + return self.submit_custom_text(ctx); + } + if self.search_field_is_focused(ctx) { + if let Some(index) = self.items().iter().position(|item| { + matches!(item, SelectorItem::Row(_)) && self.item_is_confirmable(*item) + }) { + return self.confirm_item(index, ctx); + } + return false; + } + let Some(index) = self.interaction.selection.selected_index() else { + return false; + }; + self.confirm_item(index, ctx) + } + + /// Cancels active custom-text editing, returning whether it consumed Back. + fn cancel_custom_text_editing(&mut self, ctx: &mut ViewContext) -> bool { + let Some(layout_changed) = self.custom_text.cancel_editing() else { + return false; + }; + + ctx.focus_self(); + if layout_changed { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + true + } + + /// Activates the custom editor with the last committed value. + fn begin_custom_text_editing(&mut self, ctx: &mut ViewContext) { + self.custom_text.begin_editing(ctx); + ctx.focus(&self.custom_text.editor); + ctx.notify(); + } + + /// Shows the custom-text validation error when it is not already visible. + fn show_custom_text_validation_error(&mut self, ctx: &mut ViewContext) { + if !self.custom_text.show_validation_error() { + ctx.notify(); + return; + } + self.invalidate_layout(ctx); + } + + /// Commits a validated custom-text value and exits its editor. + fn commit_custom_text(&mut self, value: String, ctx: &mut ViewContext) { + let layout_changed = self.custom_text.commit(value.clone()); + self.page.snapshot.selected_id = Some(value.clone()); + ctx.focus_self(); + ctx.emit(TuiOptionSelectorEvent::CustomTextSubmitted { value }); + if layout_changed { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + } + /// Clears focused search text, returning whether it consumed Back. + fn clear_focused_search(&mut self, ctx: &mut ViewContext) -> bool { + if !self.search_field_is_focused(ctx) || self.interaction.search_query.is_empty() { + return false; + } + + self.interaction.search_query.clear(); + if let Some(search_field) = self.search_field.as_ref() { + search_field.update(ctx, |field, ctx| field.set_text("", ctx)); + } + self.interaction.scroll_offset = 0; + self.sync_after_items_changed(); + self.invalidate_layout(ctx); + true + } + + /// Handles Escape from the embedding card, consuming it when the selector + /// has an active editor interaction to unwind. + pub(crate) fn handle_back(&mut self, ctx: &mut ViewContext) -> bool { + self.cancel_custom_text_editing(ctx) || self.clear_focused_search(ctx) + } + + /// The navigable entries, in display order. + fn items(&self) -> Vec { + let query = self.interaction.search_query.to_lowercase(); + let mut items: Vec = (0..self.page.snapshot.rows.len()) + .filter(|index| { + query.is_empty() + || self.page.snapshot.rows[*index] + .label + .to_lowercase() + .contains(&query) + }) + .map(SelectorItem::Row) + .collect(); + if matches!(self.page.snapshot.status, OptionSourceStatus::Failed { .. }) { + items.push(SelectorItem::Retry); + } + match &self.page.snapshot.footer { + Some(OptionFooter::CustomText { .. }) => items.push(SelectorItem::CustomText), + // Resource creation is out of scope in the TUI. + Some(OptionFooter::CreateNewAuthSecret) | None => {} + } + items + } + + /// Whether the item can be confirmed. Disabled rows stay selectable + /// but unconfirmable. + fn item_is_confirmable(&self, item: SelectorItem) -> bool { + match item { + SelectorItem::Row(index) => self + .page + .snapshot + .rows + .get(index) + .is_some_and(|row| row.disabled_reason.is_none()), + SelectorItem::Retry | SelectorItem::CustomText => true, + } + } + + /// The row id currently selected, when the selection is on a row. + fn selected_row_id(&self) -> Option { + let items = self.items(); + match self + .interaction + .selection + .selected_index() + .and_then(|i| items.get(i)) + { + Some(SelectorItem::Row(index)) => self + .page + .snapshot + .rows + .get(*index) + .map(|row| row.id.clone()), + Some(SelectorItem::Retry) | Some(SelectorItem::CustomText) | None => None, + } + } + + /// Moves the selection to the row with `id`, else the first item. + fn select_id(&mut self, id: Option) { + let items = self.items(); + let target = id + .and_then(|id| { + items.iter().position(|item| match item { + SelectorItem::Row(index) => self + .page + .snapshot + .rows + .get(*index) + .is_some_and(|row| row.id == id), + SelectorItem::CustomText => { + self.custom_text.committed_value.as_ref() == Some(&id) + } + SelectorItem::Retry => false, + }) + }) + .or(if items.is_empty() { None } else { Some(0) }); + self.interaction.selection.clear(); + if let Some(target) = target { + self.interaction + .selection + .select(target, items.len(), |_| true); + } + } + + /// Clamps scroll state and mouse-handle storage to the current items. + fn sync_after_items_changed(&mut self) { + let items_len = self.items().len(); + self.interaction.scroll_offset = self + .interaction + .scroll_offset + .min(items_len.saturating_sub(MAX_VISIBLE_OPTION_ROWS)); + if let Some(selected) = self.interaction.selection.selected_index() { + keep_selected_visible( + items_len, + selected, + MAX_VISIBLE_OPTION_ROWS, + &mut self.interaction.scroll_offset, + ); + } + // Handles are stable per item index across renders; grow as needed. + while self.item_mouse_states.len() < items_len { + self.item_mouse_states.push(MouseStateHandle::default()); + } + } + + /// Moves the selection one step, scrolling to keep it visible. + fn move_selection(&mut self, forward: bool, ctx: &mut ViewContext) { + if self.custom_text.is_editing() { + return; + } + let items_len = self.items().len(); + if self.search_field_is_focused(ctx) { + if items_len > 0 { + let target = if forward { 0 } else { items_len - 1 }; + self.interaction + .selection + .select(target, items_len, |_| true); + ctx.focus_self(); + self.scroll_to_keep_visible(items_len, target, ctx); + } + ctx.notify(); + return; + } + let move_to_search = self.page.searchable + && match (forward, self.interaction.selection.selected_index()) { + (false, None | Some(0)) => true, + (true, Some(index)) => index + 1 >= items_len, + (true, None) | (false, Some(_)) => false, + }; + if move_to_search { + self.interaction.selection.clear(); + self.interaction.scroll_offset = 0; + if let Some(search_field) = self.search_field.as_ref() { + ctx.focus(search_field); + } + ctx.notify(); + return; + } + if forward { + self.interaction.selection.select_next(items_len, |_| true); + } else { + self.interaction + .selection + .select_previous(items_len, |_| true); + } + if let Some(selected) = self.interaction.selection.selected_index() { + self.scroll_to_keep_visible(items_len, selected, ctx); + } + ctx.notify(); + } + + /// Confirms the item at `index` when enabled; otherwise selects it so + /// its disabled reason is surfaced. + fn confirm_item(&mut self, index: usize, ctx: &mut ViewContext) -> bool { + let items = self.items(); + let Some(item) = items.get(index).copied() else { + return false; + }; + self.interaction + .selection + .select(index, items.len(), |_| true); + self.scroll_to_keep_visible(items.len(), index, ctx); + if !self.item_is_confirmable(item) { + ctx.notify(); + return false; + } + match item { + SelectorItem::Row(row_index) => { + if let Some(row) = self.page.snapshot.rows.get(row_index) { + ctx.emit(TuiOptionSelectorEvent::Confirmed { id: row.id.clone() }); + } + } + SelectorItem::Retry => ctx.emit(TuiOptionSelectorEvent::RetryRequested), + SelectorItem::CustomText => { + self.begin_custom_text_editing(ctx); + return true; + } + } + ctx.notify(); + true + } + + /// Validates and submits the custom-text editor: the value + /// is trimmed; empty input stays editable with a concise error. + fn submit_custom_text(&mut self, ctx: &mut ViewContext) -> bool { + if !self.custom_text.is_editing() { + return false; + } + let value = self + .custom_text + .editor + .as_ref(ctx) + .text(ctx) + .trim() + .to_string(); + if value.is_empty() { + self.show_custom_text_validation_error(ctx); + false + } else { + self.commit_custom_text(value, ctx); + true + } + } + + /// Scrolls the viewport by `rows` without moving the selection + ///. + fn scroll_by(&mut self, rows: isize, ctx: &mut ViewContext) { + let items_len = self.items().len(); + let max_offset = items_len.saturating_sub(MAX_VISIBLE_OPTION_ROWS); + let before = self.interaction.scroll_offset; + self.interaction.scroll_offset = self + .interaction + .scroll_offset + .saturating_add_signed(rows) + .min(max_offset); + if self.interaction.scroll_offset != before { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + } + + // ── Rendering ─────────────────────────────────────────────────── + + /// One header block: field label + position, then the page prompt. + fn render_header(&self, builder: &TuiUiBuilder) -> Box { + let (current, total) = self.page.position; + let title = TuiText::new(self.page.field_label.clone()) + .with_style(builder.primary_text_style()) + .truncate() + .finish(); + let previous_style = if current > 1 { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + let next_style = if current < total { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + let position = TuiText::from_spans([ + ("←".to_string(), previous_style), + (format!(" {current} "), builder.primary_text_style()), + (format!("of {total} "), builder.muted_text_style()), + ("→".to_string(), next_style), + ]) + .truncate() + .finish(); + let title_row = TuiFlex::row() + .child(title) + .flex_child(TuiFlex::row().finish()) + .child(position) + .finish(); + TuiFlex::column() + .child(title_row) + .child(TuiText::new(" ").finish()) + .child( + TuiText::new(self.page.prompt.clone()) + .with_style(builder.primary_text_style().add_modifier(Modifier::BOLD)) + .finish(), + ) + .finish() + } + + /// One option row: viewport-relative digit, label, badge, and disabled + /// reason, with the current selection rendered in bold magenta. + fn render_row( + &self, + row: &OptionRow, + digit: Option, + is_selected: bool, + builder: &TuiUiBuilder, + ) -> Box { + let disabled = row.disabled_reason.is_some(); + let label_style = if is_selected { + builder.option_selector_selected_style() + } else if disabled { + builder.dim_text_style() + } else { + builder.primary_text_style() + }; + let detail_style = if is_selected { + builder.option_selector_selected_style() + } else if disabled { + builder.dim_text_style() + } else { + builder.muted_text_style() + }; + let digit_prefix = match digit { + Some(digit) => format!("({digit}) "), + None => " ".to_string(), + }; + let mut spans = vec![ + (digit_prefix, detail_style), + (row.label.clone(), label_style), + ]; + let badge = match row.badge { + Some(OptionBadge::Default) => Some("default"), + Some(OptionBadge::Recent) => Some("recent"), + Some(OptionBadge::Connected) => Some("connected"), + None => None, + }; + if let Some(badge) = badge { + spans.push((format!(" ({badge})"), detail_style)); + } + if let Some(reason) = &row.disabled_reason { + spans.push((format!(" — {reason}"), detail_style)); + } + TuiText::from_spans(spans).truncate().finish() + } + + /// A generic single-span selectable virtual row (Retry / custom text). + fn render_virtual_row( + &self, + text: String, + digit: Option, + is_selected: bool, + style: TuiStyle, + builder: &TuiUiBuilder, + ) -> Box { + let style = if is_selected { + builder.option_selector_selected_style() + } else { + style + }; + let digit_prefix = match digit { + Some(digit) => format!("({digit}) "), + None => " ".to_string(), + }; + TuiText::from_spans([(format!("{digit_prefix}{text}"), style)]) + .truncate() + .finish() + } + + /// Renders selector-owned label/error chrome around a generic editor view. + fn render_editor_field( + &self, + prefix: String, + label: &str, + editor: &ViewHandle, + error: Option<&str>, + builder: &TuiUiBuilder, + ) -> Box { + let label = TuiText::new(format!("{prefix}{label}: ")) + .with_style(builder.muted_text_style()) + .truncate() + .finish(); + let row = TuiFlex::row() + .child(label) + .flex_child(TuiChildView::new(editor).finish()) + .finish(); + let mut content = TuiFlex::column().child(row); + if let Some(error) = error { + content.add_child( + TuiText::new(error.to_string()) + .with_style(builder.error_text_style()) + .truncate() + .finish(), + ); + } + content.finish() + } + + /// The option list: visible window of items with digit prefixes, plus + /// non-selectable status rows for Loading/Failed/Empty. + fn render_list(&self, builder: &TuiUiBuilder) -> Box { + let items = self.items(); + let mut column = TuiFlex::column(); + + let visible_end = + (self.interaction.scroll_offset + MAX_VISIBLE_OPTION_ROWS).min(items.len()); + let visible = self.interaction.scroll_offset..visible_end; + if self.page.searchable + && !self.interaction.search_query.is_empty() + && !items + .iter() + .any(|item| matches!(item, SelectorItem::Row(_))) + { + column.add_child( + TuiText::new("No matches") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + if self.interaction.scroll_offset > 0 { + column.add_child( + TuiText::new("↑") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + for (position, index) in visible.clone().enumerate() { + let item = items[index]; + let digit = (position < 9).then_some(position + 1); + let is_selected = !self.custom_text.is_editing() + && self.interaction.selection.selected_index() == Some(index); + let element = match item { + SelectorItem::Row(row_index) => { + let Some(row) = self.page.snapshot.rows.get(row_index) else { + continue; + }; + self.render_row(row, digit, is_selected, builder) + } + SelectorItem::Retry => self.render_virtual_row( + "↻ Retry".to_string(), + digit, + is_selected, + builder.error_text_style(), + builder, + ), + SelectorItem::CustomText => { + match (&self.page.snapshot.footer, self.custom_text.is_editing()) { + (Some(OptionFooter::CustomText { label }), true) => self + .render_editor_field( + digit.map_or_else( + || " ".to_string(), + |digit| format!("({digit}) "), + ), + label, + &self.custom_text.editor, + self.custom_text + .error_is_visible() + .then_some(CUSTOM_TEXT_EMPTY_ERROR), + builder, + ), + (Some(OptionFooter::CustomText { label }), false) => self + .render_virtual_row( + self.custom_text + .committed_value + .clone() + .unwrap_or_else(|| label.clone()), + digit, + is_selected, + builder.primary_text_style(), + builder, + ), + (Some(OptionFooter::CreateNewAuthSecret) | None, _) => continue, + } + } + }; + // Each visible row is clickable through its own persistent + // mouse-state handle. + let element = match self.item_mouse_states.get(index) { + Some(mouse_state) => TuiHoverable::new(mouse_state.clone(), element) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::SelectItem(index)); + }) + .finish(), + None => element, + }; + column.add_child(element); + } + if visible_end < items.len() { + column.add_child( + TuiText::new("↓") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + + match &self.page.snapshot.status { + OptionSourceStatus::Ready => {} + OptionSourceStatus::Loading => { + column.add_child( + TuiText::new("Loading…") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + OptionSourceStatus::Failed { message } => { + column.add_child( + TuiText::new(message.clone()) + .with_style(builder.error_text_style()) + .truncate() + .finish(), + ); + } + OptionSourceStatus::Empty { message } => { + column.add_child( + TuiText::new(message.clone()) + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + } + column.finish() + } +} + +impl Entity for TuiOptionSelector { + type Event = TuiOptionSelectorEvent; +} + +impl TuiView for TuiOptionSelector { + fn ui_name() -> &'static str { + "TuiOptionSelector" + } + + fn render(&self, app: &AppContext) -> Box { + let builder = TuiUiBuilder::from_app(app); + let mut content = TuiFlex::column().child(self.render_header(&builder)); + if let Some(search_field) = self + .page + .searchable + .then_some(self.search_field.as_ref()) + .flatten() + { + content.add_child(self.render_editor_field( + String::new(), + "Search", + search_field, + None, + &builder, + )); + } + content.add_child(self.render_list(&builder)); + SelectorInputElement { + child: content.finish(), + list_focused: self.focused, + searchable: self.page.searchable, + } + .finish() + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + let mut ids = vec![self.custom_text.editor.id()]; + if self.page.searchable { + ids.extend(self.search_field.iter().map(ViewHandle::id)); + } + ids + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + match focus_ctx { + FocusContext::SelfFocused => self.focused = true, + FocusContext::DescendentFocused(view_id) => { + self.focused = false; + if self + .search_field + .as_ref() + .is_some_and(|search_field| *view_id == search_field.id()) + { + self.interaction.selection.clear(); + } + } + } + ctx.notify(); + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiOptionSelector { + fn handle_action(&mut self, action: &TuiOptionSelectorAction, ctx: &mut ViewContext) { + match action { + TuiOptionSelectorAction::ConfirmSelected => { + self.confirm_selected(ctx); + } + TuiOptionSelectorAction::MoveUp => self.move_selection(false, ctx), + TuiOptionSelectorAction::MoveDown => self.move_selection(true, ctx), + TuiOptionSelectorAction::SelectNumberedOption(digit) => { + let index = self.interaction.scroll_offset + usize::from(*digit) - 1; + self.confirm_item(index, ctx); + } + TuiOptionSelectorAction::SelectItem(index) => { + self.confirm_item(*index, ctx); + } + TuiOptionSelectorAction::ScrollBy(rows) => self.scroll_by(*rows, ctx), + TuiOptionSelectorAction::FocusSearchAndInsert(c) => { + if let Some(search_field) = + self.search_field.clone().filter(|_| self.page.searchable) + { + self.interaction.search_query.push(*c); + let query = self.interaction.search_query.clone(); + search_field.update(ctx, |field, ctx| field.set_text(query, ctx)); + self.interaction.selection.clear(); + self.interaction.scroll_offset = 0; + self.sync_after_items_changed(); + ctx.focus(&search_field); + self.invalidate_layout(ctx); + } + } + TuiOptionSelectorAction::Dismiss => { + if !self.handle_back(ctx) { + ctx.emit(TuiOptionSelectorEvent::Dismissed); + } + } + } + } + + type Action = TuiOptionSelectorAction; +} + +/// Wraps the selector's rendered content and translates element-level input +/// (confirmation, arrows, digits, custom-text characters, wheel scrolling) into +/// [`TuiOptionSelectorAction`]s. +struct SelectorInputElement { + child: Box, + list_focused: bool, + searchable: bool, +} + +impl TuiElement for SelectorInputElement { + fn layout( + &mut self, + constraint: TuiConstraint, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> TuiSize { + self.child.layout(constraint, ctx, app) + } + + fn render( + &mut self, + origin: TuiScreenPosition, + surface: &mut TuiPaintSurface<'_>, + ctx: &mut TuiPaintContext, + ) { + self.child.render(origin, surface, ctx); + } + + fn size(&self) -> Option { + self.child.size() + } + + fn origin(&self) -> Option { + self.child.origin() + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + self.child.present(ctx); + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + event_ctx: &mut TuiEventContext<'_>, + app: &AppContext, + ) -> bool { + if self.child.dispatch_event(event, event_ctx, app) { + return true; + } + match event { + TuiEvent::KeyDown { + keystroke, chars, .. + } => { + if keystroke.ctrl || keystroke.alt || keystroke.cmd || keystroke.meta { + return false; + } + match keystroke.key.as_str() { + "enter" | "numpadenter" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::ConfirmSelected); + true + } + "escape" => { + // Escape fallback for hosts without their own + // Escape keymap binding; the embedding card's + // `escape` binding normally consumes the key first. + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::Dismiss); + true + } + "up" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::MoveUp); + true + } + "down" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::MoveDown); + true + } + key if self.list_focused => match key.parse::() { + Ok(digit @ 1..=9) => { + event_ctx.dispatch_typed_action( + TuiOptionSelectorAction::SelectNumberedOption(digit), + ); + true + } + Ok(_) => false, + Err(_) => { + let Some(c) = chars.chars().next().filter(|c| !c.is_control()) else { + return false; + }; + if self.searchable { + event_ctx.dispatch_typed_action( + TuiOptionSelectorAction::FocusSearchAndInsert(c), + ); + true + } else { + false + } + } + }, + _ => false, + } + } + TuiEvent::ScrollWheel { + position, delta, .. + } => { + let Some((origin, size)) = self.origin().zip(self.size()) else { + return false; + }; + if !event_ctx.hit_test(origin, size, *position) { + return false; + } + let (_, rows) = *delta; + if rows == 0 { + return false; + } + // Positive wheel delta scrolls the content up (toward the + // start of the list), matching the transcript's scrollable. + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::ScrollBy(-rows)); + true + } + TuiEvent::Paste { .. } => false, + TuiEvent::LeftMouseDown { .. } + | TuiEvent::LeftMouseUp { .. } + | TuiEvent::LeftMouseDragged { .. } + | TuiEvent::MiddleMouseDown { .. } + | TuiEvent::RightMouseDown { .. } + | TuiEvent::MouseMoved { .. } => false, + } + } +} + +#[cfg(test)] +#[path = "option_selector_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/option_selector_tests.rs b/crates/warp_tui/src/option_selector_tests.rs new file mode 100644 index 00000000000..56e9f75014d --- /dev/null +++ b/crates/warp_tui/src/option_selector_tests.rs @@ -0,0 +1,1042 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use warp::tui_export::{ + Appearance, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityId, EntityIdMap}; +use warpui_core::elements::tui::{ + Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, + TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::keymap::Keystroke; +use warpui_core::{App, AppContext, TuiView as _, TypedActionView as _, ViewHandle}; + +use super::{ + OptionSelectorPage, SelectorItem, TuiOptionSelector, TuiOptionSelectorAction, + TuiOptionSelectorEvent, +}; +use crate::editor_element::TuiEditorAction; +use crate::editor_interaction::TuiEditorCommand; +use crate::editor_view::TuiEditorViewAction; +use crate::test_fixtures::TestHostView; +use crate::tui_builder::TuiUiBuilder; + +/// Builds an enabled row with `id` used as the label. +fn row(id: &str) -> OptionRow { + OptionRow { + id: id.to_string(), + label: id.to_string(), + harness: None, + badge: None, + disabled_reason: None, + } +} + +/// Builds a disabled row carrying `reason`. +fn disabled_row(id: &str, reason: &str) -> OptionRow { + OptionRow { + disabled_reason: Some(reason.to_string()), + ..row(id) + } +} + +/// Builds a `Ready` snapshot over `ids` with `selected` preselected. +fn snapshot(ids: &[&str], selected: Option<&str>) -> OptionSnapshot { + snapshot_of(ids.iter().map(|id| row(id)).collect(), selected) +} + +/// Builds a `Ready` snapshot from explicit rows. +fn snapshot_of(rows: Vec, selected: Option<&str>) -> OptionSnapshot { + OptionSnapshot { + rows, + selected_id: selected.map(str::to_string), + status: OptionSourceStatus::Ready, + footer: None, + } +} + +/// Builds one selector page with shared test metadata. +fn page(snapshot: OptionSnapshot, searchable: bool) -> OptionSelectorPage { + OptionSelectorPage { + field_label: "Host".to_string(), + position: (4, 6), + prompt: "Which host should run the agents?".to_string(), + snapshot, + searchable, + } +} + +type CapturedEvents = Rc>>; + +/// The captured events with `LayoutInvalidated` filtered out, for tests that +/// assert on the primary confirmation flow. +fn primary_events(events: &CapturedEvents) -> Vec { + events + .borrow() + .iter() + .filter(|event| **event != TuiOptionSelectorEvent::LayoutInvalidated) + .cloned() + .collect() +} + +/// Adds a selector in a fresh TUI window and captures its emitted events. +fn add_selector(app: &mut App) -> (ViewHandle, CapturedEvents) { + app.add_singleton_model(|_| Appearance::mock()); + let selector = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, TuiOptionSelector::new) + }); + let events: CapturedEvents = Rc::new(RefCell::new(Vec::new())); + let events_for_subscription = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&selector, move |_, event, _| { + events_for_subscription.borrow_mut().push(event.clone()); + }); + }); + (selector, events) +} + +/// Sets the page under the shared test header. +fn set_page(app: &mut App, selector: &ViewHandle, snapshot: OptionSnapshot) { + selector.update(app, |selector, ctx| { + selector.set_page(page(snapshot, false), ctx); + }); +} +#[test] +fn search_editor_is_created_only_for_searchable_pages() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["auto"], Some("auto"))); + assert!(selector.read(&app, |selector, _| selector.search_field.is_none())); + + set_searchable_page(&mut app, &selector, snapshot(&["auto"], Some("auto"))); + assert!(selector.read(&app, |selector, _| selector.search_field.is_some())); + }); +} + +#[test] +fn searchable_page_starts_on_the_selected_row_and_digits_still_confirm() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5", "claude"], Some("gpt-5")), + ); + assert!(selected_line(&app, &selector).contains("(2) gpt-5")); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Search:"))); + + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(3), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "claude".to_string() + }] + ); + }); +} + +#[test] +fn up_from_top_focuses_search_and_down_returns_to_first_row() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5"], Some("auto")), + ); + + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .interaction + .selection + .selected_index() + .is_none())); + + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(selected_line(&app, &selector).contains("(1) auto")); + }); +} + +#[test] +fn search_and_last_option_wrap_in_both_directions() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5", "claude"], Some("auto")), + ); + + // Up from the first option focuses Search; another Up wraps to the + // last option. + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(selected_line(&app, &selector).contains("(3) claude")); + + // Down from the last option returns to Search. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .interaction + .selection + .selected_index() + .is_none())); + }); +} +#[test] +fn focused_search_filters_including_digits_and_enter_confirms_top_match() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto (genius)", "gpt-5", "claude"], Some("auto (genius)")), + ); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + edit_search( + &mut app, + &selector, + TuiEditorAction::InsertText("gpt-5".to_string()), + ); + + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("gpt-5"))); + assert!(!lines.iter().any(|line| line.contains("auto (genius)"))); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "gpt-5".to_string() + }] + ); + }); +} + +#[test] +fn typing_a_letter_from_the_list_focuses_and_seeds_search() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5"], Some("auto")), + ); + act( + &mut app, + &selector, + TuiOptionSelectorAction::FocusSearchAndInsert('g'), + ); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert_eq!( + app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .text(ctx)), + "g" + ); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("gpt-5"))); + }); +} + +#[test] +fn search_no_matches_and_escape_clear_are_rendered_without_moving_the_field() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot( + &["auto", "gpt-5", "claude", "gemini", "pareto"], + Some("auto"), + ), + ); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + edit_search( + &mut app, + &selector, + TuiEditorAction::InsertText("zzz".to_string()), + ); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("Search:"))); + assert!(lines.iter().any(|line| line.contains("No matches"))); + + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(consumed); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(1) auto"))); + assert!(lines.iter().any(|line| line.contains("Search:"))); + }); +} + +/// Sets a searchable page under the shared test header. +fn set_searchable_page( + app: &mut App, + selector: &ViewHandle, + snapshot: OptionSnapshot, +) { + selector.update(app, |selector, ctx| { + selector.set_page(page(snapshot, true), ctx); + }); +} + +/// Applies a text-field action to the active custom-text child. +fn edit_custom_text( + app: &mut App, + selector: &ViewHandle, + action: TuiEditorViewAction, +) { + let field = custom_text_field(app, selector); + field.update(app, |field, ctx| field.handle_action(&action, ctx)); +} +/// Returns the selector's custom-text editor for focus and action assertions. +fn custom_text_field( + app: &App, + selector: &ViewHandle, +) -> ViewHandle { + selector.read(app, |selector, _| selector.custom_text.editor.clone()) +} + +/// Applies a shared editor action to the search child. +fn edit_search(app: &mut App, selector: &ViewHandle, action: TuiEditorAction) { + let editor = selector.read(app, |selector, _| { + selector + .search_field + .clone() + .expect("searchable page has an editor") + }); + editor.update(app, |editor, ctx| { + editor.handle_action(&TuiEditorViewAction::Editor(action), ctx); + }); +} + +#[test] +fn set_page_recovers_a_selected_custom_text_value() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_custom_selection = snapshot(&["warp"], Some("my-host")); + with_custom_selection.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + + set_page(&mut app, &selector, with_custom_selection); + + let line = selected_line(&app, &selector); + assert!(line.contains("my-host")); + assert!(!line.contains("Custom host…")); + }); +} + +/// Dispatches a selector action directly to the view. +fn act(app: &mut App, selector: &ViewHandle, action: TuiOptionSelectorAction) { + selector.update(app, |selector, ctx| selector.handle_action(&action, ctx)); +} + +/// Confirms the selected item through the selector-owned action. +fn confirm(app: &mut App, selector: &ViewHandle) { + act(app, selector, TuiOptionSelectorAction::ConfirmSelected); +} + +/// Lays out the selector's element at `width`, returning it with its area. +fn laid_out_element( + selector: &ViewHandle, + rendered_views: &mut EntityIdMap>, + width: u16, + app: &AppContext, +) -> (Box, TuiRect) { + let selector_ref = selector.as_ref(app); + rendered_views.insert( + selector_ref.custom_text.editor.id(), + selector_ref.custom_text.editor.as_ref(app).render(app), + ); + if let Some(search_field) = selector_ref.search_field.as_ref() { + rendered_views.insert(search_field.id(), search_field.as_ref(app).render(app)); + } + let mut element = selector_ref.render(app); + let size = { + let mut layout_ctx = TuiLayoutContext { rendered_views }; + element.layout( + TuiConstraint::loose(TuiSize::new(width, u16::MAX)), + &mut layout_ctx, + app, + ) + }; + let area = TuiRect::new(0, 0, size.width.max(1), size.height.max(1)); + (element, area) +} + +/// Renders the selector to a styled cell buffer at `width`. +fn render_buffer(app: &App, selector: &ViewHandle, width: u16) -> TuiBuffer { + app.read(|app| { + let mut rendered_views = EntityIdMap::default(); + let (mut element, area) = laid_out_element(selector, &mut rendered_views, width, app); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + { + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + } + buffer + }) +} + +/// Renders the selector to trimmed lines at `width`. +fn render_lines(app: &App, selector: &ViewHandle, width: u16) -> Vec { + render_buffer(app, selector, width) + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect() +} + +/// The rendered line for the selector's selected item. +fn selected_line(app: &App, selector: &ViewHandle) -> String { + let needle = app.read(|app| { + let selector = selector.as_ref(app); + let index = selector + .interaction + .selection + .selected_index() + .expect("a selected item"); + let digit = index - selector.interaction.scroll_offset + 1; + let label = match selector.items()[index] { + SelectorItem::Row(row_index) => selector.page.snapshot.rows[row_index].label.clone(), + SelectorItem::Retry => "↻ Retry".to_string(), + SelectorItem::CustomText => selector + .custom_text + .committed_value + .clone() + .or_else(|| match &selector.page.snapshot.footer { + Some(OptionFooter::CustomText { label }) => Some(label.clone()), + Some(OptionFooter::CreateNewAuthSecret) | None => None, + }) + .expect("custom-text footer label"), + }; + format!("({digit}) {label}") + }); + render_lines(app, selector, 60) + .into_iter() + .find(|line| line.contains(&needle)) + .expect("a selected row") +} + +#[test] +fn renders_field_label_position_prompt_and_initial_selection() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("b"))); + let lines = render_lines(&app, &selector, 60); + // Header: field label, position in the current sequence, and prompt. + assert!(lines[0].contains("Host")); + assert!(lines[0].contains("←")); + assert!(lines[0].contains("4 of 6")); + assert!(lines[0].contains("→")); + assert!(lines[0].ends_with("← 4 of 6 →")); + assert!(lines[1].is_empty()); + assert!(lines[2].contains("Which host should run the agents?")); + // The selection starts on the snapshot's current value. + let selected = selected_line(&app, &selector); + assert!(selected.contains("(2) b")); + assert!(!selected.contains('❯')); + + let buffer = render_buffer(&app, &selector, 60); + let builder = app.read(TuiUiBuilder::from_app); + let selected = &buffer[(0, 4)]; + assert_eq!( + selected.fg, + builder + .option_selector_selected_style() + .fg + .expect("selected option has a foreground") + ); + assert!(selected.modifier.contains(Modifier::BOLD)); + }); +} + +#[test] +fn up_and_down_move_the_selection_and_enter_confirms_it() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(selected_line(&app, &selector).contains('b')); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(selected_line(&app, &selector).contains('a')); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "b".to_string() + }], + ); + }); +} + +#[test] +fn digits_confirm_the_corresponding_visible_row() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(3), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "c".to_string() + }], + ); + }); +} + +#[test] +fn digits_are_viewport_relative_in_scrolled_lists() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let ids: Vec = (0..12).map(|i| format!("row-{i}")).collect(); + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + set_page(&mut app, &selector, snapshot(&id_refs, Some("row-0"))); + // Scroll two rows down; digit 1 now confirms the third row, + // and the clipped top renders an overflow marker. + act(&mut app, &selector, TuiOptionSelectorAction::ScrollBy(2)); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.trim() == "↑")); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(1), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "row-2".to_string() + }], + ); + }); +} + +#[test] +fn navigation_scrolls_to_keep_the_selection_visible() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let ids: Vec = (0..12).map(|i| format!("row-{i}")).collect(); + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + set_page(&mut app, &selector, snapshot(&id_refs, Some("row-0"))); + for _ in 0..9 { + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + } + // The selection scrolled beyond the first viewport. + assert!(selected_line(&app, &selector).contains("row-9")); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.trim() == "↑")); + }); +} + +#[test] +fn list_viewport_shows_six_rows_and_arrow_overflow_markers() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")), + ); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(6) f"))); + assert!(!lines.iter().any(|line| line.contains("(7) g"))); + assert!(lines.iter().any(|line| line.trim() == "↓")); + + act(&mut app, &selector, TuiOptionSelectorAction::ScrollBy(2)); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.trim() == "↑")); + assert!(lines.iter().any(|line| line.contains("(1) c"))); + }); +} + +#[test] +fn disabled_rows_are_selectable_but_not_confirmable() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot_of( + vec![ + row("a"), + disabled_row("b", "Disabled by your administrator"), + ], + Some("a"), + ), + ); + // The disabled row can be selected and shows its reason + // … + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + let line = selected_line(&app, &selector); + assert!(line.contains('b')); + assert!(line.contains("Disabled by your administrator")); + // … but neither Enter, its digit, nor a click confirms it. + confirm(&mut app, &selector); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(2), + ); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(primary_events(&events).is_empty()); + }); +} + +#[test] +fn loading_and_empty_states_render_non_selectable_status_rows() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut loading = snapshot(&["Skip (advanced)"], None); + loading.status = OptionSourceStatus::Loading; + set_page(&mut app, &selector, loading); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Loading…"))); + + let mut empty = snapshot_of(Vec::new(), None); + empty.status = OptionSourceStatus::Empty { + message: "No harnesses available".to_string(), + }; + set_page(&mut app, &selector, empty); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("No harnesses available"))); + // Nothing is confirmable in an empty list. + confirm(&mut app, &selector); + assert!(primary_events(&events).is_empty()); + }); +} + +#[test] +fn failed_state_offers_a_retry_row_that_emits_retry_requested() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut failed = snapshot(&["Skip (advanced)"], None); + failed.status = OptionSourceStatus::Failed { + message: "Unable to load secrets".to_string(), + }; + set_page(&mut app, &selector, failed); + let lines = render_lines(&app, &selector, 60); + assert!(lines + .iter() + .any(|line| line.contains("Unable to load secrets"))); + assert!(lines.iter().any(|line| line.contains("Retry"))); + // The Retry row is reachable by keyboard. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::RetryRequested], + ); + }); +} + +#[test] +fn custom_text_editor_trims_validates_and_submits() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + // The footer renders and confirming it opens the one-line editor. + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Custom host…"))); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + + // Whitespace-only input stays editable with a concise error + //. + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar(' ')), + ); + confirm(&mut app, &selector); + assert!(primary_events(&events).is_empty()); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Enter a value to continue."))); + + // Valid input is trimmed and submitted. + for c in "my-host ".chars() { + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar(c)), + ); + } + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Command(TuiEditorCommand::Backspace), + ); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::CustomTextSubmitted { + value: "my-host".to_string() + }], + ); + assert!(!custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + let line = selected_line(&app, &selector); + assert!(line.contains("my-host")); + assert!(!line.contains("Custom host…")); + + // Editing the custom option again starts from the submitted value. + confirm(&mut app, &selector); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Custom host…: my-host"))); + }); +} + +#[test] +fn back_cancels_custom_text_editing_before_leaving_the_page() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + // The first Back unwinds editing and is consumed; the next one isn't. + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(consumed); + assert!(!custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(!consumed); + }); +} + +/// Arrow navigation leaves list state untouched while custom text owns focus. +#[test] +fn arrows_do_not_navigate_the_list_while_editing_custom_text() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + let custom_text_index = 8; + + for action in [ + TuiOptionSelectorAction::MoveUp, + TuiOptionSelectorAction::MoveDown, + ] { + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectItem(custom_text_index), + ); + let before = selector.read(&app, |selector, _| { + ( + selector.interaction.selection.selected_index(), + selector.interaction.scroll_offset, + ) + }); + + act(&mut app, &selector, action); + + let after = selector.read(&app, |selector, _| { + ( + selector.interaction.selection.selected_index(), + selector.interaction.scroll_offset, + ) + }); + assert_eq!(after, before); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + } + }); +} + +#[test] +fn create_new_auth_secret_footer_is_ignored() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["Skip (advanced)"], None); + with_footer.footer = Some(OptionFooter::CreateNewAuthSecret); + set_page(&mut app, &selector, with_footer); + // Resource creation is out of scope in the TUI: the + // footer contributes no navigable item. + assert!(render_lines(&app, &selector, 60) + .iter() + .all(|line| !line.contains("New API key"))); + }); +} + +/// Page replacement invalidates any host-cached selector measurement. +#[test] +fn set_page_invalidates_layout() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + + set_page(&mut app, &selector, snapshot(&["a"], Some("a"))); + + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + }); +} +#[test] +fn layout_invalidated_is_emitted_only_when_overflow_markers_toggle() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")), + ); + events.borrow_mut().clear(); + + // Moves within the viewport do not scroll, so nothing is emitted. + for _ in 0..5 { + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + } + assert!(!events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + + // Scrolling past the viewport reveals the `↑` marker: one event. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| **event == TuiOptionSelectorEvent::LayoutInvalidated) + .count(), + 1, + ); + }); +} + +#[test] +fn layout_invalidated_is_emitted_when_the_custom_text_error_row_toggles() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + events.borrow_mut().clear(); + + // An empty submit adds the validation-error row. + confirm(&mut app, &selector); + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + events.borrow_mut().clear(); + + // Typing clears the error row. + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar('x')), + ); + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + }); +} + +#[test] +fn snapshot_refresh_preserves_the_selected_row() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + // The selected row survives a catalog refresh that reorders rows + //. + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot(snapshot(&["c", "a"], Some("a")), ctx); + }); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "c".to_string() + }], + ); + }); +} + +#[test] +fn snapshot_refresh_falls_back_to_the_selected_value_when_the_selection_vanishes() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + // "b" disappears from the catalog; the selection falls back to the + // snapshot's current value rather than silently confirming anything + //. + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot(snapshot(&["a", "x"], Some("a")), ctx); + }); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "a".to_string() + }], + ); + }); +} + +/// Dispatches `event` to the selector's freshly rendered and painted element +/// tree, returning whether it was handled. +fn dispatch(app: &App, selector: &ViewHandle, event: &TuiEvent) -> bool { + app.read(|app| { + let mut rendered_views = EntityIdMap::default(); + let (mut element, area) = laid_out_element(selector, &mut rendered_views, 60, app); + // Paint so the tree retains geometry and the scene supports hit + // testing during dispatch. + let scene = { + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + { + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + } + Rc::new(paint_ctx.scene.clone()) + }; + let mut event_ctx = TuiEventContext::new(scene, &mut rendered_views); + event_ctx.set_origin_view(Some(EntityId::new())); + element.dispatch_event(event, &mut event_ctx, app) + }) +} + +/// Builds an unmodified key-down event for `key`. +fn key_down(key: &str) -> TuiEvent { + TuiEvent::KeyDown { + keystroke: Keystroke { + key: key.to_string(), + ..Default::default() + }, + chars: String::new(), + details: Default::default(), + is_composing: false, + } +} + +#[test] +fn enter_and_numpad_enter_are_consumed_by_the_selector_element() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a"], Some("a"))); + for key in ["enter", "numpadenter"] { + assert!(dispatch(&app, &selector, &key_down(key)), "{key}"); + } + }); +} + +#[test] +fn paste_is_consumed_only_while_editing_custom_text() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + let paste = TuiEvent::Paste { + text: "my-host\nsecond line".to_string(), + }; + // Ignored while the list (not the editor) is active. + assert!(!dispatch(&app, &selector, &paste)); + + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + // The editor consumes the paste (only the first line's printable + // characters are inserted; the editor is single-line). + assert!(dispatch(&app, &selector, &paste)); + // The shared editor consumes the paste event, but its single-line + // policy inserts nothing when the first line is empty. + let control_only = TuiEvent::Paste { + text: "\nsecond line".to_string(), + }; + assert!(dispatch(&app, &selector, &control_only)); + }); +} + +#[test] +fn badges_render_next_to_their_rows() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let rows = vec![ + OptionRow { + badge: Some(OptionBadge::Default), + ..row("team-host") + }, + OptionRow { + badge: Some(OptionBadge::Connected), + ..row("worker-1") + }, + OptionRow { + badge: Some(OptionBadge::Recent), + ..row("old-host") + }, + ]; + set_page(&mut app, &selector, snapshot_of(rows, Some("team-host"))); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(default)"))); + assert!(lines.iter().any(|line| line.contains("(connected)"))); + assert!(lines.iter().any(|line| line.contains("(recent)"))); + }); +} diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 93177241821..ce8ee0882d7 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -211,6 +211,15 @@ impl TuiUiBuilder { TuiStyle::default().fg(cell_color(self.warping_base_fill())) } + /// Bold magenta text for a selected option-selector row. + pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { + TuiStyle::default() + .fg(cell_color(ThemeFill::from( + self.warp_theme.terminal_colors().normal.magenta, + ))) + .add_modifier(Modifier::BOLD) + } + /// Collapsible-header style while the pointer hovers it. fn hovered_header_style(&self) -> TuiStyle { self.primary_text_style().add_modifier(Modifier::BOLD) diff --git a/crates/warpui_core/src/runtime/event_conversion.rs b/crates/warpui_core/src/runtime/event_conversion.rs index ad90c0698e8..878a5f11e28 100644 --- a/crates/warpui_core/src/runtime/event_conversion.rs +++ b/crates/warpui_core/src/runtime/event_conversion.rs @@ -140,7 +140,7 @@ fn key_name(code: KeyCode, modifiers: KeyModifiers) -> Option { KeyCode::End => Some("end".to_owned()), KeyCode::PageUp => Some("pageup".to_owned()), KeyCode::PageDown => Some("pagedown".to_owned()), - KeyCode::Tab | KeyCode::BackTab => Some("\t".to_owned()), + KeyCode::Tab | KeyCode::BackTab => Some("tab".to_owned()), KeyCode::Delete => Some("delete".to_owned()), KeyCode::Insert => Some("insert".to_owned()), KeyCode::Esc => Some("escape".to_owned()), diff --git a/crates/warpui_core/src/runtime/event_conversion_tests.rs b/crates/warpui_core/src/runtime/event_conversion_tests.rs index ed36429eb95..4c673430f01 100644 --- a/crates/warpui_core/src/runtime/event_conversion_tests.rs +++ b/crates/warpui_core/src/runtime/event_conversion_tests.rs @@ -63,6 +63,14 @@ fn arrow_keys_map_to_direction_names() { assert_eq!(keystroke(KeyCode::Down, KeyModifiers::empty()).key, "down"); } +#[test] +fn tab_maps_to_the_canonical_keybinding_name() { + assert_eq!(keystroke(KeyCode::Tab, KeyModifiers::empty()).key, "tab"); + let back_tab = keystroke(KeyCode::BackTab, KeyModifiers::SHIFT); + assert_eq!(back_tab.key, "tab"); + assert!(back_tab.shift); +} + #[test] fn ctrl_modifier_is_carried_into_keystroke() { let keystroke = keystroke(KeyCode::Char('c'), KeyModifiers::CONTROL); diff --git a/specs/code-1822-tui-option-selector/TECH.md b/specs/code-1822-tui-option-selector/TECH.md new file mode 100644 index 00000000000..7d322daefc7 --- /dev/null +++ b/specs/code-1822-tui-option-selector/TECH.md @@ -0,0 +1,167 @@ +# TECH: Reusable TUI option selector over shared option snapshots + +## Context + +This slice builds on the frontend-neutral orchestration option snapshots +(base commit `d6da3b23`; see `specs/code-1822-option-snapshots/TECH.md` for the data +contract). At that base, `app/src/tui_export.rs` re-exports `OptionSnapshot`, +`OptionRow`, `OptionBadge`, `OptionSourceStatus`, and `OptionFooter`, but nothing in +`crates/warp_tui` renders them: the TUI has no single-select list primitive. + +This PR adds that primitive — `TuiOptionSelector` — on top of the generic +`TuiEditorView::single_line` supplied by the preceding stack branch. The next slice +(the TUI orchestration permission/configuration card) embeds the selector to render +its per-field configuration pages; the same primitive is intended for future +AskUserQuestion and permission prompts, which is why it is snapshot-driven and knows +nothing about orchestration edit state. + +## Proposed changes + +### `crates/warp_tui/src/option_selector.rs` + +A `TuiView` + `TypedActionView` (`TuiOptionSelector`) rendering one page: + +- `OptionSelectorPage` owns the full renderable page configuration: a short field + label, sequence position, full prompt, option snapshot, and search opt-in. The + header renders the field label on the left, right-aligned `← n of m →` navigation + state (boundary arrows muted), a blank separator row, and the bold prompt. +- Option list rendered from an `OptionSnapshot` (`warp::tui_export`): up to + `MAX_VISIBLE_OPTION_ROWS` (6) rows visible at once with `↑` / `↓` overflow markers. + Rows show a viewport-relative `(1)`-style number, the label, an optional badge + suffix (`(default)` / `(recent)` / `(connected)`), and — for disabled rows — the + `disabled_reason`. The selected row is bold magenta without an extra marker or + background. +- Optional search: `set_page(page, ctx)` lazily creates a search editor only when + `page.searchable` is true, then renders its pinned `Search:` row between the prompt + and scroll viewport. Search is not a `SelectorItem`; the list starts focused on + `selected_id` (or its first item) so digits remain immediate shortcuts. Up from the + top item focuses search, Down from search returns to the first filtered item, + Up from search selects the last filtered item, and Down from the last item + focuses search. Typing a non-digit from the list focuses and seeds search. + Filtering is case-insensitive substring matching over row labels; an empty + result renders `No matches`. The pinned search editor remains visible while + rows scroll. +- Status rows appended after the list per `OptionSourceStatus`: `Loading…` (dim), + `Failed { message }` (error style, plus a selectable `↻ Retry` virtual row that + emits `RetryRequested`), and `Empty { message }` (dim). Status rows are not + navigable. +- Footer: `OptionFooter::CustomText { label }` appends a selectable entry that, when + confirmed, embeds a one-line `TuiEditorView` in place of the entry. + Submitting a value replaces the generic footer label with that value, keeps the + footer selected, and pre-fills the value when it is edited again. A selected id + not present in the fixed rows restores this custom value when a page is rebuilt. + `OptionFooter::CreateNewAuthSecret` is ignored (resource creation is out of scope + in the TUI). + +State/API surface for the embedding host: + +- `new(ctx)` then `set_page(page, ctx)` — atomically replaces the page configuration, + resets the search query and selection to the snapshot's `selected_id` (falling back + to the first item), and discards any in-progress custom-text editing. +- `refresh_snapshot(snapshot, ctx)` — in-place catalog refresh preserving the + active selection when it still exists, else falling back to `selected_id`. +- `confirm_selected(ctx)` — the shared confirmation core used by the selector's + Enter/Numpad Enter action and by hosts that need to combine confirmation with + another interaction. Enabled rows emit `TuiOptionSelectorEvent::Confirmed { id }`; + disabled rows stay selected so their reason remains visible; while the custom-text + editor is active it validates (trimmed, non-empty — else an inline + "Enter a value to continue." error) and emits `CustomTextSubmitted { value }`. + While search owns focus, confirmation selects the first enabled filtered row, + skipping disabled matches. +- `handle_back(ctx) -> bool` — the host's Escape path: cancels active custom-text + editing and reports whether the key was consumed, so the host only leaves the page + when the selector had nothing to unwind. +- `TuiOptionSelectorEvent::LayoutInvalidated` — tells hosts with separately cached + measurements to remeasure after scrolling changes overflow markers, a catalog + refresh changes the row set, search changes the rendered rows, or the custom-text + validation row toggles. `ctx.notify()` still refreshes the child itself; the event + crosses the view boundary to invalidate the ancestor's cache, matching + `TuiAIBlockEvent::LayoutInvalidated` prior art. + +Focus and element-level input (via the private `SelectorInputElement` wrapper, active only +while the selector is rendered as the blocking interaction): +- The list and embedded editors are real focus zones. `set_page` focuses the selector; + boundary arrows move focus between the selector and search editor. +- Enter and Numpad Enter dispatch `ConfirmSelected` from the selector element, so + row, search-result, retry, and custom-text confirmation stay reusable host-agnostic + behavior. +- Up/Down move the selection, scrolling to keep it visible. Search behaves as + the final item in the cycle: Up from the first row focuses search, Up from + search selects the last row, Down from the last row focuses search, and Down + from search selects the first row. +- Digits 1-6 confirm the corresponding visible row — viewport-relative, so digit 1 is + always the top visible row after scrolling. While search owns focus, digits are + editor input instead. +- Row clicks select the row and confirm it when enabled via per-item persistent + `MouseStateHandle`s (owned by the view, per the mouse-state ownership rule). +- Wheel scrolling moves the viewport without moving the selection. +- Search and custom text use the shared `TuiEditorView`; printable characters, cursor, + selection, single-line paste, horizontal/word/line navigation, undo/redo, and + kill/yank come from the shared editor layer. Escape remains host policy with a + selector fallback: it clears a non-empty search first, cancels custom editing, or + leaves the page. +- An element-level Escape fallback emits `Dismissed` for hosts without their own + Escape binding; the embedding card's keymap normally consumes Escape first. + +Selection reuses `InlineMenuSelection` and `keep_selected_visible` from +`crates/warp_tui/src/inline_menu.rs`. + +### Generic editor dependency + +The preceding stack branch adds `TuiEditorView::single_line` and the shared editor +interaction layer documented in +`specs/code-1822-tui-generic-editor-view/TECH.md`. The generic field owns focus, +single-line insertion/replacement policy, selection, kill/yank state, model-backed +editing, one-row cursor following, and stale-viewport clamping +(`crates/warp_tui/src/editor_view.rs` (37-202); +`crates/warp_tui/src/editor_interaction.rs` (15-559)). + +This selector owns the surrounding search/custom-text labels, validation, +filtering, Enter/Escape behavior, and vertical navigation. The generic editor does +not register Up/Down or Shift-Up/Shift-Down bindings, so those keys propagate to +the selector's list/focus cycle instead of being consumed by the embedded field. + +### `crates/warp_tui/src/tui_builder.rs` + +Adds `option_selector_selected_style()`: bold, full-strength magenta text for the +selected option. The card slice adds its orchestration surface background and +remaining recipes (title glyph, selected metadata values, identity palette) itself. + +### `crates/warp_tui/src/lib.rs` + +Declares `mod option_selector` with a narrowly-scoped, commented +`#[allow(dead_code)]` on the module declaration, since nothing consumes the selector +until the card slice; that slice removes the allow. + +## Testing and validation + +- `crates/warp_tui/src/option_selector_tests.rs` covers: field label/position/prompt + rendering and initial selection from `selected_id`; Up/Down + Enter confirmation; + selector-element handling for Enter and Numpad Enter; + digit confirmation, including viewport-relative digits in scrolled lists; scrolling + to keep the selection visible with overflow markers; disabled rows being + selectable but not confirmable via Enter, digit, or click; Loading/Empty status + rows being non-selectable; the Failed state's keyboard-reachable Retry row; + custom-text trim/validate/submit, submitted-value rendering/re-editing/restoration, + and Backspace; Back cancelling custom-text editing before leaving the page; the + ignored `CreateNewAuthSecret` footer; snapshot-refresh + selection preservation and selected-value fallback; lazy search-editor creation; + `LayoutInvalidated` emission when overflow markers or custom-text validation change + rendered height; badge rendering; + and paste falling through from the list while the custom-text editor consumes it + using only the first line; + searchable pages starting on the selected row; boundary focus handoff; numeric + shortcuts remaining active from the list; digit-containing queries; filtering, + no-match rendering, Enter confirmation from focused search, and clear-on-Escape. +- Tests host the selector under `test_fixtures::TestHostView` in a headless TUI + window and render to lines (see the `tui-testing` conventions). +- Commands: `./script/format`, + `cargo nextest run -p warp_tui -E 'test(option_selector)'`, + `cargo nextest run -p warp_tui`, and + `cargo clippy -p warp_tui --tests -- -D warnings`. + +## Follow-ups + +The TUI orchestration card slice embeds `TuiOptionSelector` for its configuration +pages (host, environment, harness, model, API key, location), adds the remaining +orchestration theming recipes, and removes the module-level `allow(dead_code)`. From 3f987b6846d47580454d6101ab2ba95057450d78 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 13:51:17 -0400 Subject: [PATCH 05/40] expose frontend test seams for orchestration confirmation flows Co-Authored-By: Oz --- app/Cargo.toml | 7 +- app/src/ai/blocklist/action_model.rs | 25 ++++- app/src/ai/blocklist/mod.rs | 3 + app/src/ai/blocklist/telemetry.rs | 44 ++++----- app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 6 +- app/src/tui_export.rs | 114 +++++++++++++++++++++- app/src/tui_export_tests.rs | 28 ++++++ app/src/user_config/mod.rs | 4 +- app/src/workspaces/user_workspaces.rs | 2 +- 15 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 app/src/tui_export_tests.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index 7ddcf236938..ca354e536e2 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -845,7 +845,12 @@ voice_input = ["dep:voice_input"] system_theme = [] tab_close_button_on_left = [] team_features_override = [] -test-util = ["cloud_object_client/test-util", "warp_server_auth/test-util"] +test-util = [ + "cloud_object_client/test-util", + "warp_server_auth/test-util", + "http_client/test-util", + "warp_core/test-util", +] team_workflows = ["team_features_override"] terminal_lifecycle_recovery = [] toggle_bootstrap_block = [] diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index d0935afbef9..90362ee6cf1 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -955,6 +955,27 @@ impl BlocklistAIActionModel { }); } + /// Synchronously enqueues a pending action, bypassing async + /// preprocessing, so tests can deterministically drive an action into + /// `Blocked` status and exercise confirmation flows. + #[cfg(any(test, feature = "test-util"))] + pub fn queue_pending_action_for_test( + &mut self, + conversation_id: AIConversationId, + action: AIAgentAction, + ctx: &mut ModelContext, + ) { + let action_id = action.id.clone(); + self.pending_actions + .entry(conversation_id) + .or_default() + .push_back(action); + ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id.clone())); + ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation( + action_id, + )); + } + fn handle_preprocess_actions_results( &mut self, conversation_id: AIConversationId, @@ -1033,7 +1054,9 @@ impl BlocklistAIActionModel { self.handle_action_result(conversation_id, Arc::new(action_result), None, ctx); } - pub(super) fn cancel_action_with_id( + /// Cancels a running or pending action by id with the given reason. + /// Public because both frontends' permission cards route Reject here. + pub fn cancel_action_with_id( &mut self, conversation_id: AIConversationId, action_id: &AIAgentActionId, diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 525b2744374..fe29f4b09b2 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -55,6 +55,9 @@ pub(crate) use action_model::{ pub use action_model::{ BlocklistAIActionEvent, BlocklistAIActionModel, ShellCommandExecutor, ShellCommandExecutorEvent, }; +// Consumed by `tui_export` for the `warp_tui` frontend. +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use action_model::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot}; #[cfg(any(test, feature = "integration_tests"))] pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index 28452dfc955..1a8428326bf 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -7,7 +7,7 @@ use crate::ai::agent::conversation::AIConversationId; #[derive(Debug, EnumDiscriminants)] #[strum_discriminants(derive(EnumIter))] -pub(crate) enum BlocklistOrchestrationTelemetryEvent { +pub enum BlocklistOrchestrationTelemetryEvent { TeamAgentCommunicationFailed(TeamAgentCommunicationFailedEvent), PlanConfigApprovalToggled(PlanConfigApprovalToggledEvent), RunAgentsCardDecision(RunAgentsCardDecisionEvent), @@ -19,7 +19,7 @@ pub(crate) enum BlocklistOrchestrationTelemetryEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentCommunicationKind { +pub enum TeamAgentCommunicationKind { Message, LifecycleEvent, } @@ -27,14 +27,14 @@ pub(crate) enum TeamAgentCommunicationKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentCommunicationTransport { +pub enum TeamAgentCommunicationTransport { Local, ServerApi, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentOrchestrationVersion { +pub enum TeamAgentOrchestrationVersion { V1, V2, } @@ -42,7 +42,7 @@ pub(crate) enum TeamAgentOrchestrationVersion { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentCommunicationFailureReason { +pub enum TeamAgentCommunicationFailureReason { InvalidLifecycleEventType, MissingSourceConversation, MissingSourceIdentifier, @@ -52,7 +52,7 @@ pub(crate) enum TeamAgentCommunicationFailureReason { } #[derive(Debug, Serialize)] -pub(crate) struct TeamAgentCommunicationFailedEvent { +pub struct TeamAgentCommunicationFailedEvent { pub communication_kind: TeamAgentCommunicationKind, pub transport: TeamAgentCommunicationTransport, pub orchestration_version: TeamAgentOrchestrationVersion, @@ -72,7 +72,7 @@ pub(crate) struct TeamAgentCommunicationFailedEvent { /// `Use orchestration` toggle. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationApprovalStatus { +pub enum OrchestrationApprovalStatus { Approved, Disapproved, } @@ -82,13 +82,13 @@ pub(crate) enum OrchestrationApprovalStatus { /// environment id or worker host. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationExecutionModeKind { +pub enum OrchestrationExecutionModeKind { Local, Remote, } impl OrchestrationExecutionModeKind { - pub(crate) fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { + pub fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { if mode.is_remote() { Self::Remote } else { @@ -102,7 +102,7 @@ impl OrchestrationExecutionModeKind { /// low-cardinality. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationHarnessKind { +pub enum OrchestrationHarnessKind { Oz, ClaudeCode, Codex, @@ -112,7 +112,7 @@ pub(crate) enum OrchestrationHarnessKind { } impl OrchestrationHarnessKind { - pub(crate) fn from_str(harness_type: &str) -> Self { + pub fn from_str(harness_type: &str) -> Self { match harness_type { "oz" | "" => Self::Oz, "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, @@ -128,7 +128,7 @@ impl OrchestrationHarnessKind { /// the dispatched orchestration request and either the original tool /// call or an active approved config. Match the server's equivalent /// field-name constants so the two telemetry streams can be joined. -pub(crate) mod orchestration_modified_field { +pub mod orchestration_modified_field { pub const MODEL_ID: &str = "model_id"; pub const HARNESS: &str = "harness"; pub const EXECUTION_MODE: &str = "execution_mode"; @@ -138,7 +138,7 @@ pub(crate) mod orchestration_modified_field { } #[derive(Debug, Serialize)] -pub(crate) struct PlanConfigApprovalToggledEvent { +pub struct PlanConfigApprovalToggledEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -156,14 +156,14 @@ pub(crate) struct PlanConfigApprovalToggledEvent { /// Decision a user took on the run_agents confirmation card. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum RunAgentsCardDecision { +pub enum RunAgentsCardDecision { Accept, AcceptWithoutOrchestration, Reject, } #[derive(Debug, Serialize)] -pub(crate) struct RunAgentsCardDecisionEvent { +pub struct RunAgentsCardDecisionEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -192,7 +192,7 @@ pub(crate) struct RunAgentsCardDecisionEvent { /// [`PlanConfigApprovalToggledEvent`] (the user's approval toggle). #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationEntrySource { +pub enum OrchestrationEntrySource { /// `/orchestrate` slash-command mode on a user query. SlashCommandOrchestrate, /// `run_agents` confirmation card was shown (not auto-launched). @@ -200,7 +200,7 @@ pub(crate) enum OrchestrationEntrySource { } #[derive(Debug, Serialize)] -pub(crate) struct OrchestrationEnteredEvent { +pub struct OrchestrationEnteredEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -211,7 +211,7 @@ pub(crate) struct OrchestrationEnteredEvent { /// becomes visible to the user on a plan card. One emission per /// `OrchestrationConfigBlockView` instance. #[derive(Debug, Serialize)] -pub(crate) struct AgentProposedConfigEvent { +pub struct AgentProposedConfigEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -224,7 +224,7 @@ pub(crate) struct AgentProposedConfigEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum PillBarPillKind { +pub enum PillBarPillKind { Orchestrator, Child, } @@ -232,7 +232,7 @@ pub(crate) enum PillBarPillKind { /// Concrete user actions against an orchestration pill bar entry. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum PillBarActionKind { +pub enum PillBarActionKind { /// User clicked the pill body. See `switch_outcome` for what /// happened next. Switch, @@ -255,7 +255,7 @@ pub(crate) enum PillBarActionKind { /// action variants again. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum PillSwitchOutcome { +pub enum PillSwitchOutcome { /// Pill click navigated within the current pane. SwitchedInPlace, /// Target conversation was already owned by another visible @@ -264,7 +264,7 @@ pub(crate) enum PillSwitchOutcome { } #[derive(Debug, Serialize)] -pub(crate) struct PillBarInteractionEvent { +pub struct PillBarInteractionEvent { pub action: PillBarActionKind, pub pill_kind: PillBarPillKind, pub total_pills: usize, diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index 80abe931e21..d6b74ad3478 100644 --- a/app/src/ai/document/ai_document_model.rs +++ b/app/src/ai/document/ai_document_model.rs @@ -228,7 +228,7 @@ impl AIDocumentModel { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn new_for_test() -> Self { let (save_tx, _save_rx) = async_channel::unbounded(); Self { diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index 1bc116efc2c..70a1227098c 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,7 +115,9 @@ impl AuthManager { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn new_for_test(ctx: &mut ModelContext) -> Self { use crate::server::server_api::ServerApiProvider; diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index bef0f00bacb..2cb902d5655 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -1684,7 +1684,7 @@ impl CloudModel { .collect::>() } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self::new(None, Vec::new(), None) } diff --git a/app/src/lib.rs b/app/src/lib.rs index 10a733d79cb..c1fd2ab19ec 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -648,7 +648,9 @@ impl LaunchMode { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub(crate) fn new_for_unit_test() -> Self { LaunchMode::Test { driver: Box::new(None), diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index f3da4863500..8ff728ce47b 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,7 +449,9 @@ impl ServerApi { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1287,7 +1289,9 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn new_for_test() -> Self { let server_api = Arc::new(ServerApi::new_for_test()); let auth_client = Arc::new(AuthClientImpl::new(server_api.base_client.clone())); diff --git a/app/src/server/sync_queue.rs b/app/src/server/sync_queue.rs index b3ceac851ab..f563ad475cb 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,7 +356,9 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn mock(ctx: &mut ModelContext) -> Self { use super::server_api::ServerApiProvider; diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index a5960592194..eb0e15552a1 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -280,7 +280,7 @@ fn handle_warp_config_change( /// settings when the settings file feature flag is disabled. fn init_platform_native_preferences() -> user_preferences::Model { cfg_if::cfg_if! { - if #[cfg(test)] { + if #[cfg(any(test, feature = "test-util"))] { Box::::default() } else if #[cfg(any(target_os = "linux", target_os = "freebsd", feature = "integration_tests"))] { match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) { @@ -325,7 +325,7 @@ pub fn init_private_user_preferences() -> settings::PrivatePreferences { pub fn init_public_user_preferences() -> (user_preferences::Model, Option) { cfg_if::cfg_if! { - if #[cfg(test)] { + if #[cfg(any(test, feature = "test-util"))] { (Box::::default(), None) } else if #[cfg(target_family = "wasm")] { (Box::::default(), None) @@ -448,7 +448,7 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(test)] +#[cfg(any(test, feature = "test-util"))] pub fn init_and_register_user_preferences(ctx: &mut AppContext) { let (public_prefs, _parse_error) = init_public_user_preferences(); ctx.add_singleton_model(move |_| settings::PublicPreferences::new(public_prefs)); diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 17be62785a3..769518b64a6 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -1,9 +1,15 @@ //! Public app APIs used by the `warp_tui` frontend. +pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; +pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; +#[cfg(any(test, feature = "test-util"))] +use ai::api_keys::ApiKeyManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; +#[cfg(any(test, feature = "test-util"))] +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; pub use crate::ai::agent::api::ServerConversationToken; @@ -54,19 +60,41 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +pub use crate::ai::blocklist::telemetry::{ + orchestration_modified_field, BlocklistOrchestrationTelemetryEvent, + OrchestrationApprovalStatus, OrchestrationEnteredEvent, OrchestrationEntrySource, + OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision, + RunAgentsCardDecisionEvent, +}; pub use crate::ai::blocklist::view_util::format_credits; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::BlocklistAIPermissions; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, RequestFileEditsExecutor, ShellCommandExecutor, ShellCommandExecutorEvent, + PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, + RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, +}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::cloud_agent_settings::CloudAgentSettings; +pub use crate::ai::connected_self_hosted_workers::{ + ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; #[cfg(feature = "local_fs")] pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; +pub use crate::ai::harness_availability::{ + AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, + HarnessAvailabilityModel, HarnessModelInfo, +}; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -79,20 +107,36 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::auth_manager::AuthManager; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; +#[cfg(any(test, feature = "test-util"))] +use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; +#[cfg(any(test, feature = "test-util"))] +use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +#[cfg(any(test, feature = "test-util"))] +use crate::server::server_api::ServerApiProvider; +#[cfg(any(test, feature = "test-util"))] +use crate::server::sync_queue::SyncQueue; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ @@ -152,8 +196,14 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; +#[cfg(any(test, feature = "test-util"))] +use crate::user_config::WarpConfig; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; +#[cfg(any(test, feature = "test-util"))] +use crate::workspaces::user_workspaces::UserWorkspaces; +#[cfg(any(test, feature = "test-util"))] +use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -213,3 +263,65 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } + +/// Registers the minimal singleton set needed to construct, render, and +/// accept the TUI orchestration (`RunAgents`) card against real app models: +/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the +/// auth/server/cloud-object singletons the catalog models read, and the +/// catalog + permission models the card's snapshot builders and accept-path +/// permission checks use. Intended for `warp_tui` tests (via the `test-util` +/// feature) and this crate's own unit tests. Registration order matters: +/// each model subscribes to singletons registered before it. +#[cfg(any(test, feature = "test-util"))] +pub fn register_orchestration_test_singletons(app: &mut warpui::App) { + // Settings machinery required by CloudAgentSettings/AISettings reads. + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); + app.update(init_and_register_user_preferences); + app.add_singleton_model(|_| SettingsManager::default()); + app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + // No-op secure storage backs ApiKeyManager in tests. + warpui_extras::secure_storage::register_noop("test", ctx); + }); + app.update(AISettings::register_and_subscribe_to_events); + CloudAgentSettings::register(app); + // Secure-storage-backed; LLMPreferences subscribes to it. + app.add_singleton_model(ApiKeyManager::new); + + // Auth / server / cloud-object singletons the catalog models read. + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(|ctx| { + // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), + // so back the mock with the test ServerApi's clients instead. + let (team_client, workspace_client) = { + let provider = ServerApiProvider::as_ref(ctx); + (provider.get_team_client(), provider.get_workspace_client()) + }; + UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) + }); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| crate::appearance::Appearance::mock()); + + // Catalog + permission singletons read by the card's construction, + // snapshot builders, and accept path. + app.add_singleton_model(|_| TemplatableMCPServerManager::default()); + app.add_singleton_model(LLMPreferences::new); + app.add_singleton_model(HarnessAvailabilityModel::new); + app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); + app.add_singleton_model(BlocklistAIPermissions::new); + app.add_singleton_model(|ctx| { + AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) + }); + // Plan publication during the accept path reads the document model. + app.add_singleton_model(|_| { + crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() + }); +} + +#[cfg(test)] +#[path = "tui_export_tests.rs"] +mod tests; diff --git a/app/src/tui_export_tests.rs b/app/src/tui_export_tests.rs new file mode 100644 index 00000000000..d32445a46b2 --- /dev/null +++ b/app/src/tui_export_tests.rs @@ -0,0 +1,28 @@ +use warpui::{App, SingletonEntity}; + +use super::register_orchestration_test_singletons; +use crate::ai::blocklist::BlocklistAIPermissions; +use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +use crate::ai::harness_availability::HarnessAvailabilityModel; +use crate::ai::llms::LLMPreferences; +use crate::appearance::Appearance; + +#[test] +fn orchestration_test_singletons_are_self_consistent() { + App::test((), |mut app| async move { + register_orchestration_test_singletons(&mut app); + app.update(|ctx| { + // Touch each registered accessor the orchestration card path + // reads to prove the registered set is self-consistent. + let _ = CloudAgentSettings::as_ref(ctx); + let _ = Appearance::as_ref(ctx); + let _ = LLMPreferences::as_ref(ctx); + let _ = HarnessAvailabilityModel::as_ref(ctx); + let _ = ConnectedSelfHostedWorkersModel::as_ref(ctx); + let _ = BlocklistAIPermissions::as_ref(ctx); + let _ = AIExecutionProfilesModel::as_ref(ctx); + }); + }); +} diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index b410f026290..5d22b60794c 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,7 +107,9 @@ pub struct WarpConfig { /// Additional platform-dependent functionality can be found in impl blocks /// in native.rs and wasm.rs. impl WarpConfig { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self { theme_config: WarpThemeConfig::new(), diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 0bd9292f212..2f24191f015 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -122,7 +122,7 @@ pub struct CreateTeamResponse { } impl UserWorkspaces { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn mock( team_client: Arc, workspace_client: Arc, From b4fa62eeacf5551c54862aae2311c1c5b7ea3be4 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 13:54:45 -0400 Subject: [PATCH 06/40] add TUI orchestration permission and configuration card Co-Authored-By: Oz --- crates/warp_tui/src/agent_block.rs | 130 ++- crates/warp_tui/src/agent_block_tests.rs | 192 +++- crates/warp_tui/src/agent_identity.rs | 135 +++ crates/warp_tui/src/agent_identity_tests.rs | 73 ++ crates/warp_tui/src/keybindings.rs | 5 + crates/warp_tui/src/keybindings_tests.rs | 13 + crates/warp_tui/src/lib.rs | 5 +- crates/warp_tui/src/run_agents_card_view.rs | 1022 +++++++++++++++++ .../src/run_agents_card_view_tests.rs | 471 ++++++++ crates/warp_tui/src/terminal_session_view.rs | 74 +- crates/warp_tui/src/test_fixtures.rs | 40 +- crates/warp_tui/src/transcript_view.rs | 19 +- .../tui_block_list_viewport_source_tests.rs | 1 + crates/warp_tui/src/tui_builder.rs | 22 + specs/CODE-1822/PRODUCT.md | 118 ++ specs/CODE-1822/TECH.md | 91 ++ 16 files changed, 2382 insertions(+), 29 deletions(-) create mode 100644 crates/warp_tui/src/agent_identity.rs create mode 100644 crates/warp_tui/src/agent_identity_tests.rs create mode 100644 crates/warp_tui/src/run_agents_card_view.rs create mode 100644 crates/warp_tui/src/run_agents_card_view_tests.rs create mode 100644 specs/CODE-1822/PRODUCT.md create mode 100644 specs/CODE-1822/TECH.md diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index fda9ffcd2bb..c2411375564 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -15,10 +15,11 @@ use itertools::Itertools; use markdown_parser::{FormattedTable, FormattedText}; use parking_lot::FairMutex; use warp::tui_export::{ - AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchangeId, AIAgentOutputMessageType, - AIAgentText, AIAgentTextSection, AIAgentTodo, AIBlockModel, AIConversationId, BlockId, - BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel, MessageId, ModelEvent, - ModelEventDispatcher, SummarizationType, TerminalModel, TodoOperation, TodoStatus, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchangeId, + AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIBlockModel, + AIConversationId, BlockId, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIHistoryModel, CancellationReason, MessageId, ModelEvent, ModelEventDispatcher, + SummarizationType, TerminalModel, TodoOperation, TodoStatus, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -38,6 +39,7 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; +use crate::run_agents_card_view::{TuiRunAgentsCardView, TuiRunAgentsCardViewEvent}; use crate::transcript_view::BLOCK_TOP_PADDING_ROWS; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::TuiCLISubagentView; @@ -158,6 +160,7 @@ impl CollapsibleSectionStates { enum TuiToolCallView { FileEdits(ViewHandle), ShellCommand(ViewHandle), + RunAgents(ViewHandle), } impl TuiToolCallView { @@ -166,6 +169,7 @@ impl TuiToolCallView { match self { Self::FileEdits(view) => view.id(), Self::ShellCommand(view) => view.id(), + Self::RunAgents(view) => view.id(), } } @@ -174,14 +178,25 @@ impl TuiToolCallView { match self { Self::FileEdits(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), + Self::RunAgents(view) => TuiChildView::new(view), } } } +/// The front-of-queue blocking interaction owned by an agent block: the +/// pending action awaiting a decision plus the child view that renders it. +pub(super) struct TuiBlockingChild { + pub(super) action_id: AIAgentActionId, + pub(super) view: ViewHandle, +} + /// Events emitted to the transcript that owns this rich-content block. pub(super) enum TuiAIBlockEvent { /// The block's cached canonical height must be remeasured. LayoutInvalidated, + /// A blocking child's focus/blocking state may have changed; the session + /// surface re-derives the active blocker (input replacement). + BlockingStateChanged, } /// User interactions handled by the owning agent block. @@ -317,6 +332,7 @@ impl TuiAIBlock { let output_streaming = status.is_streaming(); let mut file_edit_action_ids = Vec::new(); let mut shell_command_actions = Vec::new(); + let mut run_agents_actions = Vec::new(); if let Some(output) = status.output_to_render() { for message in &output.get().messages { if matches!(&message.message, AIAgentOutputMessageType::TodoOperation(_)) { @@ -334,6 +350,8 @@ impl TuiAIBlock { AIAgentActionType::RequestCommandOutput { .. } ) { shell_command_actions.push(action.clone()); + } else if matches!(&action.action, AIAgentActionType::RunAgents(_)) { + run_agents_actions.push(action.clone()); } } } @@ -375,6 +393,108 @@ impl TuiAIBlock { .insert(action_id, TuiToolCallView::ShellCommand(view)); ctx.notify(); } + + for action in run_agents_actions { + let AIAgentActionType::RunAgents(request) = &action.action else { + continue; + }; + // Existing card: re-sync its edit state from the latest streamed + // chunk (the request may have grown since the view was created). + if let Some(TuiToolCallView::RunAgents(view)) = self.action_views.get(&action.id) { + let request = request.clone(); + view.update(ctx, |view, ctx| view.update_request(&request, ctx)); + continue; + } + // Read the active orchestration config for plan-inherited + // resolution from the conversation, mirroring the GUI's + // `ensure_run_agents_card_view`. + let active_config = if request.plan_id.is_empty() { + None + } else { + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&self.conversation_id) + .and_then(|conversation| { + conversation + .orchestration_config_for_plan(&request.plan_id) + .map(|(config, status)| (config.clone(), status)) + }) + }; + let action_id = action.id.clone(); + let request = request.clone(); + let card_action_model = action_model.clone(); + let run_agents_executor = action_model.as_ref(ctx).run_agents_executor(ctx); + let fallback_base_model_id = self.block_model.base_model(ctx).map(|id| id.to_string()); + let is_restored = self.block_model.is_restored(); + let view = ctx.add_typed_action_tui_view(move |ctx| { + TuiRunAgentsCardView::new( + action, + &request, + active_config, + card_action_model, + run_agents_executor, + fallback_base_model_id, + is_restored, + ctx, + ) + }); + let action_id_for_events = action_id.clone(); + ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { + TuiRunAgentsCardViewEvent::RejectRequested => { + me.cancel_action(&action_id_for_events, ctx); + } + TuiRunAgentsCardViewEvent::BlockingStateChanged => { + ctx.emit(TuiAIBlockEvent::BlockingStateChanged); + me.invalidate_layout(ctx); + } + }); + self.action_views + .insert(action_id, TuiToolCallView::RunAgents(view)); + ctx.notify(); + } + } + + /// Cancels a pending or running action as manually cancelled — the + /// TUI counterpart of the GUI `AIBlock::cancel_action` reject path. + fn cancel_action(&self, action_id: &AIAgentActionId, ctx: &mut ViewContext) { + let conversation_id = self.conversation_id; + self.action_model.update(ctx, |action_model, ctx| { + action_model.cancel_action_with_id( + conversation_id, + action_id, + CancellationReason::ManuallyCancelled, + ctx, + ); + }); + } + + /// The front-of-queue blocking interaction owned by this block, if any: + /// the conversation's front pending action when it is `Blocked`, rendered + /// by one of this block's child views, and that view reports + /// `wants_focus`. Deriving from the action queue (not transcript order) + /// keeps semantics identical to the GUI's `focus_subview_if_necessary`. + pub(super) fn active_blocking_child(&self, ctx: &AppContext) -> Option { + let action_model = self.action_model.as_ref(ctx); + let pending = action_model.get_pending_action(ctx)?; + let action_id = pending.id.clone(); + if !self.renders_action(&action_id) { + return None; + } + if !matches!( + action_model.get_action_status(&action_id), + Some(AIActionStatus::Blocked) + ) { + return None; + } + match self.action_views.get(&action_id)? { + TuiToolCallView::RunAgents(view) => { + view.as_ref(ctx).wants_focus(ctx).then(|| TuiBlockingChild { + action_id, + view: view.clone(), + }) + } + // These tool views render inline and never replace the input. + TuiToolCallView::FileEdits(_) | TuiToolCallView::ShellCommand(_) => None, + } } /// Reconciles persistent code children from the latest rendered output. @@ -543,7 +663,7 @@ impl TuiAIBlock { self.last_measured_width.get() != Some(width) || self.block_model.status(app).is_streaming() || self.action_views.values().any(|view| match view { - TuiToolCallView::FileEdits(_) => false, + TuiToolCallView::FileEdits(_) | TuiToolCallView::RunAgents(_) => false, TuiToolCallView::ShellCommand(view) => { view.as_ref(app).needs_continuous_height_measurement() } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index a9d2c99814c..d0c8a070446 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -6,18 +6,20 @@ use std::time::Duration; use markdown_parser::parse_markdown; use parking_lot::FairMutex; use warp::tui_export::{ - AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, - AIAgentActionType, AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, - AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoList, - AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, AgentOutputImage, - AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, Appearance, LLMId, - MessageId, OutputStatusUpdateCallback, RequestCommandOutputResult, ServerOutputId, Shared, + register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, + AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, AIAgentExchangeId, + AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, + AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, + AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, + AgentOutputMermaidDiagram, AgentOutputTable, Appearance, BlocklistAIActionModel, LLMId, + MessageId, ModelEventDispatcher, OutputStatusUpdateCallback, RequestCommandOutputResult, + RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, ServerOutputId, Shared, SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, SingletonEntity}; +use warpui::{AddWindowOptions, ModelHandle, SingletonEntity}; use warpui_core::elements::tui::{ Color, Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPoint, TuiRect, TuiScreenPosition, @@ -35,7 +37,11 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; -use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; +use crate::run_agents_card_view::TuiRunAgentsCardAction; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_action_model_and_events, + add_test_action_model_with_surface, TestHostView, +}; use crate::tui_shell_command_view::TuiShellCommandViewAction; #[test] @@ -408,6 +414,7 @@ fn shell_command_disclosure_invalidates_agent_block_layout() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -613,6 +620,7 @@ fn code_children_reconcile_across_streamed_section_boundaries() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -1290,6 +1298,174 @@ struct FakeAgentBlockModel { status: AIBlockOutputStatus, } +/// Builds a Local/Oz `RunAgents` tool call with one child agent. +fn run_agents_action(id: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(id.to_owned()), + task_id: TaskId::new("task-1".to_owned()), + action: AIAgentActionType::RunAgents(RunAgentsRequest { + summary: "Parallelize the task.".to_owned(), + base_prompt: "base".to_owned(), + skills: Vec::new(), + model_id: "auto".to_owned(), + harness_type: "oz".to_owned(), + execution_mode: RunAgentsExecutionMode::Local, + agent_run_configs: vec![RunAgentsAgentRunConfig { + name: "researcher".to_owned(), + prompt: "research".to_owned(), + title: "Researcher".to_owned(), + }], + plan_id: String::new(), + harness_auth_secret_name: None, + }), + requires_result: true, + } +} + +/// Builds an agent block over `actions` against the caller's action model +/// and conversation, so confirmation-queue state is observable on the block. +fn test_agent_block_for_actions( + app: &mut App, + conversation_id: AIConversationId, + action_model: &ModelHandle, + model_events: &ModelHandle, + actions: Vec, +) -> ViewHandle { + let messages = actions + .into_iter() + .enumerate() + .map(|(index, action)| action_message(&format!("message-{index}"), action)) + .collect(); + let model = FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(messages), + }; + let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); + let action_model = action_model.clone(); + let model_events = model_events.clone(); + app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiAIBlock::new( + conversation_id, + AIAgentExchangeId::new(), + Rc::new(model), + action_model, + &model_events, + terminal_model, + ctx, + ) + }) + }) +} + +/// The registered RunAgents card view for `action_id`, panicking otherwise. +fn run_agents_card_view( + app: &App, + block: &ViewHandle, + action_id: &AIAgentActionId, +) -> ViewHandle { + app.read(|ctx| match block.as_ref(ctx).action_views.get(action_id) { + Some(TuiToolCallView::RunAgents(view)) => view.clone(), + Some(TuiToolCallView::FileEdits(_)) | Some(TuiToolCallView::ShellCommand(_)) | None => { + panic!("expected a registered RunAgents card view") + } + }) +} + +#[test] +fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmation() { + App::test((), |mut app| async move { + register_orchestration_test_singletons(&mut app); + let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); + let conversation_id = add_active_test_conversation(&mut app, surface_id); + let action = run_agents_action("run-agents-1"); + let block = test_agent_block_for_actions( + &mut app, + conversation_id, + &action_model, + &model_events, + vec![action.clone()], + ); + let card = run_agents_card_view(&app, &block, &action.id); + + // Still streaming / not yet queued: the card renders the fallback + // status and does not hide the input (PRODUCT 7). + assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); + + // Queued and awaiting confirmation: the card is the active blocker + // (PRODUCT 1). + action_model.update(&mut app, |model, ctx| { + model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); + }); + let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); + let blocker = blocker.expect("the blocked RunAgents card blocks the input"); + assert_eq!(blocker.action_id, action.id); + assert_eq!(blocker.view.id(), card.id()); + + // Reject through the card: the block maps it to manual cancellation + // and the blocker resolves, restoring the input (PRODUCT 5, 56). + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + card.window_id(ctx), + card.id(), + &TuiRunAgentsCardAction::Reject, + ); + }); + assert!(matches!( + app.read(|ctx| action_model.as_ref(ctx).get_action_status(&action.id)), + Some(AIActionStatus::Finished(_)) + )); + assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); + }); +} + +#[test] +fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { + App::test((), |mut app| async move { + register_orchestration_test_singletons(&mut app); + let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); + let conversation_id = add_active_test_conversation(&mut app, surface_id); + let first = run_agents_action("run-agents-1"); + let second = run_agents_action("run-agents-2"); + let block = test_agent_block_for_actions( + &mut app, + conversation_id, + &action_model, + &model_events, + vec![first.clone(), second.clone()], + ); + action_model.update(&mut app, |model, ctx| { + model.queue_pending_action_for_test(conversation_id, first.clone(), ctx); + model.queue_pending_action_for_test(conversation_id, second.clone(), ctx); + }); + + // Pending requests behind the front blocker do not affect input + // visibility (PRODUCT 4). + let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); + assert_eq!(blocker.expect("front blocker").action_id, first.id); + + // Resolving the front blocker hands off directly to the next queued + // blocking interaction (PRODUCT 6). + let first_card = run_agents_card_view(&app, &block, &first.id); + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + first_card.window_id(ctx), + first_card.id(), + &TuiRunAgentsCardAction::Reject, + ); + }); + let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); + assert_eq!(blocker.expect("handed-off blocker").action_id, second.id); + }); +} + /// Builds an agent block with fresh test identity, registered in a fresh TUI /// window and backed by a real action model. fn test_agent_block(app: &mut App, model: FakeAgentBlockModel) -> ViewHandle { diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/agent_identity.rs new file mode 100644 index 00000000000..a3d62c19762 --- /dev/null +++ b/crates/warp_tui/src/agent_identity.rs @@ -0,0 +1,135 @@ +//! Deterministic color-and-glyph agent identities for the TUI orchestration +//! card (PRODUCT 11-13): a theme-derived palette of ANSI colors crossed with +//! a curated glyph set, plus the stable hash and per-request assignment +//! policy that keep identities stable across re-renders and edits. + +use pathfinder_color::ColorU; +use warp_core::ui::theme::{Fill as ThemeFill, TerminalColors}; +use warpui_core::elements::tui::TuiStyle; +use warpui_core::elements::Fill as CoreFill; + +/// Glyphs paired with themed colors to form deterministic agent identities. +const AGENT_IDENTITY_GLYPHS: [&str; 8] = ["⟡", "⊹", "✶", "◊", "⊛", "*", "✠", "●"]; + +/// Minimum luma distance from the resolved background for a palette color +/// to count as readable. +const AGENT_IDENTITY_MIN_CONTRAST: f32 = 32.0; + +/// The identity palette must offer at least this many combinations +/// (PRODUCT 12); when background filtering would drop below it, the +/// unfiltered ANSI set is used instead. +const AGENT_IDENTITY_MIN_COMBOS: usize = 32; + +/// One deterministic color-and-glyph agent identity. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AgentIdentity { + pub(crate) glyph: &'static str, + pub(crate) style: TuiStyle, +} + +/// Builds the identity palette from the themed ANSI colors (normal + bright, +/// excluding low-contrast slots against `background`) crossed with the glyph +/// set, yielding at least [`AGENT_IDENTITY_MIN_COMBOS`] combinations. All +/// colors derive from the theme; no raw design hex. +pub(crate) fn agent_identity_palette( + colors: &TerminalColors, + background: ColorU, +) -> Vec { + let all_colors: [ColorU; 16] = [ + colors.normal.black.into(), + colors.normal.red.into(), + colors.normal.green.into(), + colors.normal.yellow.into(), + colors.normal.blue.into(), + colors.normal.magenta.into(), + colors.normal.cyan.into(), + colors.normal.white.into(), + colors.bright.black.into(), + colors.bright.red.into(), + colors.bright.green.into(), + colors.bright.yellow.into(), + colors.bright.blue.into(), + colors.bright.magenta.into(), + colors.bright.cyan.into(), + colors.bright.white.into(), + ]; + let background_luma = luma(background); + let readable: Vec = all_colors + .iter() + .copied() + .filter(|color| (luma(*color) - background_luma).abs() >= AGENT_IDENTITY_MIN_CONTRAST) + .collect(); + // Guarantee the minimum combination count even for unusual themes + // where filtering strips too many slots. + let colors = if readable.len() * AGENT_IDENTITY_GLYPHS.len() >= AGENT_IDENTITY_MIN_COMBOS { + readable + } else { + all_colors.to_vec() + }; + // Vary the color fastest so adjacent palette indices differ in color + // before repeating a glyph. + AGENT_IDENTITY_GLYPHS + .iter() + .flat_map(|glyph| { + colors.iter().map(|color| AgentIdentity { + glyph, + style: TuiStyle::default().fg(CoreFill::from(ThemeFill::Solid(*color)).into()), + }) + }) + .collect() +} + +/// Rec. 709 luma of a solid color, for background-contrast filtering. +fn luma(color: ColorU) -> f32 { + 0.2126 * f32::from(color.r) + 0.7152 * f32::from(color.g) + 0.0722 * f32::from(color.b) +} + +/// Stable FNV-1a hash of an agent name; must not vary across runs or +/// platforms so identities stay deterministic (PRODUCT 11). +pub(crate) fn stable_hash(name: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Assigns a palette index to each agent name: `stable_hash(name) % len`, +/// with a first-come linear-probe fallback so identities within one request +/// stay distinct, cycling deterministically once the palette is exhausted +/// (PRODUCT 12-13). +pub(crate) fn assign_agent_identity_indices( + names: impl IntoIterator>, + palette_len: usize, +) -> Vec { + let mut assigned: Vec = Vec::new(); + if palette_len == 0 { + return assigned; + } + let mut used = vec![false; palette_len]; + let mut used_count = 0; + for name in names { + let base = + usize::try_from(stable_hash(name.as_ref()) % palette_len as u64).unwrap_or_default(); + let index = if used_count >= palette_len { + // Palette exhausted: cycle deterministically by raw hash slot. + base + } else { + (0..palette_len) + .map(|offset| (base + offset) % palette_len) + .find(|candidate| !used[*candidate]) + .unwrap_or(base) + }; + if !used[index] { + used[index] = true; + used_count += 1; + } + assigned.push(index); + } + assigned +} + +#[cfg(test)] +#[path = "agent_identity_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/agent_identity_tests.rs b/crates/warp_tui/src/agent_identity_tests.rs new file mode 100644 index 00000000000..d49927d07c2 --- /dev/null +++ b/crates/warp_tui/src/agent_identity_tests.rs @@ -0,0 +1,73 @@ +use std::collections::HashSet; + +use warp::tui_export::{dark_theme, light_theme}; +use warp_core::ui::theme::WarpTheme; + +use super::{agent_identity_palette, assign_agent_identity_indices, stable_hash}; + +fn palette_len(theme: &WarpTheme) -> usize { + agent_identity_palette(theme.terminal_colors(), theme.background().into_solid()).len() +} + +#[test] +fn palette_offers_at_least_32_combinations_in_dark_and_light_themes() { + assert!(palette_len(&dark_theme()) >= 32); + assert!(palette_len(&light_theme()) >= 32); +} + +#[test] +fn palette_entries_are_distinct_glyph_color_pairs() { + let theme = dark_theme(); + let palette = agent_identity_palette(theme.terminal_colors(), theme.background().into_solid()); + let unique: HashSet = palette + .iter() + .map(|identity| format!("{}-{:?}", identity.glyph, identity.style.fg)) + .collect(); + assert_eq!(unique.len(), palette.len()); +} + +#[test] +fn stable_hash_is_deterministic_and_name_sensitive() { + assert_eq!(stable_hash("researcher"), stable_hash("researcher")); + assert_ne!(stable_hash("researcher"), stable_hash("reviewer")); +} + +#[test] +fn assignment_is_deterministic_across_calls() { + let names = ["alpha", "beta", "gamma", "delta"]; + assert_eq!( + assign_agent_identity_indices(names, 40), + assign_agent_identity_indices(names, 40), + ); +} + +#[test] +fn assignment_keeps_identities_distinct_within_one_request() { + // Two names that collide on a length-4 palette still get distinct slots + // via the first-come probe fallback. + let palette_len = 4; + let names: Vec = (0..palette_len).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + let unique: HashSet = indices.iter().copied().collect(); + assert_eq!(unique.len(), palette_len); +} + +#[test] +fn assignment_cycles_deterministically_beyond_palette_exhaustion() { + let palette_len = 3; + let names: Vec = (0..palette_len + 2).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + assert_eq!(indices.len(), palette_len + 2); + // The first `palette_len` assignments cover every slot; overflow entries + // reuse slots by raw hash without panicking or omitting agents. + let first: HashSet = indices[..palette_len].iter().copied().collect(); + assert_eq!(first.len(), palette_len); + for index in &indices[palette_len..] { + assert!(*index < palette_len); + } +} + +#[test] +fn assignment_handles_an_empty_palette() { + assert!(assign_agent_identity_indices(["alpha"], 0).is_empty()); +} diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 27d1cc76986..dc1c35ff19b 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -29,7 +29,9 @@ use crate::editor_interaction::{editor_binding_specs, TuiEditorBindingTarget, Tu use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; use crate::input::view::TuiInputAction; use crate::input::TuiInputView; +use crate::option_selector::TuiOptionSelector; use crate::root_view::RootTuiView; +use crate::run_agents_card_view::TuiRunAgentsCardView; use crate::terminal_session_view::TuiTerminalSessionView; use crate::transcript_view::TuiTranscriptView; @@ -55,6 +57,7 @@ pub(crate) fn init(app: &mut AppContext) { id!("TuiEditorView"), TuiEditorViewAction::Command, ); + crate::run_agents_card_view::init(app); register_binding_validators(app); } @@ -89,6 +92,8 @@ fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); } fn is_tui_owned_binding(binding: BindingLens) -> IsBindingValid { diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 9817f4d3e06..1a75ca6520d 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -1,3 +1,5 @@ +use warpui_core::App; + use super::{is_tui_owned, TUI_BINDING_GROUP}; #[test] @@ -13,3 +15,14 @@ fn tui_ownership_is_by_name_prefix_or_group() { assert!(!is_tui_owned("", Some("workspace"))); assert!(!is_tui_owned("input:clear_screen", None)); } + +/// Registering every TUI binding — including the orchestration card's +/// enter/ctrl-e/escape/ctrl-c set — must satisfy the debug-time +/// cross-surface validators, which panic on any keystroke binding matching +/// a TUI view's context that is not TUI-owned. +#[test] +fn tui_binding_registration_passes_the_cross_surface_validators() { + App::test((), |mut app| async move { + app.update(|ctx| super::init(ctx)); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 62f551e9e94..6e18041baf5 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,6 +9,7 @@ mod agent_block; mod agent_block_sections; +mod agent_identity; mod alt_screen_view; mod autoupdate; mod clipboard; @@ -31,11 +32,9 @@ mod input_suggestions_mode; mod keybindings; mod mcp_menu; mod model_menu; -// Not consumed yet: the TUI orchestration card slice embeds this selector -// and removes the allow. -#[allow(dead_code)] mod option_selector; mod resume; +mod run_agents_card_view; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs new file mode 100644 index 00000000000..689d9be37aa --- /dev/null +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -0,0 +1,1022 @@ +//! [`TuiRunAgentsCardView`]: the TUI permission and configuration card for a +//! `RunAgents` request (PRODUCT 9-28). +//! +//! The card has two interactive modes: an acceptance card summarizing the +//! request and its run-wide configuration, and a configuring mode that walks +//! a dynamic sequence of single-field pages rendered by +//! [`TuiOptionSelector`]. Accept dispatches the edited request through the +//! shared [`BlocklistAIActionModel::execute_run_agents`] path; Reject emits +//! [`TuiRunAgentsCardViewEvent::RejectRequested`], which the owning +//! [`crate::agent_block::TuiAIBlock`] maps to action cancellation. Terminal, +//! spawning, streaming, and restored states reuse the existing fallback +//! tool-call presentation and its `tool_call_labels` copy. + +use warp::tui_export::{ + accept_disabled_reason_with_auth, api_key_snapshot, empty_env_recommendation_message, + environment_snapshot, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, + persist_environment_selection, persist_host_selection, + resolve_auth_secret_selection_for_harness, resolve_default_environment_id, + resolve_default_host_slug, should_show_auth_secret_picker, AIActionStatus, AIAgentAction, + AIAgentActionId, AIAgentActionType, AuthSecretSelection, BlocklistAIActionEvent, + BlocklistAIActionModel, Harness, HarnessAvailabilityEvent, HarnessAvailabilityModel, + LLMPreferences, LLMPreferencesEvent, OptionSnapshot, OrchestrationConfig, + OrchestrationConfigState, OrchestrationConfigStatus, OrchestrationEditState, + RunAgentsExecutionMode, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsRequest, + RunAgentsSpawningSnapshot, ORCHESTRATION_WARP_WORKER_HOST, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, +}; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{self, FixedBinding}; +use warpui_core::{ + AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, +}; + +use crate::agent_block_sections::render_fallback_tool_call_section; +use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::keybindings::TUI_BINDING_GROUP; +use crate::option_selector::{OptionSelectorHeader, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::tui_builder::TuiUiBuilder; + +const RUN_AGENTS_CARD_TITLE: &str = "Can I start additional agents for this task?"; + +/// Keymap-context flag set while the acceptance card is active. +const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiRunAgentsCardAcceptance"; +/// Keymap-context flag set while a configuration page is active. +const CONFIGURING_CONTEXT_FLAG: &str = "TuiRunAgentsCardConfiguring"; + +/// Row ids emitted by `location_snapshot`. +const LOCATION_CLOUD_ID: &str = "cloud"; + +/// Registers the card's keybindings (PRODUCT 16, 26-28). Called once at TUI +/// startup from `keybindings::init`. All bindings are fixed and scoped to +/// the card's keymap context, so they only fire while a card is focused. +pub(crate) fn init(app: &mut AppContext) { + let acceptance = || id!(TuiRunAgentsCardView::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); + let configuring = || id!(TuiRunAgentsCardView::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); + app.register_fixed_bindings([ + FixedBinding::new("enter", TuiRunAgentsCardAction::Accept, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("numpadenter", TuiRunAgentsCardAction::Accept, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("ctrl-e", TuiRunAgentsCardAction::Configure, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "enter", + TuiRunAgentsCardAction::ConfirmSelection, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "numpadenter", + TuiRunAgentsCardAction::ConfirmSelection, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-c", + TuiRunAgentsCardAction::Reject, + id!(TuiRunAgentsCardView::ui_name()), + ) + .with_group(TUI_BINDING_GROUP), + ]); +} + +/// Builds the dispatched request from the card fields and the edited +/// run-wide state, exactly as the GUI's `RunAgentsEditState::to_request` +/// does (auth via `auth_secret_name()`, `computer_use_enabled` preserved +/// through the cloned execution mode). +fn build_request(fields: &RunAgentsRequest, state: &OrchestrationConfigState) -> RunAgentsRequest { + RunAgentsRequest { + summary: fields.summary.clone(), + base_prompt: fields.base_prompt.clone(), + skills: fields.skills.clone(), + model_id: state.model_id.clone(), + harness_type: state.harness_type.clone(), + execution_mode: state.execution_mode.clone(), + agent_run_configs: fields.agent_run_configs.clone(), + plan_id: fields.plan_id.clone(), + harness_auth_secret_name: state.auth_secret_name().map(str::to_string), + } +} + +/// One single-field configuration page (PRODUCT 18-19). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConfigPage { + Location, + Harness, + ApiKey, + Host, + Environment, + Model, +} + +impl ConfigPage { + /// The page's header title. + fn title(self) -> &'static str { + match self { + Self::Location => "Location", + Self::Harness => "Harness", + Self::ApiKey => "API key", + Self::Host => "Host", + Self::Environment => "Environment", + Self::Model => "Model", + } + } + + /// The page's single question (PRODUCT 18). + fn question(self) -> &'static str { + match self { + Self::Location => "Where should the agents run?", + Self::Harness => "Which harness should run the agents?", + Self::ApiKey => "Which API key should the agents use?", + Self::Host => "Which host should run the agents?", + Self::Environment => "Which environment should the agents use?", + Self::Model => "Which model should the agents use?", + } + } +} + +/// Whether the card shows the acceptance summary or a configuration page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CardMode { + Acceptance, + Configuring { page: ConfigPage }, +} + +/// Events emitted to the owning agent block. +#[derive(Clone, Debug)] +pub(crate) enum TuiRunAgentsCardViewEvent { + /// The user rejected the request; the block cancels the action. + RejectRequested, + /// The card's blocking/focus state may have changed; ancestors re-derive + /// the active blocker and re-measure the card. + BlockingStateChanged, +} + +/// Typed actions bound to the card's keybindings. +#[derive(Clone, Debug)] +pub(crate) enum TuiRunAgentsCardAction { + Accept, + Configure, + ConfirmSelection, + Back, + Reject, +} + +/// The TUI `RunAgents` confirmation card view. See the module docs. +pub(crate) struct TuiRunAgentsCardView { + action_id: AIAgentActionId, + /// The latest streamed tool call, kept in sync by + /// [`Self::update_request`]; terminal/streaming states render from it + /// through the shared fallback tool-call presentation. + action: AIAgentAction, + orchestration_edit_state: OrchestrationEditState, + /// Card fields carried through editing into the dispatched request. + request_fields: RunAgentsRequest, + mode: CardMode, + selector: ViewHandle, + action_model: ModelHandle, + /// Approved/disapproved plan config used to resolve inherited fields. + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + /// The conversation's base model, used as the Oz model fallback. + fallback_base_model_id: Option, + /// Whether the block was restored from history (non-interactive). + is_restored: bool, + spawning: Option, + /// Set once the request is accepted or rejected (PRODUCT 8). + decided: bool, + /// Validation reason shown inline after a blocked Accept (PRODUCT 53). + accept_error: Option, + /// Identity palette pinned at construction so identities stay stable + /// across re-renders, edits, and theme switches (PRODUCT 11). + identity_palette: Vec, +} + +impl TuiRunAgentsCardView { + /// Creates a card for one pending `RunAgents` action and wires its model + /// subscriptions. + pub(crate) fn new( + action: AIAgentAction, + request: &RunAgentsRequest, + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + action_model: ModelHandle, + run_agents_executor: ModelHandle, + fallback_base_model_id: Option, + is_restored: bool, + ctx: &mut ViewContext, + ) -> Self { + let selector = ctx.add_typed_action_tui_view(|_| TuiOptionSelector::new()); + ctx.subscribe_to_view(&selector, |me, _, event, ctx| { + me.handle_selector_event(event, ctx); + }); + + let action_id = action.id.clone(); + let action_id_for_executor = action_id.clone(); + ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event { + RunAgentsExecutorEvent::SpawningStarted { + action_id, + snapshot, + } if action_id == &action_id_for_executor => { + me.spawning = Some(*snapshot); + me.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningFinished { action_id } + if action_id == &action_id_for_executor => + { + me.spawning = None; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningStarted { .. } + | RunAgentsExecutorEvent::SpawningFinished { .. } => {} + }); + + let action_id_for_actions = action_id.clone(); + ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event { + BlocklistAIActionEvent::FinishedAction { action_id, .. } + if action_id == &action_id_for_actions => + { + me.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) + if action_id == &action_id_for_actions => + { + // Streaming completed: the card transitions from the + // "Configuring agents…" placeholder to the interactive + // acceptance card, so resolve display defaults now. + me.resolve_interactive_defaults(ctx); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + _ => {} + }); + + // Live catalog changes revalidate the edit state and refresh the + // active page (PRODUCT 45, 49-50). + ctx.subscribe_to_model( + &HarnessAvailabilityModel::handle(ctx), + |me, _, event, ctx| match event { + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretsLoaded + | HarnessAvailabilityEvent::AuthSecretsFetchFailed + | HarnessAvailabilityEvent::AuthSecretCreated { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} + }, + ); + ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { + if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + }); + ctx.subscribe_to_model( + &warp::tui_export::ConnectedSelfHostedWorkersModel::handle(ctx), + |me, _, event, ctx| { + let warp::tui_export::ConnectedSelfHostedWorkersEvent::Changed = event; + me.refresh_active_page(ctx); + ctx.notify(); + }, + ); + + let mut view = Self { + action_id, + action, + orchestration_edit_state: OrchestrationEditState::new(Self::config_state_from_request( + request, + active_config.as_ref(), + )), + request_fields: request.clone(), + mode: CardMode::Acceptance, + selector, + action_model, + active_config, + fallback_base_model_id, + is_restored, + spawning: None, + decided: false, + accept_error: None, + identity_palette: TuiUiBuilder::from_app(ctx).agent_identity_palette(), + }; + view.resolve_interactive_defaults(ctx); + view + } + + /// Seeds the run-wide edit state from the streamed request, resolving + /// empty fields from an approved plan config (state parity with the + /// GUI card's `RunAgentsEditState::from_request` + `resolve_from_config`). + fn config_state_from_request( + request: &RunAgentsRequest, + active_config: Option<&(OrchestrationConfig, OrchestrationConfigStatus)>, + ) -> OrchestrationConfigState { + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some(&request.model_id), + Some(&request.harness_type), + &request.execution_mode, + ); + // Carry the request's auth secret across the round trip. Absence + // becomes `Unset`; defaults re-resolve from persisted settings. + state.auth_secret_selection = + AuthSecretSelection::from_optional_name(request.harness_auth_secret_name.clone()); + if matches!(request.execution_mode, RunAgentsExecutionMode::Local) { + // Re-applying Local sanitizes product-disabled local harnesses. + state.toggle_execution_mode_to_remote(false); + } + if let Some((config, status)) = active_config { + if status.is_approved() { + state.resolve_from_config(config); + } + } + state + } + + /// Resolves UI-only display defaults, mirroring the GUI card's + /// `resolve_interactive_defaults`: the Oz model falls back to the + /// conversation base model, a Remote run pre-fills the default host and + /// environment, and an `Unset` auth selection re-seeds from persisted + /// per-harness settings. + fn resolve_interactive_defaults(&mut self, ctx: &AppContext) { + let state = &mut self.orchestration_edit_state.orchestration_config_state; + if state.model_id.is_empty() { + let harness = Harness::parse_orchestration_harness(&state.harness_type); + if matches!(harness, Some(Harness::Oz) | None) { + if let Some(base) = &self.fallback_base_model_id { + state.model_id = base.clone(); + } + } + } + if let RunAgentsExecutionMode::Remote { + environment_id, + worker_host, + .. + } = &state.execution_mode + { + let needs_host = worker_host.is_empty(); + let needs_env = environment_id.is_empty(); + if needs_host { + let default_host = resolve_default_host_slug(ctx) + .unwrap_or_else(|| ORCHESTRATION_WARP_WORKER_HOST.to_string()); + state.set_worker_host(default_host); + } + if needs_env { + if let Some(default_env) = resolve_default_environment_id(ctx) { + state.set_environment_id(default_env); + } + } + } + if matches!(state.auth_secret_selection, AuthSecretSelection::Unset) { + state.auth_secret_selection = + resolve_auth_secret_selection_for_harness(&state.harness_type, ctx); + } + } + + /// Re-syncs edit state from the latest streaming request chunk + /// (mirroring the GUI card's `update_request`). + pub(crate) fn update_request( + &mut self, + request: &RunAgentsRequest, + ctx: &mut ViewContext, + ) { + if self.spawning.is_some() || self.decided { + return; + } + self.action.action = AIAgentActionType::RunAgents(request.clone()); + let new_state = Self::config_state_from_request(request, self.active_config.as_ref()); + let changed = self.request_fields != *request + || self.orchestration_edit_state.orchestration_config_state != new_state; + if !changed { + return; + } + self.request_fields = request.clone(); + self.orchestration_edit_state = OrchestrationEditState::new(new_state); + self.resolve_interactive_defaults(ctx); + self.refresh_active_page(ctx); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Whether this card is the active blocking interaction: interactive in + /// Acceptance/Configuring while the action awaits confirmation, and + /// false once accepted, rejected, spawning, finished, or restored + /// (PRODUCT 1-8). + pub(crate) fn wants_focus(&self, ctx: &AppContext) -> bool { + if self.decided || self.spawning.is_some() || self.is_restored { + return false; + } + matches!( + self.action_model + .as_ref(ctx) + .get_action_status(&self.action_id), + Some(AIActionStatus::Blocked) + ) + } + + /// The dynamic page sequence for the current edit state (PRODUCT 19-21). + fn page_sequence(state: &OrchestrationConfigState) -> Vec { + if state.execution_mode.is_remote() { + let mut pages = vec![ConfigPage::Location, ConfigPage::Harness]; + if should_show_auth_secret_picker(state) { + pages.push(ConfigPage::ApiKey); + } + pages.extend([ConfigPage::Host, ConfigPage::Environment, ConfigPage::Model]); + pages + } else { + vec![ConfigPage::Location, ConfigPage::Model] + } + } + + /// Builds the option snapshot for `page` from the shared builders. + fn snapshot_for_page(&self, page: ConfigPage, ctx: &AppContext) -> OptionSnapshot { + let state = &self.orchestration_edit_state.orchestration_config_state; + match page { + ConfigPage::Location => location_snapshot(state, ctx), + ConfigPage::Harness => harness_snapshot(state, ctx), + ConfigPage::ApiKey => api_key_snapshot(state, ctx), + ConfigPage::Host => host_snapshot(state, ctx), + ConfigPage::Environment => environment_snapshot(state, ctx), + ConfigPage::Model => model_snapshot(state, ctx), + } + } + + /// Opens `page`: swaps the selector to its snapshot and header, and + /// lazily fetches auth secrets for the API-key page (the same lazy fetch + /// the GUI triggers on picker population). + fn open_page(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + self.mode = CardMode::Configuring { page }; + self.accept_error = None; + if matches!(page, ConfigPage::ApiKey) { + self.ensure_auth_secrets_fetched(ctx); + } + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; + let header = OptionSelectorHeader { + title: page.title().to_string(), + position: (position, sequence.len()), + question: page.question().to_string(), + }; + let snapshot = self.snapshot_for_page(page, ctx); + self.selector.update(ctx, |selector, ctx| { + selector.set_page(header, snapshot, ctx); + }); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Refreshes the active page's snapshot in place after a catalog or + /// state change, updating the header so the dynamic page count stays + /// current (PRODUCT 20). + fn refresh_active_page(&mut self, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + if !sequence.contains(&page) { + // The active page no longer applies (e.g. auth page removed by a + // catalog change); fall back to the acceptance card. + self.mode = CardMode::Acceptance; + ctx.notify(); + return; + } + let snapshot = self.snapshot_for_page(page, ctx); + self.selector.update(ctx, |selector, ctx| { + selector.refresh_snapshot(snapshot, ctx); + }); + } + + /// Triggers the lazy per-harness auth-secret fetch (also the Retry path + /// for a `Failed` API-key page, PRODUCT 48). + fn ensure_auth_secrets_fetched(&self, ctx: &mut ViewContext) { + let Some(harness) = Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) else { + return; + }; + HarnessAvailabilityModel::handle(ctx).update(ctx, |availability, ctx| { + availability.ensure_auth_secrets_fetched(harness, ctx); + }); + } + + /// Applies a confirmed selection to the edit state (PRODUCT 24) and + /// advances to the next applicable page (PRODUCT 25-26). + fn handle_page_confirmed(&mut self, id: &str, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + match page { + ConfigPage::Location => { + let is_remote = id == LOCATION_CLOUD_ID; + self.orchestration_edit_state + .orchestration_config_state + .apply_execution_mode_change( + is_remote, + self.fallback_base_model_id.clone(), + ctx, + ); + } + ConfigPage::Harness => { + let fallback = self.fallback_base_model_id.clone(); + self.orchestration_edit_state + .apply_harness_change(id, fallback, ctx); + } + ConfigPage::ApiKey => { + let name = (!id.is_empty()).then(|| id.to_string()); + self.orchestration_edit_state + .orchestration_config_state + .apply_auth_secret_change(name, ctx); + } + ConfigPage::Host => { + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(id.to_string()); + persist_host_selection(id, ctx); + } + ConfigPage::Environment => { + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(id.to_string()); + persist_environment_selection(id, ctx); + } + ConfigPage::Model => { + self.orchestration_edit_state + .orchestration_config_state + .model_id = id.to_string(); + } + } + self.advance_after(page, ctx); + } + + /// Advances past `page` in the (freshly recomputed) sequence, returning + /// to the acceptance card after the final page (PRODUCT 25-26). + fn advance_after(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let next = sequence + .iter() + .position(|p| *p == page) + .and_then(|index| sequence.get(index + 1)) + .copied(); + match next { + Some(next) => self.open_page(next, ctx), + None => { + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + } + } + + /// Routes selector events for the active page. + fn handle_selector_event( + &mut self, + event: &TuiOptionSelectorEvent, + ctx: &mut ViewContext, + ) { + match event { + TuiOptionSelectorEvent::Confirmed { id } => { + let id = id.clone(); + self.handle_page_confirmed(&id, ctx); + } + TuiOptionSelectorEvent::CustomTextSubmitted { value } => { + if let CardMode::Configuring { + page: ConfigPage::Host, + } = self.mode + { + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(value.clone()); + persist_host_selection(value, ctx); + self.advance_after(ConfigPage::Host, ctx); + } + } + TuiOptionSelectorEvent::RetryRequested => { + self.ensure_auth_secrets_fetched(ctx); + self.refresh_active_page(ctx); + } + TuiOptionSelectorEvent::Dismissed => self.handle_back(ctx), + } + } + + /// Builds the dispatched request from this card's fields and edited + /// run-wide state; see [`build_request`]. + fn to_request(&self) -> RunAgentsRequest { + build_request( + &self.request_fields, + &self.orchestration_edit_state.orchestration_config_state, + ) + } + + /// Accept (PRODUCT 52-55): validates with the shared gate; a blocked + /// accept renders the reason inline and stays active, a valid one + /// dispatches the edited request through `execute_run_agents`. + fn handle_accept(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { + return; + } + if let Some(reason) = accept_disabled_reason_with_auth( + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) { + self.accept_error = Some(reason); + ctx.notify(); + return; + } + self.decided = true; + self.accept_error = None; + self.mode = CardMode::Acceptance; + let request = self.to_request(); + let action_id = self.action_id.clone(); + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(&action_id, request, ctx); + }); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Reject (PRODUCT 56): resolves the request as rejected exactly once, + /// from the acceptance card or any configuration page (PRODUCT 28). + fn handle_reject(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { + return; + } + self.decided = true; + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::RejectRequested); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Opens configuration on the first page (PRODUCT 16). + fn handle_configure(&mut self, ctx: &mut ViewContext) { + if !self.wants_focus(ctx) { + return; + } + let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) + .first() + .copied(); + if let Some(first) = first { + self.open_page(first, ctx); + } + } + + /// Escape from configuration: completed pages keep their confirmed + /// selections; the current page's unconfirmed highlight is discarded + /// (PRODUCT 27). Active custom-text editing unwinds first. + fn handle_back(&mut self, ctx: &mut ViewContext) { + let consumed = self + .selector + .update(ctx, |selector, ctx| selector.handle_back(ctx)); + if consumed { + return; + } + if matches!(self.mode, CardMode::Configuring { .. }) { + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + } + + /// Confirms the selector's highlighted option (Enter, PRODUCT 31). + fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { + self.selector.update(ctx, |selector, ctx| { + selector.confirm_highlighted(ctx); + }); + } + + // ── Rendering ─────────────────────────────────────────────────── + + /// Deterministic identities for the proposed agents, from the pinned + /// palette (PRODUCT 11-13). + fn agent_identities(&self) -> Vec<&AgentIdentity> { + let names = self + .request_fields + .agent_run_configs + .iter() + .map(|config| config.name.as_str()); + assign_agent_identity_indices(names, self.identity_palette.len()) + .into_iter() + .filter_map(|index| self.identity_palette.get(index)) + .collect() + } + + /// The harness display label for the current selection (PRODUCT 39). + fn harness_label(&self, ctx: &AppContext) -> String { + match Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) { + Some(harness) => HarnessAvailabilityModel::as_ref(ctx) + .display_name_for(harness) + .to_string(), + None => "Warp".to_string(), + } + } + + /// The display label for an id within a snapshot, falling back to the id. + fn label_for_id(snapshot: &OptionSnapshot, id: &str, fallback: &str) -> String { + snapshot + .rows + .iter() + .find(|row| row.id == id) + .map(|row| row.label.clone()) + .unwrap_or_else(|| { + if id.is_empty() { + fallback.to_string() + } else { + id.to_string() + } + }) + } + + /// One `label: value` metadata row with a bold selected value. + fn render_metadata_row( + label: &str, + value: String, + builder: &TuiUiBuilder, + ) -> Box { + TuiText::from_spans([ + (format!("{label:<12}"), builder.muted_text_style()), + (value, builder.orchestration_selected_value_style()), + ]) + .truncate() + .finish() + } + + /// The acceptance card body (PRODUCT 9-17). + fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let mut column = TuiFlex::column(); + + // Title row with the attention glyph, on the tinted header. + column.add_child( + TuiText::from_spans([( + format!("◆ {RUN_AGENTS_CARD_TITLE}"), + builder.orchestration_title_style(), + )]) + .finish(), + ); + + let summary = if self.request_fields.summary.trim().is_empty() { + format!( + "Spawn {} agent(s) to address this task.", + self.request_fields.agent_run_configs.len() + ) + } else { + self.request_fields.summary.clone() + }; + column.add_child( + TuiContainer::new( + TuiText::new(summary) + .with_style(builder.primary_text_style()) + .finish(), + ) + .with_padding_top(1) + .finish(), + ); + + // Agent list: every proposed agent's name with its identity. + column.add_child( + TuiContainer::new( + TuiText::new(format!( + "Agents ({})", + self.request_fields.agent_run_configs.len() + )) + .with_style(builder.muted_text_style()) + .truncate() + .finish(), + ) + .with_padding_top(1) + .finish(), + ); + for (config, identity) in self + .request_fields + .agent_run_configs + .iter() + .zip(self.agent_identities()) + { + column.add_child( + TuiText::from_spans([ + ( + format!("{} ", identity.glyph), + identity.style.add_modifier(Modifier::BOLD), + ), + (config.name.clone(), builder.primary_text_style()), + ]) + .truncate() + .finish(), + ); + } + + // Run-wide configuration values (PRODUCT 9-10, 14). + let is_remote = state.execution_mode.is_remote(); + let mut metadata = TuiFlex::column(); + metadata.add_child(Self::render_metadata_row( + "Location", + if is_remote { "Cloud" } else { "Local" }.to_string(), + builder, + )); + metadata.add_child(Self::render_metadata_row( + "Harness", + self.harness_label(app), + builder, + )); + if is_remote { + if should_show_auth_secret_picker(state) { + let api_key = match &state.auth_secret_selection { + AuthSecretSelection::Named(name) => name.clone(), + AuthSecretSelection::Inherit => "Skip (advanced)".to_string(), + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { + "Select an API key".to_string() + } + }; + metadata.add_child(Self::render_metadata_row("API key", api_key, builder)); + } + let host = match &state.execution_mode { + RunAgentsExecutionMode::Remote { worker_host, .. } + if !worker_host.trim().is_empty() => + { + worker_host.clone() + } + RunAgentsExecutionMode::Remote { .. } | RunAgentsExecutionMode::Local => { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } + }; + metadata.add_child(Self::render_metadata_row("Host", host, builder)); + let environment_id = match &state.execution_mode { + RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let environment = Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ); + metadata.add_child(Self::render_metadata_row( + "Environment", + environment, + builder, + )); + } + let model = Self::label_for_id( + &model_snapshot(state, app), + &state.model_id, + "Default model", + ); + metadata.add_child(Self::render_metadata_row("Model", model, builder)); + column.add_child( + TuiContainer::new(metadata.finish()) + .with_padding_top(1) + .finish(), + ); + + // Inline validation (PRODUCT 53) or the soft empty-env nudge. + if let Some(error) = &self.accept_error { + column.add_child( + TuiText::new(error.clone()) + .with_style(builder.error_text_style()) + .finish(), + ); + } else if let Some(message) = empty_env_recommendation_message(&state.execution_mode, app) { + column.add_child( + TuiText::new(message) + .with_style(builder.attention_glyph_style()) + .truncate() + .finish(), + ); + } + + // Action hints replace the normal input footer (PRODUCT 2, 16-17); + // they wrap rather than truncate so every available action stays + // visible at narrow widths (PRODUCT 15). + column.add_child( + TuiContainer::new( + TuiText::new("enter accept · ctrl-e configure · ctrl-c reject") + .with_style(builder.muted_text_style()) + .finish(), + ) + .with_padding_top(1) + .finish(), + ); + + column.finish() + } + + /// The configuring body: the active selector page plus its hints (which + /// wrap rather than truncate at narrow widths, PRODUCT 15). + fn render_configuring(&self, builder: &TuiUiBuilder) -> Box { + TuiFlex::column() + .child(TuiChildView::new(&self.selector).finish()) + .child( + TuiContainer::new( + TuiText::new("↑↓ move · 1-9 select · enter confirm · esc back · ctrl-c reject") + .with_style(builder.muted_text_style()) + .finish(), + ) + .with_padding_top(1) + .finish(), + ) + .finish() + } +} + +impl Entity for TuiRunAgentsCardView { + type Event = TuiRunAgentsCardViewEvent; +} + +impl TuiView for TuiRunAgentsCardView { + fn ui_name() -> &'static str { + "TuiRunAgentsCardView" + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + vec![self.selector.id()] + } + + fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context { + let mut context = keymap::Context::default(); + context.set.insert(Self::ui_name()); + match self.mode { + CardMode::Acceptance => context.set.insert(ACCEPTANCE_CONTEXT_FLAG), + CardMode::Configuring { .. } => context.set.insert(CONFIGURING_CONTEXT_FLAG), + }; + context + } + + fn render(&self, app: &AppContext) -> Box { + let status = self + .action_model + .as_ref(app) + .get_action_status(&self.action_id); + + // Terminal, spawning, restored, and still-streaming states reuse the + // shared fallback tool-call row and its `tool_call_labels` copy + // (PRODUCT 7, 57). + let interactive = !self.is_restored + && self.spawning.is_none() + && matches!(status, Some(AIActionStatus::Blocked)); + if !interactive { + return render_fallback_tool_call_section( + &self.action, + status.as_ref(), + false, + None, + app, + ); + } + + let builder = TuiUiBuilder::from_app(app); + let body = match self.mode { + CardMode::Acceptance => self.render_acceptance(app, &builder), + CardMode::Configuring { .. } => self.render_configuring(&builder), + }; + // The orchestration treatment: a themed magenta-tinted surface + // (PRODUCT 14), padded one cell on each side. + TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(1) + .finish() + } +} + +impl TypedActionView for TuiRunAgentsCardView { + type Action = TuiRunAgentsCardAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), + TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), + TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), + TuiRunAgentsCardAction::Back => self.handle_back(ctx), + TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), + } + } +} + +#[cfg(test)] +#[path = "run_agents_card_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs new file mode 100644 index 00000000000..d29f05b5678 --- /dev/null +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -0,0 +1,471 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use ai::agent::orchestration_config::{ + OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, +}; +use warp::tui_export::{ + register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, + AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, RunAgentsAgentRunConfig, + RunAgentsExecutionMode, RunAgentsRequest, TaskId, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ModelHandle}; +use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; + +use super::{ + build_request, ConfigPage, TuiRunAgentsCardAction, TuiRunAgentsCardView, + TuiRunAgentsCardViewEvent, +}; +use crate::option_selector::TuiOptionSelectorAction; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_action_model_with_surface, TestHostView, +}; + +/// Builds a request with the given harness and execution mode. +fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { + RunAgentsRequest { + summary: "Parallelize the task.".to_string(), + base_prompt: "base".to_string(), + skills: Vec::new(), + model_id: "auto".to_string(), + harness_type: harness.to_string(), + execution_mode, + agent_run_configs: vec![RunAgentsAgentRunConfig { + name: "researcher".to_string(), + prompt: "research".to_string(), + title: "Researcher".to_string(), + }], + plan_id: "plan-1".to_string(), + harness_auth_secret_name: None, + } +} + +/// A Cloud execution mode with the given env/host. +fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { + RunAgentsExecutionMode::Remote { + environment_id: environment_id.to_string(), + worker_host: worker_host.to_string(), + computer_use_enabled: true, + } +} + +#[test] +fn local_collapses_the_page_sequence_to_two_pages() { + let state = TuiRunAgentsCardView::config_state_from_request( + &request("oz", RunAgentsExecutionMode::Local), + None, + ); + assert_eq!( + TuiRunAgentsCardView::page_sequence(&state), + vec![ConfigPage::Location, ConfigPage::Model], + ); +} + +#[test] +fn cloud_oz_uses_five_pages_without_the_api_key_page() { + let state = TuiRunAgentsCardView::config_state_from_request( + &request("oz", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiRunAgentsCardView::page_sequence(&state), + vec![ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::Host, + ConfigPage::Environment, + ConfigPage::Model, + ], + ); +} + +#[test] +fn cloud_managed_credential_harness_inserts_the_api_key_page() { + let state = TuiRunAgentsCardView::config_state_from_request( + &request("claude", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiRunAgentsCardView::page_sequence(&state), + vec![ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::ApiKey, + ConfigPage::Host, + ConfigPage::Environment, + ConfigPage::Model, + ], + ); +} + +#[test] +fn edit_state_carries_the_request_auth_secret() { + let mut with_secret = request("claude", remote("env-1", "warp")); + with_secret.harness_auth_secret_name = Some("work-key".to_string()); + let state = TuiRunAgentsCardView::config_state_from_request(&with_secret, None); + assert_eq!( + state.auth_secret_selection, + AuthSecretSelection::Named("work-key".to_string()), + ); + // Absence means "no choice yet", not Inherit. + let state = + TuiRunAgentsCardView::config_state_from_request(&request("claude", remote("", "")), None); + assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); +} + +#[test] +fn edit_state_resolves_empty_fields_from_an_approved_config() { + let mut inherit_all = request("", RunAgentsExecutionMode::Local); + inherit_all.model_id = String::new(); + let config = OrchestrationConfig { + model_id: "auto".to_string(), + harness_type: "claude".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-2".to_string(), + worker_host: "warp".to_string(), + }, + }; + let state = TuiRunAgentsCardView::config_state_from_request( + &inherit_all, + Some(&(config.clone(), OrchestrationConfigStatus::Approved)), + ); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "auto"); + assert!(state.execution_mode.is_remote()); + + // A disapproved config does not resolve inherited fields. + let state = TuiRunAgentsCardView::config_state_from_request( + &inherit_all, + Some(&(config, OrchestrationConfigStatus::Disapproved)), + ); + assert_eq!(state.harness_type, ""); + assert!(!state.execution_mode.is_remote()); +} + +#[test] +fn build_request_carries_card_fields_and_edited_run_wide_state() { + let original = request("oz", remote("env-1", "warp")); + let mut state = TuiRunAgentsCardView::config_state_from_request(&original, None); + state.model_id = "gpt-5".to_string(); + state.harness_type = "codex".to_string(); + state.set_environment_id("env-9".to_string()); + state.set_worker_host("self-hosted".to_string()); + state.auth_secret_selection = AuthSecretSelection::Named("codex-key".to_string()); + + let built = build_request(&original, &state); + // Card fields pass through unchanged. + assert_eq!(built.summary, original.summary); + assert_eq!(built.base_prompt, original.base_prompt); + assert_eq!(built.agent_run_configs, original.agent_run_configs); + assert_eq!(built.plan_id, original.plan_id); + // Run-wide fields come from the edited state; the per-call + // computer-use flag is preserved through the round trip (PRODUCT 46). + assert_eq!(built.model_id, "gpt-5"); + assert_eq!(built.harness_type, "codex"); + assert_eq!( + built.execution_mode, + RunAgentsExecutionMode::Remote { + environment_id: "env-9".to_string(), + worker_host: "self-hosted".to_string(), + computer_use_enabled: true, + }, + ); + assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); +} + +#[test] +fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { + // A stale Named(_) selection must not leak into a Local dispatch. + let original = request("claude", RunAgentsExecutionMode::Local); + let mut state = TuiRunAgentsCardView::config_state_from_request(&original, None); + state.auth_secret_selection = AuthSecretSelection::Named("stale".to_string()); + assert_eq!( + build_request(&original, &state).harness_auth_secret_name, + None + ); +} + +// ── Blocked-card fixtures ──────────────────────────────────── + +type CapturedCardEvents = Rc>>; + +struct BlockedCard { + card: ViewHandle, + action_model: ModelHandle, + action_id: AIAgentActionId, + events: CapturedCardEvents, +} + +/// Queues `request` as a Blocked `RunAgents` action against the real action +/// model and constructs an interactive card for it. +fn blocked_card(app: &mut App, request: &RunAgentsRequest) -> BlockedCard { + register_orchestration_test_singletons(app); + let (action_model, _, terminal_surface_id) = add_test_action_model_with_surface(app); + let conversation_id = add_active_test_conversation(app, terminal_surface_id); + let action = AIAgentAction { + id: AIAgentActionId::from("run-agents-1".to_string()), + task_id: TaskId::new("task-1".to_string()), + action: AIAgentActionType::RunAgents(request.clone()), + requires_result: true, + }; + let action_id = action.id.clone(); + action_model.update(app, |model, ctx| { + model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); + }); + + let card_action_model = action_model.clone(); + let request = request.clone(); + let card = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + let run_agents_executor = card_action_model.as_ref(ctx).run_agents_executor(ctx); + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiRunAgentsCardView::new( + action, + &request, + None, + card_action_model, + run_agents_executor, + Some("auto".to_string()), + false, + ctx, + ) + }) + }); + let events: CapturedCardEvents = Rc::new(RefCell::new(Vec::new())); + let events_for_subscription = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&card, move |_, event, _| { + events_for_subscription.borrow_mut().push(event.clone()); + }); + }); + BlockedCard { + card, + action_model, + action_id, + events, + } +} + +/// Renders the card through the real presenter (so the embedded selector +/// child view resolves) and returns trimmed lines at `width`. +fn render_card_lines( + app: &mut App, + card: &ViewHandle, + width: u16, +) -> Vec { + let mut presenter = TuiPresenter::new(); + let frame = app.update(|ctx| { + let window_id = card.window_id(ctx); + // Mirror the runtime's draw: `invalidate` renders the card and its + // selector into the presenter cache, then `present` resolves the + // embedded selector via `TuiChildView`. + let mut invalidation = WindowInvalidation::default(); + invalidation.updated.insert(card.id()); + invalidation.updated.insert(card.as_ref(ctx).selector.id()); + presenter.invalidate(&invalidation, ctx, window_id); + presenter.present(ctx, card, TuiRect::new(0, 0, width, 60)) + }); + frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect() +} + +/// Dispatches a card action directly to the view. +fn act(app: &mut App, card: &ViewHandle, action: TuiRunAgentsCardAction) { + card.update(app, |card, ctx| card.handle_action(&action, ctx)); +} + +/// The action's current status in the real action model. +fn action_status(app: &App, fixture: &BlockedCard) -> Option { + app.read(|app| { + fixture + .action_model + .as_ref(app) + .get_action_status(&fixture.action_id) + }) +} + +#[test] +fn acceptance_card_renders_required_content_across_widths() { + App::test((), |mut app| async move { + let mut two_agents = request("oz", remote("", "warp")); + two_agents.agent_run_configs.push(RunAgentsAgentRunConfig { + name: "reviewer".to_string(), + prompt: "review".to_string(), + title: "Reviewer".to_string(), + }); + let fixture = blocked_card(&mut app, &two_agents); + for width in [40u16, 80, 132] { + let lines = render_card_lines(&mut app, &fixture.card, width); + let all = lines.join("\n"); + // PRODUCT 9: question, summary, agent names, and run-wide values. + assert!(all.contains("Can I start"), "width {width}: {all}"); + assert!(all.contains("Parallelize the task."), "width {width}"); + assert!(all.contains("Agents (2)"), "width {width}"); + assert!(all.contains("researcher"), "width {width}"); + assert!(all.contains("reviewer"), "width {width}"); + assert!(all.contains("Location"), "width {width}"); + assert!(all.contains("Cloud"), "width {width}"); + assert!(all.contains("Harness"), "width {width}"); + assert!(all.contains("Model"), "width {width}"); + assert!(all.contains("Host"), "width {width}"); + assert!(all.contains("Environment"), "width {width}"); + // PRODUCT 16-17: the footer hints replace the input footer and + // wrap (rather than clip) at narrow widths (PRODUCT 15). + assert!(all.contains("enter accept"), "width {width}"); + assert!(all.contains("ctrl-e configure"), "width {width}"); + assert!(all.contains("ctrl-c reject"), "width {width}"); + } + }); +} + +#[test] +fn agent_identities_stay_stable_across_rerenders_and_edits() { + App::test((), |mut app| async move { + let base = request("oz", RunAgentsExecutionMode::Local); + let fixture = blocked_card(&mut app, &base); + fn agent_line(app: &mut App, fixture: &BlockedCard) -> String { + render_card_lines(app, &fixture.card, 80) + .into_iter() + .find(|line| line.contains("researcher")) + .expect("agent row") + } + let before = agent_line(&mut app, &fixture); + // Stable across plain re-renders (PRODUCT 11)… + assert_eq!(before, agent_line(&mut app, &fixture)); + // …and across a streamed edit that appends an agent. + let mut extended = base.clone(); + extended.agent_run_configs.push(RunAgentsAgentRunConfig { + name: "reviewer".to_string(), + prompt: "review".to_string(), + title: "Reviewer".to_string(), + }); + fixture.card.update(&mut app, |card, ctx| { + card.update_request(&extended, ctx); + }); + assert_eq!(before, agent_line(&mut app, &fixture)); + }); +} + +#[test] +fn accept_dispatches_through_the_action_model_exactly_once() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + assert!(matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) + )); + assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); + + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); + // The action left the Blocked queue through `execute_run_agents` + // (PRODUCT 55) and the card stopped blocking the input (PRODUCT 5). + assert!(!matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) | None + )); + assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); + + // A second decision is a no-op (PRODUCT 8): no reject can follow. + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); + assert!(!fixture + .events + .borrow() + .iter() + .any(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested))); + }); +} + +#[test] +fn reject_emits_the_cancellation_event_exactly_once() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); + let rejects = fixture + .events + .borrow() + .iter() + .filter(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested)) + .count(); + // Exactly one decision (PRODUCT 8, 56); the card stops blocking. + assert_eq!(rejects, 1); + assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); + }); +} + +#[test] +fn invalid_configurations_cannot_launch_and_surface_a_reason() { + App::test((), |mut app| async move { + // OpenCode + Cloud is a hard block (PRODUCT 54). + let fixture = blocked_card(&mut app, &request("opencode", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); + // The card stays active and blocked (PRODUCT 53), showing the reason. + assert!(matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) + )); + assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("OpenCode is not supported on Cloud yet")); + }); +} + +#[test] +fn configure_walks_pages_and_esc_returns_to_acceptance() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + // First page of the Cloud sequence with its dynamic count + // (PRODUCT 18-19) and the configuring hints. + assert!(all.contains("Location")); + assert!(all.contains("1 of 5")); + assert!(all.contains("Where should the agents run?")); + assert!(all.contains("esc back")); + + // Esc returns to the acceptance card without deciding (PRODUCT 27). + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("enter accept")); + assert!(matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) + )); + }); +} + +#[test] +fn switching_to_local_mid_flow_collapses_the_sequence() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + // Highlight "Local" (second row) and confirm it: the sequence + // collapses to Location, Model and advances to Model (PRODUCT 21). + let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::ConfirmSelection, + ); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("Model"), "{all}"); + assert!(all.contains("2 of 2"), "{all}"); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 1162a37650b..d472d85e590 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -51,6 +51,7 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; +use crate::agent_block::TuiBlockingChild; use crate::alt_screen_view::AltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; @@ -273,6 +274,10 @@ pub(crate) struct TuiTerminalSessionView { conversation_restore_state: ConversationRestoreState, next_restore_request_id: u64, exit_summary: TuiExitSummaryHandle, + /// The view id of the blocker currently holding focus, tracked only to + /// detect blocker transitions in [`Self::sync_blocker_focus`]. Input + /// visibility itself is derived at render time, never stored. + active_blocker_view_id: Option, } /// Registers the session surface's keybindings. Called once at TUI startup @@ -317,7 +322,11 @@ impl TuiTerminalSessionView { ctx.focus_self(); } } else if ctx.is_self_focused() { - ctx.focus(&self.input_view); + if let Some(blocker) = self.active_blocking_child(ctx) { + ctx.focus(&blocker.view); + } else { + ctx.focus(&self.input_view); + } } } fn resume_after_user_controlled_command( @@ -614,6 +623,12 @@ impl TuiTerminalSessionView { ctx, ) }); + // Input visibility and focus derive from the front-of-queue blocker; + // re-derive on every action-queue transition (queued, blocked, + // finished). No suppression flag is stored. + ctx.subscribe_to_model(&action_model, |view, _, _, ctx| { + view.sync_blocker_focus(ctx); + }); let input_editor_model = ctx.add_model(|ctx| CodeEditorModel::new_tui(INITIAL_INPUT_WIDTH, ctx)); let suggestions_mode = ctx.add_model(|_| TuiInputSuggestionsModeModel::new()); @@ -759,6 +774,9 @@ impl TuiTerminalSessionView { view.show_transient_hint(COPY_FAILED_HINT.to_owned(), ctx); } }, + TuiTranscriptViewEvent::BlockingStateChanged => { + view.sync_blocker_focus(ctx); + } }); ctx.subscribe_to_view(&input_view, |view, _, event, ctx| match event { @@ -1000,7 +1018,37 @@ impl TuiTerminalSessionView { conversation_restore_state: ConversationRestoreState::Idle, next_restore_request_id: 0, exit_summary, + active_blocker_view_id: None, + } + } + + /// The active front-of-queue blocking interaction, if any (PRODUCT 1, 4). + fn active_blocking_child(&self, ctx: &AppContext) -> Option { + self.transcript.as_ref(ctx).active_blocking_child(ctx) + } + + /// Reconciles focus with the derived blocker: a newly active blocker is + /// focused (handing off directly between consecutive blockers with no + /// intermediate editable input, PRODUCT 6), and focus returns to the + /// input when the last blocker resolves (PRODUCT 5). Nothing here writes + /// to the input model, so its draft/cursor/selection are untouched + /// (PRODUCT 3). + fn sync_blocker_focus(&mut self, ctx: &mut ViewContext) { + let blocker = self.active_blocking_child(ctx); + let blocker_view_id = blocker.as_ref().map(|child| child.view.id()); + if blocker_view_id != self.active_blocker_view_id { + // A foreground process owns the rendered pane and keyboard. Track + // blocker changes while it is active, but defer focus handoff + // until the process releases input. + if !self.process_owns_input() { + match &blocker { + Some(child) => ctx.focus(&child.view), + None => ctx.focus(&self.input_view), + } + } + self.active_blocker_view_id = blocker_view_id; } + ctx.notify(); } /// Restores an Oz conversation into the TUI's sole conversation surface. @@ -2271,14 +2319,22 @@ impl TuiView for TuiTerminalSessionView { } } } - if let Some(menu) = inline_menu { - content = content.child( - TuiConstrainedBox::new(menu) - .with_max_rows(MAX_INLINE_MENU_ROWS) - .finish(), - ); - } - if !inline_process_owns_input { + // While a `RunAgents` card (or another blocking interaction) is the + // active front-of-queue blocker, the input box, inline menus, and + // normal footer are omitted; the blocker renders its own action + // hints in their place (PRODUCT 1-2). Visibility is derived fresh + // each pass — no stored suppression flag — and the hidden input + // model is never written to, so its draft/cursor/selection/scroll + // survive untouched (PRODUCT 3). + let blocker_active = self.active_blocking_child(ctx).is_some(); + if !blocker_active && !inline_process_owns_input { + if let Some(menu) = inline_menu { + content = content.child( + TuiConstrainedBox::new(menu) + .with_max_rows(MAX_INLINE_MENU_ROWS) + .finish(), + ); + } let builder = TuiUiBuilder::from_app(ctx); let border_style = if self.is_shell_mode(ctx) { builder.shell_mode_accent_style() diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index 8d19365258c..68922b5495e 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -7,7 +7,7 @@ use warp::tui_export::{ ModelEventDispatcher, Sessions, TerminalModel, }; use warp_core::semantic_selection::SemanticSelection; -use warpui::{AddSingletonModel, App, EntityId, ModelHandle}; +use warpui::{AddSingletonModel, App, EntityId, ModelHandle, SingletonEntity as _}; use warpui_core::elements::tui::{TuiElement, TuiText}; use warpui_core::{AppContext, Entity, TuiView, TypedActionView}; @@ -48,6 +48,21 @@ pub(crate) fn add_test_action_model_and_events( ) -> ( ModelHandle, ModelHandle, +) { + let (action_model, dispatcher, _) = add_test_action_model_with_surface(app); + (action_model, dispatcher) +} + +/// [`add_test_action_model_and_events`] plus the terminal-surface id the +/// action model was built with, so tests can register an active conversation +/// for that surface in the history model (required by +/// `BlocklistAIActionModel::get_pending_action`). +pub(crate) fn add_test_action_model_with_surface( + app: &mut App, +) -> ( + ModelHandle, + ModelHandle, + EntityId, ) { add_test_semantic_selection(app); // Read as a singleton by the action model's executors. @@ -62,15 +77,34 @@ pub(crate) fn add_test_action_model_and_events( // `GetRelevantFilesController::new` subscribes to the `CodebaseIndexManager` // singleton, which these tests don't register; `default` skips it. let get_relevant_files = app.add_model(|_| GetRelevantFilesController::default()); + let terminal_surface_id = EntityId::new(); let action_model = app.add_model(|ctx| { BlocklistAIActionModel::new( terminal_model, active_session, &dispatcher, get_relevant_files, - EntityId::new(), + terminal_surface_id, ctx, ) }); - (action_model, dispatcher) + (action_model, dispatcher, terminal_surface_id) +} + +/// Creates a live, active conversation for `terminal_surface_id` in the +/// history model, returning its id. Combined with +/// `BlocklistAIActionModel::queue_pending_action_for_test`, this drives an +/// action into `Blocked` status for confirmation-flow tests. +pub(crate) fn add_active_test_conversation( + app: &mut App, + terminal_surface_id: EntityId, +) -> warp::tui_export::AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = + history.start_new_conversation(terminal_surface_id, false, false, false, ctx); + history.set_active_conversation_id(conversation_id, terminal_surface_id, ctx); + conversation_id + }) + }) } diff --git a/crates/warp_tui/src/transcript_view.rs b/crates/warp_tui/src/transcript_view.rs index 00a1b8e7e59..b57f6099da9 100644 --- a/crates/warp_tui/src/transcript_view.rs +++ b/crates/warp_tui/src/transcript_view.rs @@ -22,7 +22,7 @@ use warpui_core::{ ViewContext, ViewHandle, }; -use super::agent_block::{TuiAIBlock, TuiAIBlockEvent}; +use super::agent_block::{TuiAIBlock, TuiAIBlockEvent, TuiBlockingChild}; use super::terminal_block::should_render_terminal_block; use super::tui_block_list_viewport_source::{ AgentBlockRegistry, CLISubagentBlockRegistry, TuiBlockListViewportSource, @@ -57,6 +57,9 @@ pub(crate) const TRANSCRIPT_BLOCK_SPACING: BlockSpacing = BlockSpacing { pub(super) enum TuiTranscriptViewEvent { SelectionStarted, SelectionEnded(String), + /// An agent block's blocking child changed state; the session surface + /// re-derives the active blocker (input replacement). + BlockingStateChanged, } /// Selection actions originating from the transcript's element tree. @@ -361,6 +364,10 @@ impl TuiTranscriptView { .mark_rich_content_dirty(view_id); ctx.notify(); } + TuiAIBlockEvent::BlockingStateChanged => { + ctx.emit(TuiTranscriptViewEvent::BlockingStateChanged); + ctx.notify(); + } }); self.agent_blocks.borrow_mut().insert(view_id, view); let item = RichContentItem::new(Some(RichContentType::AIBlock), view_id, None, false); @@ -527,6 +534,16 @@ impl TuiTranscriptView { ctx.notify(); } + /// The front-of-queue blocking interaction across this transcript's + /// agent blocks, if any. A pure query over the shared action queue; the + /// session surface derives input visibility and focus from it. + pub(super) fn active_blocking_child(&self, ctx: &AppContext) -> Option { + self.agent_blocks + .borrow() + .values() + .find_map(|block| block.as_ref(ctx).active_blocking_child(ctx)) + } + /// Clears persistent selection owned by the transcript. pub(super) fn clear_selection(&mut self, ctx: &mut ViewContext) { if self.selection.clear() { diff --git a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs index 8bcb546d43e..cd48a24225d 100644 --- a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs +++ b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs @@ -674,6 +674,7 @@ fn reasoning_agent_block_source( .block_list_mut() .mark_rich_content_dirty(view_id); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); { diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index ce8ee0882d7..5980e0716b8 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -17,6 +17,7 @@ use warpui_core::elements::tui::{ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; +use crate::agent_identity::{agent_identity_palette, AgentIdentity}; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -211,6 +212,13 @@ impl TuiUiBuilder { TuiStyle::default().fg(cell_color(self.warping_base_fill())) } + /// The magenta-tinted background behind the orchestration permission + /// card, pre-blended over the probed base background. + pub(crate) fn orchestration_surface_background(&self) -> Color { + let magenta = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + cell_color(self.base_background().blend(&magenta.with_opacity(10))) + } + /// Bold magenta text for a selected option-selector row. pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { TuiStyle::default() @@ -220,6 +228,20 @@ impl TuiUiBuilder { .add_modifier(Modifier::BOLD) } + /// Bold primary text for selected configuration metadata. + pub(crate) fn orchestration_selected_value_style(&self) -> TuiStyle { + self.primary_text_style().add_modifier(Modifier::BOLD) + } + + /// The deterministic agent identity palette for this theme, resolved + /// against the probed terminal background. See [`crate::agent_identity`]. + pub(crate) fn agent_identity_palette(&self) -> Vec { + agent_identity_palette( + self.warp_theme.terminal_colors(), + self.base_background().into_solid(), + ) + } + /// Collapsible-header style while the pointer hovers it. fn hovered_header_style(&self) -> TuiStyle { self.primary_text_style().add_modifier(Modifier::BOLD) diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md new file mode 100644 index 00000000000..3428037110a --- /dev/null +++ b/specs/CODE-1822/PRODUCT.md @@ -0,0 +1,118 @@ +# PRODUCT: TUI Orchestration Permission and Configuration +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) + +## Summary +The Warp TUI gains an interactive permission card for an active `RunAgents` request. A user can review the proposed agents and run-wide configuration, accept or reject the request, or edit its configuration using the keyboard or mouse without leaving the TUI. + +## Figma +- Initial acceptance card: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=766-18419&m=dev +- Location page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=777-13269&m=dev +- Harness page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=783-13443&m=dev +- Model page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=783-13585&m=dev + +## Goals +- Give the TUI keyboard- and mouse-driven controls for reviewing, editing, accepting, and rejecting an active orchestration request. +- Provide parity with the GUI's existing cloud orchestration choices and validation while preserving the TUI-specific interaction design. +- Establish reusable TUI patterns for option selection and for temporarily replacing the main input with a blocking interaction. + +## Non-goals +- Creating API keys or environments from this flow. The TUI chooses among existing resources. +- The GUI's “continue without orchestration” action. The TUI offers Accept, Edit, and Reject. +- Changing how the agent decides to request orchestration. This spec begins when a `RunAgents` request is ready for user confirmation. +- Implementing a separate TUI orchestration executor. A valid acceptance uses the existing shared execution path; upstream work that makes orchestration requests reachable end to end in the TUI may land in a stacked change. + +## Behavior +### Active interaction and input visibility +1. When a `RunAgents` request reaches the front of the confirmation queue and awaits a decision, its permission card becomes the active interaction. +2. While the permission card or one of its configuration pages is active, the main input row, input cursor, and normal input footer are hidden. The permission surface provides the relevant action hints in their place. +3. Hiding the input preserves its draft text, cursor position, selection, editor mode, and scroll state without modification. +4. Pending requests behind the active front-of-queue blocker do not independently affect input visibility or focus. +5. Accepting or rejecting the request ends the blocking interaction immediately, even while an accepted request continues into a spawning state. The main input and footer reappear with their preserved state, and prior focus is restored. +6. If the next queued action is also a blocking interaction, focus and input visibility transition directly to that interaction without briefly exposing an editable input. +7. A restored, completed, cancelled, rejected, spawning, succeeded, partially succeeded, or failed card is non-interactive and does not hide the input. +8. A request can be accepted or rejected only once. + +### Acceptance card +9. The initial card shows: + - “Can I start additional agents for this task?” + - The agent-provided summary of why orchestration is requested. + - Every proposed agent's name. + - The current run-wide location, harness, and model. + - For Cloud runs, the current API-key choice when applicable, host, and environment. +10. Returning from configuration updates the displayed run-wide values. The user always reviews the final values on the acceptance card before launching. +11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. +12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. +13. If a future request exceeds the number of unique combinations, the palette cycles deterministically. No agent is omitted and rendering does not fail. +14. The card uses the orchestration treatment from the designs: a themed magenta-tinted body and header, an attention glyph, primary text for content, muted separators and metadata, and bold emphasis for selected configuration values. +15. Text and agent identities wrap and reflow at narrow terminal widths. If the complete card cannot fit vertically, it remains navigable without clipping required configuration or actions. +16. On the acceptance card: + - Enter accepts the current configuration. + - Ctrl+E opens configuration. + - Ctrl+C rejects the request. +17. The footer renders these bindings using the active theme and the exact actions available in the current state. + +### Configuration flow +18. Configuration is a sequence of single-field pages. Each page shows a title, its position in the current sequence, one question, a selectable option list, and contextual keybinding hints. +19. Cloud uses this order: + 1. Location + 2. Harness + 3. API key, only when the selected harness supports managed credentials + 4. Host + 5. Environment + 6. Model +20. The page count is dynamic. Adding or removing the conditional API-key page immediately updates the current position and total. +21. Selecting Local immediately forces the Warp harness and removes the Harness, API key, Host, and Environment pages. The flow becomes Location (1 of 2) followed by Model (2 of 2). +22. Selecting Cloud restores the applicable Cloud page sequence and valid selections from the current edit session when possible. +23. Each page initially highlights the request's current value, including values inherited from an approved plan configuration. If the current value is unavailable, the page highlights the appropriate valid default and clearly reflects the replacement. +24. A confirmed selection is saved immediately to the edit session. +25. Confirming a selection on a non-final page advances to the next applicable page. +26. Confirming the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. +27. Esc returns to the acceptance card and retains selections confirmed on completed pages. The current page's highlighted but unconfirmed option is discarded. +28. Ctrl+C rejects the entire request from any configuration page. + +### General option selection +29. Every configuration page uses the same option-selection behavior and presentation. +30. Up and Down move the highlight through enabled options without confirming a value. Navigation scrolls when the highlight moves beyond the visible viewport. +31. Enter confirms the highlighted option. +32. Number keys 1–9 confirm the corresponding visible option and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. +33. Options beyond the first nine visible rows remain reachable with scrolling, Up and Down, and Enter. +34. Clicking an enabled option confirms it and advances exactly like Enter. +35. Mouse-wheel and trackpad input scroll lists that exceed the available height. +36. Disabled rows can be highlighted for context but cannot be confirmed. They show a concise reason when one is available. +37. Empty lists show a non-selectable empty state rather than leaving a blank surface. + +### Field-specific choices +38. Location offers Cloud and Local. +39. Harness shows the same live availability, display labels, ordering, and disabled reasons as the GUI's orchestration controls, subject to the TUI's Local behavior in (21). +40. Model shows the same live, harness-specific catalog, labels, ordering, and default behavior as the GUI: + - Warp Cloud excludes unsupported custom models. + - Warp Local includes models supported by local Warp agents. + - Non-Warp Cloud harnesses include their harness default and server-provided models. +41. API key appears only for Cloud harnesses that support managed credentials. It offers: + - “Skip (advanced)” to inherit credentials from the selected worker environment. + - Existing named managed secrets valid for the selected harness. + - No resource-creation option. +42. Host appears only for Cloud. It offers the Warp-hosted option, the workspace default when configured, known connected self-hosted workers, the user's recent custom host when available, and a custom-host text-entry option. +43. A custom host is trimmed and validated before it is confirmed. Invalid or empty custom input remains editable and shows a concise error. +44. Environment appears only for Cloud. It offers “Empty environment” plus existing environments, using the same labels and default-selection behavior as the GUI. It does not offer environment creation. +45. Switching Location or Harness revalidates all dependent fields: + - Local forces Warp and removes Cloud-only values from the launch configuration. + - A Harness change restores that harness's prior model selection when still valid, otherwise it selects the appropriate default. + - A Harness change re-resolves the API-key choice for the new harness. + - Values that remain applicable are preserved. +46. The incoming per-call computer-use value is preserved through editing but is not presented as a configuration page because the GUI does not expose it as an editable orchestration choice. + +### Loading, refresh, and failures +47. Pages backed by data that has not loaded show a non-selectable Loading row. +48. A load failure shows an inline error and a Retry action reachable by keyboard and mouse. +49. A prior selection remains visible while its catalog refreshes or retries. A transient failure never silently clears it. +50. Live catalog changes refresh the relevant list. If the selected item disappears, the page explains that it is unavailable and selects a valid default only when required to proceed. +51. Secret values are never displayed. The UI shows managed-secret names only. + +### Validation, acceptance, and rejection +52. The acceptance card validates the edited configuration before launch using the same rules as the GUI and shared execution path. +53. Invalid or incomplete configurations cannot launch. Enter leaves the card active and shows a visible reason directing the user to the field that needs attention. +54. Examples of blocked acceptance include an unavailable local configuration, an unsupported Cloud harness, and a required API-key choice that is still unset. +55. Accepting a valid request sends the edited request through the shared orchestration execution path and transitions the card to the existing spawning/result presentation. +56. Rejecting resolves the request as rejected and transitions the card to a non-interactive terminal presentation. +57. Spawning, mixed success, success, failure, cancellation, and denial use the existing TUI tool-status semantics and restore the input as described in (5). diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md new file mode 100644 index 00000000000..6a59002b8d3 --- /dev/null +++ b/specs/CODE-1822/TECH.md @@ -0,0 +1,91 @@ +# TECH: TUI Orchestration Permission and Configuration +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/CODE-1822/PRODUCT.md](./PRODUCT.md) +Inspected commit: `27da0f4885aa23603c4feb442c7806b0170cde70` + +## Context +### Shared wire types and execution (already frontend-agnostic) +- [`crates/ai/src/agent/action/mod.rs (214-249) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/ai/src/agent/action/mod.rs#L214-L249) — `RunAgentsRequest`, `RunAgentsExecutionMode`, `RunAgentsAgentRunConfig`. +- [`crates/ai/src/agent/orchestration_config.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/ai/src/agent/orchestration_config.rs) — `OrchestrationConfig`, `OrchestrationConfigStatus`, `matches_active_config`. +- [`app/src/ai/blocklist/action_model.rs (684-745) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/action_model.rs#L684-L745) — `execute_run_agents` (replaces the queued request with the user-edited one, then executes) and `deny_run_agents` (records a `Denied` result; used by the GUI for "accept without orchestration" and disapproved configs, not for plain rejection). +- [`app/src/ai/blocklist/action_model.rs (1036-1066) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/action_model.rs#L1036-L1066) — `cancel_action_with_id`; the GUI reject path (`RunAgentsCardViewEvent::RejectRequested` → `AIBlock::cancel_action`, [`block.rs:4845-4854`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4845-L4854), [`block.rs:7102-7106`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7102-L7106)). +- [`app/src/ai/blocklist/action_model/execute/run_agents.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/action_model/execute/run_agents.rs) — `RunAgentsExecutor`: validation, plan publication wait, per-child fan-out via `StartAgentExecutor`, `SpawningStarted`/`SpawningFinished` events. `resolve_request_from_config` consumes the shared `OrchestrationConfigState` from `app/src/ai/orchestration/`. + +### Shared orchestration domain and selector (landed earlier in this stack) +The frontend-neutral edit state, option snapshots, and the reusable selector this card consumes landed in the three PRs below this one; see their specs for details: +- [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md) — `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, transitions, providers, and validation helpers in `app/src/ai/orchestration/`. +- [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md) — `OptionSnapshot`/`OptionRow`/`OptionSourceStatus`/`OptionFooter` and the per-page snapshot builders, plus the GUI picker adaptation onto them. +- [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md) — the reusable `TuiOptionSelector` list primitive (`crates/warp_tui/src/option_selector.rs`) the card embeds for its configuration pages. + +Live catalogs come from `HarnessAvailabilityModel` ([`app/src/ai/harness_availability.rs`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/harness_availability.rs)), `LLMPreferences`, `CloudAmbientAgentEnvironment`, `ConnectedSelfHostedWorkersModel`, `CloudAgentSettings`, `UserWorkspaces`. + +### TUI plumbing +- [`crates/warp_tui/src/agent_block.rs (105-306) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/agent_block.rs#L105-L306) — `TuiToolCallView` enum plus `sync_action_views`, the lazy per-action child-view registration seam (currently `FileEdits`, `ShellCommand`). +- [`crates/warp_tui/src/terminal_session_view.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/terminal_session_view.rs) — renders transcript, inline menu, input box, footer; focuses the input at startup (620) and after restore flows (808, 839, 867). +- [`crates/warp_tui/src/inline_menu.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/inline_menu.rs) — `TuiInlineMenuHandle`/`TuiInlineMenuSnapshot`; scroll/selection math shared with GUI via `warp_search_core::inline_menu::InlineMenuSelection`. +- [`crates/warp_tui/src/tool_call_labels.rs (503-577) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tool_call_labels.rs#L503-L577) — existing static RunAgents status labels (kept for restored/terminal fallbacks). +- [`crates/warp_tui/src/tui_builder.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tui_builder.rs) — `TuiUiBuilder` theme→style recipes; all colors derive from `WarpTheme`, no raw hex. +- [`app/src/tui_export.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/tui_export.rs) — the sole `warp` → `warp_tui` export seam. + +There is no TUI permission/confirmation UI for `RunAgents` today and no generalized input-hiding mechanism; the closest precedent is the inline-menu overlay, which keeps the input visible and focused. + +## Proposed changes +### 1. TUI RunAgents card `crates/warp_tui/src/run_agents_card_view.rs` +New `TuiToolCallView::RunAgents(ViewHandle)` variant, constructed in `TuiAIBlock::sync_action_views` for `AIAgentActionType::RunAgents` actions (mirroring `ensure_run_agents_card_view`'s active-config lookup via `conversation.orchestration_config_for_plan(&request.plan_id)` at [`block.rs:7069-7083`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7069-L7083), including `update_request` re-syncs while streaming). + +View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. + +- Acceptance render (PRODUCT 9-17): title row, summary, agent list with identity glyph+color per agent, and a metadata section (Location/Harness/Model, plus API key/Host/Environment when Cloud) with bold selected values. State parity with the GUI card: seed from `RunAgentsRequest`, `resolve_from_config` for approved plan configs (matching the GUI card; the executor's unconditional `override_from_approved_config` remains the launch-time source of truth), `resolve_auth_secret_selection_for_harness` for unset auth. Harness rows render label-only in the TUI (labels/order/disabled reasons come from the shared snapshot; there is no TUI harness icon set). +- Keybindings registered in a new `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept, `ctrl-e` → Configure (acceptance mode context), `esc` → Back (configuring context), `ctrl-c` → Reject (both contexts). Digits/arrows are handled by the selector's bindings under the configuring context. +- Page sequencing (PRODUCT 18-28): `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. +- Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. +- Reject: emit an event the owning `TuiAIBlock` maps to `cancel_action_with_id(conversation_id, &action_id, CancellationReason::ManuallyCancelled, ctx)`, matching the GUI's `RejectRequested` semantics (`deny_run_agents` remains reserved for disapproved-config denial, which the TUI does not surface). +- Subscriptions: `RunAgentsExecutorEvent` (spawning presentation), `BlocklistAIActionEvent` (blocked/finished transitions), `HarnessAvailabilityEvent` (`Changed`, `AuthSecretsLoaded`, `AuthSecretsFetchFailed`, `AuthSecretDeleted` → `revalidate_after_catalog_change` + refresh the active selector snapshot), `LLMPreferencesEvent` (Oz model catalog), `ConnectedSelfHostedWorkersEvent` (host list). Retry from a `Failed` API-key page calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched` — the same lazy fetch the GUI triggers on picker population. +- Terminal states reuse the pure result-matching copy already in `tool_call_labels.rs` (503-577); restored blocks keep the existing fallback label path. +- The card never locks the terminal model; it renders from its own state and shared singletons. + +### 2. Generalized input replacement (derived, no stored flag) +Input visibility is a pure function of the front-of-queue blocker rather than a suppression boolean: +- `TuiAIBlock` gains `active_blocking_child(&self, ctx) -> Option` (`{ action_id, view_id }`): the front pending action for the conversation (`BlocklistAIActionModel::get_pending_action`) when its status is `Blocked` and its registered child view reports `wants_focus(ctx)`. `TuiRunAgentsCardView::wants_focus` is true in Acceptance/Configuring and false once accepted, rejected, spawning, or finished — matching PRODUCT (1-8). Deriving from the action queue (not transcript order) keeps semantics identical to the GUI's `focus_subview_if_necessary` ([`block.rs:4913-4954`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4913-L4954)). +- `TuiTranscriptView` exposes the same query over its agent blocks; `TuiTerminalSessionView::render` calls it once per pass. When `Some`, the session view omits the input box and normal footer from its element tree and the card renders its own hint footer; when `None`, it renders input + footer as today. +- Focus: on the `None → Some` transition the session view records that the input was focused and focuses the blocker view; on `Some(a) → Some(b)` it focuses `b` directly (no intermediate editable input, PRODUCT 6); on `Some → None` it restores focus to the input (PRODUCT 5). Draft/cursor/selection/scroll are untouched by construction — nothing in this path writes to the input model. +- Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. + +### 3. Theming and agent identity +`TuiUiBuilder` gains orchestration recipes, all pre-blended from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (magenta overlay over the probed base background, mirroring `input_background`'s accent recipe), `orchestration_title_style()`, `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations (PRODUCT 12); assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion (PRODUCT 13). The card captures the palette once at construction so identities stay stable across re-renders and edits (PRODUCT 11). + +### 4. Export seam +`tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. + +### 5. Frontend test seams +So the TUI card tests can exercise the real accept/reject paths: +- `BlocklistAIActionModel::cancel_action_with_id` becomes `pub`, letting the TUI reject path invoke it through the seam. +- `BlocklistAIActionModel::queue_pending_action_for_test` (test/`test-util` only) enqueues a `Blocked` pending action so frontend tests can drive confirmation flows against the real action model. +- `register_orchestration_test_singletons` in `tui_export.rs` (test/`test-util` only) registers the settings machinery, auth/server/cloud-object singletons, and catalog + permission models (including `AIDocumentModel` for plan publication) that the card's snapshot builders and accept path read; `app/Cargo.toml` widens the `test-util` feature to the crates these singletons need, and `tui_export_tests.rs` smoke-tests the bootstrap. + +## Testing and validation +TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): +- Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). +- Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch — PRODUCT (18-22). +- Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). +- Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). +- Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). +- Accept dispatch asserts `execute_run_agents` receives exactly the edited request (via the real `BlocklistAIActionModel` fixture used in `agent_block_tests.rs`); reject asserts `cancel_action_with_id` and terminal render — PRODUCT (55-57). +- `keybindings_tests.rs` validator covers the new bindings as TUI-owned. + +Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. + +Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. + +## Orchestration +The work ships as a four-PR Graphite stack, each mergeable on its own: +1. `harry/code-1822-edit-state` — the frontend-neutral orchestration domain module (`app/src/ai/orchestration/`: edit state, session, transitions, providers, validation) and the executor retarget; specified in [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md). +2. `harry/code-1822-option-snapshots` — option snapshots and their builders, plus the behavior-preserving GUI picker adaptation onto them; specified in [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md). +3. `harry/code-1822-tui-option-selector` — the reusable `TuiOptionSelector` primitive; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). +4. The final PR (this spec's remaining scope) — the TUI RunAgents card, generalized input replacement, theming and agent identity, and the frontend test seams, reviewed against the PRODUCT invariants. + +## Risks and mitigations +- Catalog events arriving mid-configuration can reshape option lists — the selector preserves the highlighted id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. +- Focus derivation vs. event ordering: `SpawningStarted` must flip `wants_focus` before the next render; both arrive through the same entity-event loop, and the render-time derivation (not cached state) makes late events self-correcting. +- Theme switches would rebuild the identity palette; the card pins its palette at construction so in-flight requests keep stable identities, at the cost of using pre-switch colors until the next request. From e9c64ab615a19fe7e6dccbf5cc40110aedb28f6f Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 17:39:15 -0400 Subject: [PATCH 07/40] align TUI orchestration card with Figma Keep persistent tinted card chrome, add Tab/arrow page navigation, match spacing/copy/footer styling, and normalize Tab events in the TUI runtime. Co-Authored-By: Oz --- crates/warp_tui/src/agent_block_tests.rs | 10 +- crates/warp_tui/src/agent_identity.rs | 12 +- crates/warp_tui/src/keybindings_tests.rs | 2 +- crates/warp_tui/src/run_agents_card_view.rs | 215 ++++++++++-------- .../src/run_agents_card_view_tests.rs | 145 +++++++++--- crates/warp_tui/src/terminal_session_view.rs | 13 +- specs/CODE-1822/PRODUCT.md | 17 +- specs/CODE-1822/TECH.md | 11 +- 8 files changed, 265 insertions(+), 160 deletions(-) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index d0c8a070446..789ae44c30a 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -1396,11 +1396,11 @@ fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmati let card = run_agents_card_view(&app, &block, &action.id); // Still streaming / not yet queued: the card renders the fallback - // status and does not hide the input (PRODUCT 7). + // status and does not hide the input. assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); // Queued and awaiting confirmation: the card is the active blocker - // (PRODUCT 1). + //. action_model.update(&mut app, |model, ctx| { model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); }); @@ -1410,7 +1410,7 @@ fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmati assert_eq!(blocker.view.id(), card.id()); // Reject through the card: the block maps it to manual cancellation - // and the blocker resolves, restoring the input (PRODUCT 5, 56). + // and the blocker resolves, restoring the input. app.update(|ctx| { ctx.dispatch_typed_action_for_view( card.window_id(ctx), @@ -1447,12 +1447,12 @@ fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { }); // Pending requests behind the front blocker do not affect input - // visibility (PRODUCT 4). + // visibility. let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); assert_eq!(blocker.expect("front blocker").action_id, first.id); // Resolving the front blocker hands off directly to the next queued - // blocking interaction (PRODUCT 6). + // blocking interaction. let first_card = run_agents_card_view(&app, &block, &first.id); app.update(|ctx| { ctx.dispatch_typed_action_for_view( diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/agent_identity.rs index a3d62c19762..29d6d08ed56 100644 --- a/crates/warp_tui/src/agent_identity.rs +++ b/crates/warp_tui/src/agent_identity.rs @@ -1,5 +1,5 @@ //! Deterministic color-and-glyph agent identities for the TUI orchestration -//! card (PRODUCT 11-13): a theme-derived palette of ANSI colors crossed with +//! card: a theme-derived palette of ANSI colors crossed with //! a curated glyph set, plus the stable hash and per-request assignment //! policy that keep identities stable across re-renders and edits. @@ -15,9 +15,8 @@ const AGENT_IDENTITY_GLYPHS: [&str; 8] = ["⟡", "⊹", "✶", "◊", "⊛", "*" /// to count as readable. const AGENT_IDENTITY_MIN_CONTRAST: f32 = 32.0; -/// The identity palette must offer at least this many combinations -/// (PRODUCT 12); when background filtering would drop below it, the -/// unfiltered ANSI set is used instead. +/// The identity palette must offer at least this many combinations; use the +/// unfiltered ANSI set when contrast filtering would drop below it. const AGENT_IDENTITY_MIN_COMBOS: usize = 32; /// One deterministic color-and-glyph agent identity. @@ -85,7 +84,7 @@ fn luma(color: ColorU) -> f32 { } /// Stable FNV-1a hash of an agent name; must not vary across runs or -/// platforms so identities stay deterministic (PRODUCT 11). +/// platforms so identities stay deterministic. pub(crate) fn stable_hash(name: &str) -> u64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in name.as_bytes() { @@ -97,8 +96,7 @@ pub(crate) fn stable_hash(name: &str) -> u64 { /// Assigns a palette index to each agent name: `stable_hash(name) % len`, /// with a first-come linear-probe fallback so identities within one request -/// stay distinct, cycling deterministically once the palette is exhausted -/// (PRODUCT 12-13). +/// stay distinct, cycling deterministically once the palette is exhausted. pub(crate) fn assign_agent_identity_indices( names: impl IntoIterator>, palette_len: usize, diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 1a75ca6520d..9fbca234e46 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -17,7 +17,7 @@ fn tui_ownership_is_by_name_prefix_or_group() { } /// Registering every TUI binding — including the orchestration card's -/// enter/ctrl-e/escape/ctrl-c set — must satisfy the debug-time +/// enter/ctrl-e/escape/ctrl-c and Tab/Left/Right navigation set — must satisfy the debug-time /// cross-surface validators, which panic on any keystroke binding matching /// a TUI view's context that is not TUI-owned. #[test] diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 689d9be37aa..c6c5923334e 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -1,5 +1,5 @@ //! [`TuiRunAgentsCardView`]: the TUI permission and configuration card for a -//! `RunAgents` request (PRODUCT 9-28). +//! `RunAgents` request. //! //! The card has two interactive modes: an acceptance card summarizing the //! request and its run-wide configuration, and a configuring mode that walks @@ -50,7 +50,7 @@ const CONFIGURING_CONTEXT_FLAG: &str = "TuiRunAgentsCardConfiguring"; /// Row ids emitted by `location_snapshot`. const LOCATION_CLOUD_ID: &str = "cloud"; -/// Registers the card's keybindings (PRODUCT 16, 26-28). Called once at TUI +/// Registers the card's keybindings. Called once at TUI /// startup from `keybindings::init`. All bindings are fixed and scoped to /// the card's keymap context, so they only fire while a card is focused. pub(crate) fn init(app: &mut AppContext) { @@ -77,6 +77,12 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP), FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), + FixedBinding::new("left", TuiRunAgentsCardAction::PreviousPage, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("right", TuiRunAgentsCardAction::NextPage, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("tab", TuiRunAgentsCardAction::NextPage, configuring()) + .with_group(TUI_BINDING_GROUP), FixedBinding::new( "ctrl-c", TuiRunAgentsCardAction::Reject, @@ -104,7 +110,7 @@ fn build_request(fields: &RunAgentsRequest, state: &OrchestrationConfigState) -> } } -/// One single-field configuration page (PRODUCT 18-19). +/// One single-field configuration page. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ConfigPage { Location, @@ -116,27 +122,16 @@ enum ConfigPage { } impl ConfigPage { - /// The page's header title. - fn title(self) -> &'static str { - match self { - Self::Location => "Location", - Self::Harness => "Harness", - Self::ApiKey => "API key", - Self::Host => "Host", - Self::Environment => "Environment", - Self::Model => "Model", - } - } - - /// The page's single question (PRODUCT 18). - fn question(self) -> &'static str { + /// The page's question, with agent/agents chosen from the request. + fn question(self, agent_count: usize) -> String { + let agent = if agent_count == 1 { "agent" } else { "agents" }; match self { - Self::Location => "Where should the agents run?", - Self::Harness => "Which harness should run the agents?", - Self::ApiKey => "Which API key should the agents use?", - Self::Host => "Which host should run the agents?", - Self::Environment => "Which environment should the agents use?", - Self::Model => "Which model should the agents use?", + Self::Location => format!("Where should the {agent} run?"), + Self::Harness => format!("Which harness should the {agent} use?"), + Self::ApiKey => format!("Which API key should the {agent} use?"), + Self::Host => format!("Which host should run the {agent}?"), + Self::Environment => format!("Which environment should the {agent} use?"), + Self::Model => format!("Which model should the {agent} use?"), } } } @@ -164,6 +159,8 @@ pub(crate) enum TuiRunAgentsCardAction { Accept, Configure, ConfirmSelection, + PreviousPage, + NextPage, Back, Reject, } @@ -188,12 +185,12 @@ pub(crate) struct TuiRunAgentsCardView { /// Whether the block was restored from history (non-interactive). is_restored: bool, spawning: Option, - /// Set once the request is accepted or rejected (PRODUCT 8). + /// Set once the request is accepted or rejected. decided: bool, - /// Validation reason shown inline after a blocked Accept (PRODUCT 53). + /// Validation reason shown inline after a blocked Accept. accept_error: Option, /// Identity palette pinned at construction so identities stay stable - /// across re-renders, edits, and theme switches (PRODUCT 11). + /// across re-renders, edits, and theme switches. identity_palette: Vec, } @@ -261,7 +258,7 @@ impl TuiRunAgentsCardView { }); // Live catalog changes revalidate the edit state and refresh the - // active page (PRODUCT 45, 49-50). + // active page. ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), |me, _, event, ctx| match event { @@ -416,8 +413,7 @@ impl TuiRunAgentsCardView { /// Whether this card is the active blocking interaction: interactive in /// Acceptance/Configuring while the action awaits confirmation, and - /// false once accepted, rejected, spawning, finished, or restored - /// (PRODUCT 1-8). + /// false once accepted, rejected, spawning, finished, or restored. pub(crate) fn wants_focus(&self, ctx: &AppContext) -> bool { if self.decided || self.spawning.is_some() || self.is_restored { return false; @@ -430,7 +426,7 @@ impl TuiRunAgentsCardView { ) } - /// The dynamic page sequence for the current edit state (PRODUCT 19-21). + /// The dynamic page sequence for the current edit state. fn page_sequence(state: &OrchestrationConfigState) -> Vec { if state.execution_mode.is_remote() { let mut pages = vec![ConfigPage::Location, ConfigPage::Harness]; @@ -470,9 +466,9 @@ impl TuiRunAgentsCardView { Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; let header = OptionSelectorHeader { - title: page.title().to_string(), + title: "Edit agent configuration".to_string(), position: (position, sequence.len()), - question: page.question().to_string(), + question: page.question(self.request_fields.agent_run_configs.len()), }; let snapshot = self.snapshot_for_page(page, ctx); self.selector.update(ctx, |selector, ctx| { @@ -484,7 +480,7 @@ impl TuiRunAgentsCardView { /// Refreshes the active page's snapshot in place after a catalog or /// state change, updating the header so the dynamic page count stays - /// current (PRODUCT 20). + /// current. fn refresh_active_page(&mut self, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -505,7 +501,7 @@ impl TuiRunAgentsCardView { } /// Triggers the lazy per-harness auth-secret fetch (also the Retry path - /// for a `Failed` API-key page, PRODUCT 48). + /// for a `Failed` API-key page). fn ensure_auth_secrets_fetched(&self, ctx: &mut ViewContext) { let Some(harness) = Harness::parse_orchestration_harness( &self @@ -520,8 +516,8 @@ impl TuiRunAgentsCardView { }); } - /// Applies a confirmed selection to the edit state (PRODUCT 24) and - /// advances to the next applicable page (PRODUCT 25-26). + /// Applies a confirmed selection to the edit state and + /// advances to the next applicable page. fn handle_page_confirmed(&mut self, id: &str, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -570,7 +566,7 @@ impl TuiRunAgentsCardView { } /// Advances past `page` in the (freshly recomputed) sequence, returning - /// to the acceptance card after the final page (PRODUCT 25-26). + /// to the acceptance card after the final page. fn advance_after(&mut self, page: ConfigPage, ctx: &mut ViewContext) { let sequence = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); @@ -589,6 +585,26 @@ impl TuiRunAgentsCardView { } } + /// Moves to an adjacent page without applying the current highlight. + fn navigate_page(&mut self, forward: bool, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { + return; + }; + let target = if forward { + sequence.get(index + 1) + } else { + index.checked_sub(1).and_then(|index| sequence.get(index)) + }; + if let Some(target) = target.copied() { + self.open_page(target, ctx); + } + } + /// Routes selector events for the active page. fn handle_selector_event( &mut self, @@ -629,7 +645,7 @@ impl TuiRunAgentsCardView { ) } - /// Accept (PRODUCT 52-55): validates with the shared gate; a blocked + /// Accept: validates with the shared gate; a blocked /// accept renders the reason inline and stays active, a valid one /// dispatches the edited request through `execute_run_agents`. fn handle_accept(&mut self, ctx: &mut ViewContext) { @@ -656,8 +672,8 @@ impl TuiRunAgentsCardView { ctx.notify(); } - /// Reject (PRODUCT 56): resolves the request as rejected exactly once, - /// from the acceptance card or any configuration page (PRODUCT 28). + /// Reject: resolves the request as rejected exactly once, + /// from the acceptance card or any configuration page. fn handle_reject(&mut self, ctx: &mut ViewContext) { if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { return; @@ -669,7 +685,7 @@ impl TuiRunAgentsCardView { ctx.notify(); } - /// Opens configuration on the first page (PRODUCT 16). + /// Opens configuration on the first page. fn handle_configure(&mut self, ctx: &mut ViewContext) { if !self.wants_focus(ctx) { return; @@ -683,8 +699,8 @@ impl TuiRunAgentsCardView { } /// Escape from configuration: completed pages keep their confirmed - /// selections; the current page's unconfirmed highlight is discarded - /// (PRODUCT 27). Active custom-text editing unwinds first. + /// selections; the current page's unconfirmed highlight is discarded. + /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { let consumed = self .selector @@ -699,7 +715,7 @@ impl TuiRunAgentsCardView { } } - /// Confirms the selector's highlighted option (Enter, PRODUCT 31). + /// Confirms the selector's highlighted option (Enter). fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { self.selector.update(ctx, |selector, ctx| { selector.confirm_highlighted(ctx); @@ -709,7 +725,7 @@ impl TuiRunAgentsCardView { // ── Rendering ─────────────────────────────────────────────────── /// Deterministic identities for the proposed agents, from the pinned - /// palette (PRODUCT 11-13). + /// palette. fn agent_identities(&self) -> Vec<&AgentIdentity> { let names = self .request_fields @@ -722,7 +738,7 @@ impl TuiRunAgentsCardView { .collect() } - /// The harness display label for the current selection (PRODUCT 39). + /// The harness display label for the current selection. fn harness_label(&self, ctx: &AppContext) -> String { match Harness::parse_orchestration_harness( &self @@ -767,20 +783,11 @@ impl TuiRunAgentsCardView { .finish() } - /// The acceptance card body (PRODUCT 9-17). + /// The acceptance card body. fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { let state = &self.orchestration_edit_state.orchestration_config_state; let mut column = TuiFlex::column(); - // Title row with the attention glyph, on the tinted header. - column.add_child( - TuiText::from_spans([( - format!("◆ {RUN_AGENTS_CARD_TITLE}"), - builder.orchestration_title_style(), - )]) - .finish(), - ); - let summary = if self.request_fields.summary.trim().is_empty() { format!( "Spawn {} agent(s) to address this task.", @@ -832,7 +839,7 @@ impl TuiRunAgentsCardView { ); } - // Run-wide configuration values (PRODUCT 9-10, 14). + // Run-wide configuration values. let is_remote = state.execution_mode.is_remote(); let mut metadata = TuiFlex::column(); metadata.add_child(Self::render_metadata_row( @@ -894,7 +901,7 @@ impl TuiRunAgentsCardView { .finish(), ); - // Inline validation (PRODUCT 53) or the soft empty-env nudge. + // Inline validation or the soft empty-env nudge. if let Some(error) = &self.accept_error { column.add_child( TuiText::new(error.clone()) @@ -910,37 +917,46 @@ impl TuiRunAgentsCardView { ); } - // Action hints replace the normal input footer (PRODUCT 2, 16-17); - // they wrap rather than truncate so every available action stays - // visible at narrow widths (PRODUCT 15). - column.add_child( - TuiContainer::new( - TuiText::new("enter accept · ctrl-e configure · ctrl-c reject") - .with_style(builder.muted_text_style()) - .finish(), - ) - .with_padding_top(1) - .finish(), - ); - column.finish() } + /// The persistent title row shared by acceptance and configuration. + fn render_title(&self, builder: &TuiUiBuilder) -> Box { + TuiText::from_spans([ + ("■ ".to_string(), builder.attention_glyph_style()), + ( + RUN_AGENTS_CARD_TITLE.to_string(), + builder.primary_text_style(), + ), + ]) + .finish() + } - /// The configuring body: the active selector page plus its hints (which - /// wrap rather than truncate at narrow widths, PRODUCT 15). - fn render_configuring(&self, builder: &TuiUiBuilder) -> Box { - TuiFlex::column() - .child(TuiChildView::new(&self.selector).finish()) - .child( - TuiContainer::new( - TuiText::new("↑↓ move · 1-9 select · enter confirm · esc back · ctrl-c reject") - .with_style(builder.muted_text_style()) - .finish(), - ) - .with_padding_top(1) - .finish(), - ) - .finish() + /// The configuring body: the active selector page. + fn render_configuring(&self) -> Box { + TuiChildView::new(&self.selector).finish() + } + + /// Key hints shown below (not inside) the tinted card. + fn render_footer(&self, builder: &TuiUiBuilder) -> Box { + let spans = match self.mode { + CardMode::Acceptance => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Ctrl-E ".to_string(), builder.primary_text_style()), + ("to configure ".to_string(), builder.muted_text_style()), + ("Ctrl-C ".to_string(), builder.primary_text_style()), + ("to reject".to_string(), builder.muted_text_style()), + ], + CardMode::Configuring { .. } => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Tab or ← →".to_string(), builder.primary_text_style()), + (" to navigate ".to_string(), builder.muted_text_style()), + ("Esc ".to_string(), builder.primary_text_style()), + ("to go back".to_string(), builder.muted_text_style()), + ], + }; + TuiText::from_spans(spans).finish() } } @@ -974,8 +990,7 @@ impl TuiView for TuiRunAgentsCardView { .get_action_status(&self.action_id); // Terminal, spawning, restored, and still-streaming states reuse the - // shared fallback tool-call row and its `tool_call_labels` copy - // (PRODUCT 7, 57). + // shared fallback tool-call row and its `tool_call_labels` copy. let interactive = !self.is_restored && self.spawning.is_none() && matches!(status, Some(AIActionStatus::Blocked)); @@ -992,13 +1007,23 @@ impl TuiView for TuiRunAgentsCardView { let builder = TuiUiBuilder::from_app(app); let body = match self.mode { CardMode::Acceptance => self.render_acceptance(app, &builder), - CardMode::Configuring { .. } => self.render_configuring(&builder), + CardMode::Configuring { .. } => TuiContainer::new(self.render_configuring()) + .with_padding_top(1) + .with_padding_x(2) + .finish(), }; - // The orchestration treatment: a themed magenta-tinted surface - // (PRODUCT 14), padded one cell on each side. - TuiContainer::new(body) - .with_background(builder.orchestration_surface_background()) - .with_padding_x(1) + let card = TuiContainer::new( + TuiFlex::column() + .child(self.render_title(&builder)) + .child(body) + .finish(), + ) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(1) + .finish(); + TuiFlex::column() + .child(card) + .child(self.render_footer(&builder)) .finish() } } @@ -1011,6 +1036,8 @@ impl TypedActionView for TuiRunAgentsCardView { TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), + TuiRunAgentsCardAction::PreviousPage => self.navigate_page(false, ctx), + TuiRunAgentsCardAction::NextPage => self.navigate_page(true, ctx), TuiRunAgentsCardAction::Back => self.handle_back(ctx), TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index d29f05b5678..22276b69634 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -12,7 +12,7 @@ use warp::tui_export::{ use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle}; use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; -use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::presenter::tui::{TuiFrame, TuiPresenter}; use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; use super::{ @@ -23,6 +23,7 @@ use crate::option_selector::TuiOptionSelectorAction; use crate::test_fixtures::{ add_active_test_conversation, add_test_action_model_with_surface, TestHostView, }; +use crate::tui_builder::TuiUiBuilder; /// Builds a request with the given harness and execution mode. fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { @@ -162,7 +163,7 @@ fn build_request_carries_card_fields_and_edited_run_wide_state() { assert_eq!(built.agent_run_configs, original.agent_run_configs); assert_eq!(built.plan_id, original.plan_id); // Run-wide fields come from the edited state; the per-call - // computer-use flag is preserved through the round trip (PRODUCT 46). + // computer-use flag is preserved through the round trip. assert_eq!(built.model_id, "gpt-5"); assert_eq!(built.harness_type, "codex"); assert_eq!( @@ -255,15 +256,14 @@ fn blocked_card(app: &mut App, request: &RunAgentsRequest) -> BlockedCard { } } -/// Renders the card through the real presenter (so the embedded selector -/// child view resolves) and returns trimmed lines at `width`. -fn render_card_lines( +/// Renders the card through the real presenter so child views resolve. +fn render_card_frame( app: &mut App, card: &ViewHandle, width: u16, -) -> Vec { +) -> TuiFrame { let mut presenter = TuiPresenter::new(); - let frame = app.update(|ctx| { + app.update(|ctx| { let window_id = card.window_id(ctx); // Mirror the runtime's draw: `invalidate` renders the card and its // selector into the presenter cache, then `present` resolves the @@ -273,13 +273,20 @@ fn render_card_lines( invalidation.updated.insert(card.as_ref(ctx).selector.id()); presenter.invalidate(&invalidation, ctx, window_id); presenter.present(ctx, card, TuiRect::new(0, 0, width, 60)) - }); - frame + }) +} + +/// Returns the card's trimmed rendered lines at `width`. +fn render_card_lines( + app: &mut App, + card: &ViewHandle, + width: u16, +) -> Vec { + render_card_frame(app, card, width) .buffer .to_lines() .into_iter() .map(|line| line.trim_end().to_owned()) - .filter(|line| !line.is_empty()) .collect() } @@ -311,7 +318,7 @@ fn acceptance_card_renders_required_content_across_widths() { for width in [40u16, 80, 132] { let lines = render_card_lines(&mut app, &fixture.card, width); let all = lines.join("\n"); - // PRODUCT 9: question, summary, agent names, and run-wide values. + // question, summary, agent names, and run-wide values. assert!(all.contains("Can I start"), "width {width}: {all}"); assert!(all.contains("Parallelize the task."), "width {width}"); assert!(all.contains("Agents (2)"), "width {width}"); @@ -323,11 +330,11 @@ fn acceptance_card_renders_required_content_across_widths() { assert!(all.contains("Model"), "width {width}"); assert!(all.contains("Host"), "width {width}"); assert!(all.contains("Environment"), "width {width}"); - // PRODUCT 16-17: the footer hints replace the input footer and - // wrap (rather than clip) at narrow widths (PRODUCT 15). - assert!(all.contains("enter accept"), "width {width}"); - assert!(all.contains("ctrl-e configure"), "width {width}"); - assert!(all.contains("ctrl-c reject"), "width {width}"); + // the footer hints replace the input footer and + // wrap (rather than clip) at narrow widths. + assert!(all.contains("Enter to accept"), "width {width}: {all}"); + assert!(all.contains("Ctrl-E to configure"), "width {width}: {all}"); + assert!(all.contains("Ctrl-C to reject"), "width {width}: {all}"); } }); } @@ -344,7 +351,7 @@ fn agent_identities_stay_stable_across_rerenders_and_edits() { .expect("agent row") } let before = agent_line(&mut app, &fixture); - // Stable across plain re-renders (PRODUCT 11)… + // Stable across plain re-renders… assert_eq!(before, agent_line(&mut app, &fixture)); // …and across a streamed edit that appends an agent. let mut extended = base.clone(); @@ -372,14 +379,14 @@ fn accept_dispatches_through_the_action_model_exactly_once() { act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); // The action left the Blocked queue through `execute_run_agents` - // (PRODUCT 55) and the card stopped blocking the input (PRODUCT 5). + // and the card stopped blocking the input. assert!(!matches!( action_status(&app, &fixture), Some(AIActionStatus::Blocked) | None )); assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); - // A second decision is a no-op (PRODUCT 8): no reject can follow. + // A second decision is a no-op: no reject can follow. act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); assert!(!fixture .events @@ -401,7 +408,7 @@ fn reject_emits_the_cancellation_event_exactly_once() { .iter() .filter(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested)) .count(); - // Exactly one decision (PRODUCT 8, 56); the card stops blocking. + // Exactly one decision; the card stops blocking. assert_eq!(rejects, 1); assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); }); @@ -410,10 +417,10 @@ fn reject_emits_the_cancellation_event_exactly_once() { #[test] fn invalid_configurations_cannot_launch_and_surface_a_reason() { App::test((), |mut app| async move { - // OpenCode + Cloud is a hard block (PRODUCT 54). + // OpenCode + Cloud is a hard block. let fixture = blocked_card(&mut app, &request("opencode", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); - // The card stays active and blocked (PRODUCT 53), showing the reason. + // The card stays active and blocked, showing the reason. assert!(matches!( action_status(&app, &fixture), Some(AIActionStatus::Blocked) @@ -429,18 +436,33 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - // First page of the Cloud sequence with its dynamic count - // (PRODUCT 18-19) and the configuring hints. - assert!(all.contains("Location")); - assert!(all.contains("1 of 5")); - assert!(all.contains("Where should the agents run?")); - assert!(all.contains("esc back")); - - // Esc returns to the acceptance card without deciding (PRODUCT 27). + let lines = render_card_lines(&mut app, &fixture.card, 80); + let all = lines.join("\n"); + assert!(lines[0].contains("■ Can I start additional agents for this task?")); + assert!(lines[1].trim().is_empty()); + assert!(lines[2].starts_with(" Edit agent configuration")); + assert!(lines[2].contains("← 1 of 5 →")); + assert!(lines[3].trim().is_empty()); + assert!(lines[4].starts_with(" Where should the agent run?")); + assert!(lines[5].starts_with(" (1) Cloud")); + assert!(all.contains("Enter to accept")); + assert!(all.contains("Tab or ← → to navigate")); + assert!(all.contains("Esc to go back")); + + let frame = render_card_frame(&mut app, &fixture.card, 80); + let surface = + app.read(|app| TuiUiBuilder::from_app(app).orchestration_surface_background()); + assert_eq!(frame.buffer[(0, 0)].bg, surface); + let footer_row = lines + .iter() + .position(|line| line.contains("Enter to accept")) + .expect("configuration footer row"); + assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface); + + // Esc returns to the acceptance card without deciding. act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("enter accept")); + assert!(all.contains("Enter to accept")); assert!(matches!( action_status(&app, &fixture), Some(AIActionStatus::Blocked) @@ -454,7 +476,7 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); // Highlight "Local" (second row) and confirm it: the sequence - // collapses to Location, Model and advances to Model (PRODUCT 21). + // collapses to Location, Model and advances to Model. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); @@ -465,7 +487,62 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { TuiRunAgentsCardAction::ConfirmSelection, ); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Model"), "{all}"); + assert!(all.contains("Which model should the agent use?"), "{all}"); assert!(all.contains("2 of 2"), "{all}"); }); } + +#[test] +fn horizontal_navigation_moves_between_pages_without_applying_highlights() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + + // Highlight Local, then navigate away without confirming it. + let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 2 of 5 →")); + assert!(all.contains("Which harness should the agent use?")); + assert!(app.read(|app| { + fixture + .card + .as_ref(app) + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + })); + + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::PreviousPage, + ); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 1 of 5 →")); + + // Previous on the first page is clamped. + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::PreviousPage, + ); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 1 of 5 →")); + + for _ in 0..10 { + act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + } + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 5 of 5 →")); + + // Next on the final page is clamped. + act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 5 of 5 →")); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index d472d85e590..6315c87943e 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1022,17 +1022,16 @@ impl TuiTerminalSessionView { } } - /// The active front-of-queue blocking interaction, if any (PRODUCT 1, 4). + /// The active front-of-queue blocking interaction, if any. fn active_blocking_child(&self, ctx: &AppContext) -> Option { self.transcript.as_ref(ctx).active_blocking_child(ctx) } /// Reconciles focus with the derived blocker: a newly active blocker is /// focused (handing off directly between consecutive blockers with no - /// intermediate editable input, PRODUCT 6), and focus returns to the - /// input when the last blocker resolves (PRODUCT 5). Nothing here writes - /// to the input model, so its draft/cursor/selection are untouched - /// (PRODUCT 3). + /// intermediate editable input), and focus returns to the input when the + /// last blocker resolves. Nothing here writes to the input model, so its + /// draft/cursor/selection are untouched. fn sync_blocker_focus(&mut self, ctx: &mut ViewContext) { let blocker = self.active_blocking_child(ctx); let blocker_view_id = blocker.as_ref().map(|child| child.view.id()); @@ -2322,10 +2321,10 @@ impl TuiView for TuiTerminalSessionView { // While a `RunAgents` card (or another blocking interaction) is the // active front-of-queue blocker, the input box, inline menus, and // normal footer are omitted; the blocker renders its own action - // hints in their place (PRODUCT 1-2). Visibility is derived fresh + // hints in their place. Visibility is derived fresh // each pass — no stored suppression flag — and the hidden input // model is never written to, so its draft/cursor/selection/scroll - // survive untouched (PRODUCT 3). + // survive untouched. let blocker_active = self.active_blocking_child(ctx).is_some(); if !blocker_active && !inline_process_owns_input { if let Some(menu) = inline_menu { diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 3428037110a..93c2b182c70 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -43,7 +43,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. 12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. 13. If a future request exceeds the number of unique combinations, the palette cycles deterministically. No agent is omitted and rendering does not fail. -14. The card uses the orchestration treatment from the designs: a themed magenta-tinted body and header, an attention glyph, primary text for content, muted separators and metadata, and bold emphasis for selected configuration values. +14. The card uses the orchestration treatment from the designs: one 10%-magenta-tinted body/header surface, a yellow square attention glyph, primary text for content, muted separators and metadata, and bold magenta emphasis for selected configuration options. 15. Text and agent identities wrap and reflow at narrow terminal widths. If the complete card cannot fit vertically, it remains navigable without clipping required configuration or actions. 16. On the acceptance card: - Enter accepts the current configuration. @@ -52,7 +52,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 17. The footer renders these bindings using the active theme and the exact actions available in the current state. ### Configuration flow -18. Configuration is a sequence of single-field pages. Each page shows a title, its position in the current sequence, one question, a selectable option list, and contextual keybinding hints. +18. Configuration is a sequence of single-field pages. The tinted card keeps the permission title visible, then shows `Edit agent configuration` with right-aligned `← n of m →` navigation, one bold question, a selectable option list, and contextual keybinding hints below the tinted surface. 19. Cloud uses this order: 1. Location 2. Harness @@ -65,6 +65,9 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 22. Selecting Cloud restores the applicable Cloud page sequence and valid selections from the current edit session when possible. 23. Each page initially highlights the request's current value, including values inherited from an approved plan configuration. If the current value is unavailable, the page highlights the appropriate valid default and clearly reflects the replacement. 24. A confirmed selection is saved immediately to the edit session. + - Tab and Right move to the next applicable page without confirming the current highlight. + - Left moves to the previous applicable page without confirming the current highlight. + - Navigation clamps at the first and final pages; the unavailable boundary arrow is muted. 25. Confirming a selection on a non-final page advances to the next applicable page. 26. Confirming the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. 27. Esc returns to the acceptance card and retains selections confirmed on completed pages. The current page's highlighted but unconfirmed option is discarded. @@ -72,13 +75,13 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ### General option selection 29. Every configuration page uses the same option-selection behavior and presentation. -30. Up and Down move the highlight through enabled options without confirming a value. Navigation scrolls when the highlight moves beyond the visible viewport. +30. Up and Down move the highlight through options without confirming a value. Four rows are visible at once; navigation scrolls when the highlight moves beyond that viewport and shows `↑` / `↓` overflow markers. 31. Enter confirms the highlighted option. -32. Number keys 1–9 confirm the corresponding visible option and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. -33. Options beyond the first nine visible rows remain reachable with scrolling, Up and Down, and Enter. +32. Number keys 1–9 confirm the corresponding visible option, when present, and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. +33. Options beyond the four-row viewport remain reachable with scrolling, Up and Down, and Enter. 34. Clicking an enabled option confirms it and advances exactly like Enter. 35. Mouse-wheel and trackpad input scroll lists that exceed the available height. -36. Disabled rows can be highlighted for context but cannot be confirmed. They show a concise reason when one is available. +36. Rows render with `(n)` number prefixes. The selected row is bold magenta without a separate marker or background. Disabled rows can be highlighted for context but cannot be confirmed; they show a concise reason when available. 37. Empty lists show a non-selectable empty state rather than leaving a blank surface. ### Field-specific choices @@ -93,7 +96,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ - Existing named managed secrets valid for the selected harness. - No resource-creation option. 42. Host appears only for Cloud. It offers the Warp-hosted option, the workspace default when configured, known connected self-hosted workers, the user's recent custom host when available, and a custom-host text-entry option. -43. A custom host is trimmed and validated before it is confirmed. Invalid or empty custom input remains editable and shows a concise error. +43. A custom host is trimmed and validated before it is confirmed. Invalid or empty custom input remains editable and shows a concise error. Once confirmed, the user-entered value replaces the generic `Custom host…` option text and pre-fills the editor if selected again. 44. Environment appears only for Cloud. It offers “Empty environment” plus existing environments, using the same labels and default-selection behavior as the GUI. It does not offer environment creation. 45. Switching Location or Harness revalidates all dependent fields: - Local forces Warp and removes Cloud-only values from the launch configuration. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 6a59002b8d3..fd70fb2b13f 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -35,9 +35,9 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. -- Acceptance render (PRODUCT 9-17): title row, summary, agent list with identity glyph+color per agent, and a metadata section (Location/Harness/Model, plus API key/Host/Environment when Cloud) with bold selected values. State parity with the GUI card: seed from `RunAgentsRequest`, `resolve_from_config` for approved plan configs (matching the GUI card; the executor's unconditional `override_from_approved_config` remains the launch-time source of truth), `resolve_auth_secret_selection_for_harness` for unset auth. Harness rows render label-only in the TUI (labels/order/disabled reasons come from the shared snapshot; there is no TUI harness icon set). -- Keybindings registered in a new `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept, `ctrl-e` → Configure (acceptance mode context), `esc` → Back (configuring context), `ctrl-c` → Reject (both contexts). Digits/arrows are handled by the selector's bindings under the configuring context. -- Page sequencing (PRODUCT 18-28): `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. +- Shared card chrome: a persistent yellow-square permission title over a single 10%-magenta surface in both modes. Acceptance renders summary, agent identities, and run-wide metadata. Configuration adds one blank row, then an inner two-cell inset containing `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface. +- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option highlight. +- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. - Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. - Reject: emit an event the owning `TuiAIBlock` maps to `cancel_action_with_id(conversation_id, &action_id, CancellationReason::ManuallyCancelled, ctx)`, matching the GUI's `RejectRequested` semantics (`deny_run_agents` remains reserved for disapproved-config denial, which the TUI does not surface). - Subscriptions: `RunAgentsExecutorEvent` (spawning presentation), `BlocklistAIActionEvent` (blocked/finished transitions), `HarnessAvailabilityEvent` (`Changed`, `AuthSecretsLoaded`, `AuthSecretsFetchFailed`, `AuthSecretDeleted` → `revalidate_after_catalog_change` + refresh the active selector snapshot), `LLMPreferencesEvent` (Oz model catalog), `ConnectedSelfHostedWorkersEvent` (host list). Retry from a `Failed` API-key page calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched` — the same lazy fetch the GUI triggers on picker population. @@ -52,7 +52,7 @@ Input visibility is a pure function of the front-of-queue blocker rather than a - Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. ### 3. Theming and agent identity -`TuiUiBuilder` gains orchestration recipes, all pre-blended from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (magenta overlay over the probed base background, mirroring `input_background`'s accent recipe), `orchestration_title_style()`, `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations (PRODUCT 12); assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion (PRODUCT 13). The card captures the palette once at construction so identities stay stable across re-renders and edits (PRODUCT 11). +`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_option_selected_style()` (bold full-strength magenta), `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. @@ -67,7 +67,8 @@ So the TUI card tests can exercise the real accept/reject paths: TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): - Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). - Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch — PRODUCT (18-22). +- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, four-row viewport, and external footer styling. +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed highlights. - Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). - Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). - Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). From 1a96005119c5d792837ad2066a56d00c5ebfbd54 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 18:00:14 -0400 Subject: [PATCH 08/40] fix styling --- crates/warp_tui/src/run_agents_card_view.rs | 201 ++++++++---------- .../src/run_agents_card_view_tests.rs | 118 ++++++++-- crates/warp_tui/src/tui_builder.rs | 12 ++ specs/CODE-1822/PRODUCT.md | 15 +- specs/CODE-1822/TECH.md | 4 +- 5 files changed, 212 insertions(+), 138 deletions(-) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index c6c5923334e..e8a41f140e2 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -769,89 +769,43 @@ impl TuiRunAgentsCardView { }) } - /// One `label: value` metadata row with a bold selected value. - fn render_metadata_row( - label: &str, - value: String, - builder: &TuiUiBuilder, - ) -> Box { - TuiText::from_spans([ - (format!("{label:<12}"), builder.muted_text_style()), - (value, builder.orchestration_selected_value_style()), - ]) - .truncate() - .finish() - } - - /// The acceptance card body. - fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { - let state = &self.orchestration_edit_state.orchestration_config_state; - let mut column = TuiFlex::column(); - - let summary = if self.request_fields.summary.trim().is_empty() { - format!( - "Spawn {} agent(s) to address this task.", - self.request_fields.agent_run_configs.len() - ) - } else { - self.request_fields.summary.clone() - }; - column.add_child( - TuiContainer::new( - TuiText::new(summary) - .with_style(builder.primary_text_style()) - .finish(), - ) - .with_padding_top(1) - .finish(), - ); - - // Agent list: every proposed agent's name with its identity. - column.add_child( - TuiContainer::new( - TuiText::new(format!( - "Agents ({})", - self.request_fields.agent_run_configs.len() - )) - .with_style(builder.muted_text_style()) - .truncate() - .finish(), - ) - .with_padding_top(1) - .finish(), - ); - for (config, identity) in self + /// The wrapping agent-identity line: every proposed agent's glyph and + /// name in its identity color, separated by muted bullets. + fn render_agent_identity_line(&self, builder: &TuiUiBuilder) -> Box { + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (config, identity)) in self .request_fields .agent_run_configs .iter() .zip(self.agent_identities()) + .enumerate() { - column.add_child( - TuiText::from_spans([ - ( - format!("{} ", identity.glyph), - identity.style.add_modifier(Modifier::BOLD), - ), - (config.name.clone(), builder.primary_text_style()), - ]) - .truncate() - .finish(), - ); + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{} ", identity.glyph), identity.style)); + spans.push(( + config.name.clone(), + identity.style.add_modifier(Modifier::BOLD), + )); } + TuiText::from_spans(spans).finish() + } - // Run-wide configuration values. + /// The wrapping inline `Label: value` metadata line with muted bullet + /// separators and bold values. + fn render_metadata_line( + &self, + app: &AppContext, + builder: &TuiUiBuilder, + ) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; let is_remote = state.execution_mode.is_remote(); - let mut metadata = TuiFlex::column(); - metadata.add_child(Self::render_metadata_row( + let mut entries: Vec<(&str, String)> = vec![( "Location", if is_remote { "Cloud" } else { "Local" }.to_string(), - builder, - )); - metadata.add_child(Self::render_metadata_row( - "Harness", - self.harness_label(app), - builder, - )); + )]; + entries.push(("Harness", self.harness_label(app))); if is_remote { if should_show_auth_secret_picker(state) { let api_key = match &state.auth_secret_selection { @@ -861,7 +815,7 @@ impl TuiRunAgentsCardView { "Select an API key".to_string() } }; - metadata.add_child(Self::render_metadata_row("API key", api_key, builder)); + entries.push(("API key", api_key)); } let host = match &state.execution_mode { RunAgentsExecutionMode::Remote { worker_host, .. } @@ -873,33 +827,59 @@ impl TuiRunAgentsCardView { ORCHESTRATION_WARP_WORKER_HOST.to_string() } }; - metadata.add_child(Self::render_metadata_row("Host", host, builder)); + entries.push(("Host", host)); let environment_id = match &state.execution_mode { RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), RunAgentsExecutionMode::Local => String::new(), }; - let environment = Self::label_for_id( - &environment_snapshot(state, app), - &environment_id, - "Empty environment", - ); - metadata.add_child(Self::render_metadata_row( + entries.push(( "Environment", - environment, - builder, + Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ), )); } - let model = Self::label_for_id( - &model_snapshot(state, app), - &state.model_id, - "Default model", - ); - metadata.add_child(Self::render_metadata_row("Model", model, builder)); + entries.push(( + "Model", + Self::label_for_id( + &model_snapshot(state, app), + &state.model_id, + "Default model", + ), + )); + + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (label, value)) in entries.into_iter().enumerate() { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{label}: "), builder.primary_text_style())); + spans.push((value, builder.orchestration_selected_value_style())); + } + TuiText::from_spans(spans).finish() + } + + /// The acceptance card body: the agent list and the inline run-wide + /// configuration values. The request summary is not repeated here; it + /// streams into the transcript above the card. + fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let mut column = TuiFlex::column(); + column.add_child( - TuiContainer::new(metadata.finish()) - .with_padding_top(1) - .finish(), + TuiText::new(format!( + "Agents ({}):", + self.request_fields.agent_run_configs.len() + )) + .with_style(builder.primary_text_style()) + .truncate() + .finish(), ); + column.add_child(self.render_agent_identity_line(builder)); + column.add_child(TuiText::new(" ").finish()); + column.add_child(self.render_metadata_line(app, builder)); // Inline validation or the soft empty-env nudge. if let Some(error) = &self.accept_error { @@ -942,10 +922,10 @@ impl TuiRunAgentsCardView { CardMode::Acceptance => vec![ ("Enter ".to_string(), builder.primary_text_style()), ("to accept ".to_string(), builder.muted_text_style()), - ("Ctrl-E ".to_string(), builder.primary_text_style()), - ("to configure ".to_string(), builder.muted_text_style()), - ("Ctrl-C ".to_string(), builder.primary_text_style()), - ("to reject".to_string(), builder.muted_text_style()), + ("Ctrl + E".to_string(), builder.primary_text_style()), + (" to edit ".to_string(), builder.muted_text_style()), + ("Ctrl + C".to_string(), builder.primary_text_style()), + (" to reject".to_string(), builder.muted_text_style()), ], CardMode::Configuring { .. } => vec![ ("Enter ".to_string(), builder.primary_text_style()), @@ -1005,24 +985,25 @@ impl TuiView for TuiRunAgentsCardView { } let builder = TuiUiBuilder::from_app(app); + // The header row carries a stronger tint than the body, per the + // design's stacked header overlays. + let header = TuiContainer::new(self.render_title(&builder)) + .with_background(builder.orchestration_header_background()) + .with_padding_x(1) + .finish(); let body = match self.mode { CardMode::Acceptance => self.render_acceptance(app, &builder), - CardMode::Configuring { .. } => TuiContainer::new(self.render_configuring()) - .with_padding_top(1) - .with_padding_x(2) - .finish(), + CardMode::Configuring { .. } => self.render_configuring(), }; - let card = TuiContainer::new( - TuiFlex::column() - .child(self.render_title(&builder)) - .child(body) - .finish(), - ) - .with_background(builder.orchestration_surface_background()) - .with_padding_x(1) - .finish(); + let body = TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(3) + .with_padding_top(1) + .with_padding_bottom(1) + .finish(); TuiFlex::column() - .child(card) + .child(header) + .child(body) .child(self.render_footer(&builder)) .finish() } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index 22276b69634..697918d5d0b 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -11,7 +11,7 @@ use warp::tui_export::{ }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle}; -use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; +use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; use warpui_core::presenter::tui::{TuiFrame, TuiPresenter}; use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; @@ -318,27 +318,98 @@ fn acceptance_card_renders_required_content_across_widths() { for width in [40u16, 80, 132] { let lines = render_card_lines(&mut app, &fixture.card, width); let all = lines.join("\n"); - // question, summary, agent names, and run-wide values. + // question, agent names, and run-wide values. assert!(all.contains("Can I start"), "width {width}: {all}"); - assert!(all.contains("Parallelize the task."), "width {width}"); - assert!(all.contains("Agents (2)"), "width {width}"); + assert!(all.contains("Agents (2):"), "width {width}"); assert!(all.contains("researcher"), "width {width}"); assert!(all.contains("reviewer"), "width {width}"); - assert!(all.contains("Location"), "width {width}"); + assert!(all.contains("Location:"), "width {width}"); assert!(all.contains("Cloud"), "width {width}"); - assert!(all.contains("Harness"), "width {width}"); - assert!(all.contains("Model"), "width {width}"); - assert!(all.contains("Host"), "width {width}"); - assert!(all.contains("Environment"), "width {width}"); - // the footer hints replace the input footer and - // wrap (rather than clip) at narrow widths. - assert!(all.contains("Enter to accept"), "width {width}: {all}"); - assert!(all.contains("Ctrl-E to configure"), "width {width}: {all}"); - assert!(all.contains("Ctrl-C to reject"), "width {width}: {all}"); + assert!(all.contains("Harness:"), "width {width}"); + assert!(all.contains("Model:"), "width {width}"); + assert!(all.contains("Host:"), "width {width}"); + assert!(all.contains("Environment:"), "width {width}"); + // The footer hints replace the input footer and wrap (rather + // than clip) at narrow widths; compare whitespace-normalized + // so a wrapped key name still matches. + let flat = all.split_whitespace().collect::>().join(" "); + assert!(flat.contains("Enter to accept"), "width {width}: {all}"); + assert!(flat.contains("Ctrl + E to edit"), "width {width}: {all}"); + assert!(flat.contains("Ctrl + C to reject"), "width {width}: {all}"); } }); } +#[test] +fn acceptance_card_matches_the_design_layout_and_styles() { + App::test((), |mut app| async move { + let mut seven_agents = request("oz", remote("", "warp")); + seven_agents.agent_run_configs = [ + "infrastructure-bot", + "ui-implementer", + "dependency-bot", + "verification-bot", + "design-bot", + "event-pipeline-monitor", + "performance-regression-guard", + ] + .into_iter() + .map(|name| RunAgentsAgentRunConfig { + name: name.to_string(), + prompt: "work".to_string(), + title: name.to_string(), + }) + .collect(); + let fixture = blocked_card(&mut app, &seven_agents); + + let lines = render_card_lines(&mut app, &fixture.card, 80); + // Header row, blank body padding row, then the inset agent list. + assert!(lines[0].starts_with(" ■ Can I start additional agents for this task?")); + assert!(lines[1].trim().is_empty()); + assert!(lines[2].starts_with(" Agents (7):")); + // The glyph is hash-assigned; assert the inset and the first name. + assert!(lines[3].starts_with(" "), "{}", lines[3]); + assert!(lines[3].contains("infrastructure-bot"), "{}", lines[3]); + // The identity line wraps with muted bullet separators, and the + // metadata renders as one inline row after a blank separator. + assert!(lines[3].contains(" • "), "{}", lines[3]); + let metadata = lines + .iter() + .find(|line| line.contains("Location: ")) + .expect("inline metadata row"); + assert!(metadata.contains("Location: Cloud")); + assert!(metadata.contains(" • ")); + assert!(metadata.contains("Harness: ")); + + let frame = render_card_frame(&mut app, &fixture.card, 80); + let builder_styles = app.read(|app| { + let builder = TuiUiBuilder::from_app(app); + ( + builder.orchestration_header_background(), + builder.orchestration_surface_background(), + ) + }); + let (header_bg, surface_bg) = builder_styles; + // Distinct header tint over the body tint; footer stays untinted. + assert_ne!(header_bg, surface_bg); + assert_eq!(frame.buffer[(0, 0)].bg, header_bg); + assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); + let footer_row = render_card_lines(&mut app, &fixture.card, 80) + .iter() + .position(|line| line.contains("Enter to accept")) + .expect("acceptance footer row") as u16; + assert_ne!(frame.buffer[(0, footer_row)].bg, header_bg); + assert_ne!(frame.buffer[(0, footer_row)].bg, surface_bg); + // The agent glyph and name share the identity color, with the + // name bolded; identity colors are set (not default foreground). + let glyph_cell = &frame.buffer[(3, 3)]; + let name_cell = &frame.buffer[(5, 3)]; + assert_eq!(glyph_cell.fg, name_cell.fg); + assert!(name_cell.modifier.contains(Modifier::BOLD)); + assert!(!glyph_cell.modifier.contains(Modifier::BOLD)); + }); +} + #[test] fn agent_identities_stay_stable_across_rerenders_and_edits() { App::test((), |mut app| async move { @@ -349,6 +420,11 @@ fn agent_identities_stay_stable_across_rerenders_and_edits() { .into_iter() .find(|line| line.contains("researcher")) .expect("agent row") + .split("•") + .find(|entry| entry.contains("researcher")) + .expect("researcher entry") + .trim() + .to_string() } let before = agent_line(&mut app, &fixture); // Stable across plain re-renders… @@ -450,14 +526,20 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { assert!(all.contains("Esc to go back")); let frame = render_card_frame(&mut app, &fixture.card, 80); - let surface = - app.read(|app| TuiUiBuilder::from_app(app).orchestration_surface_background()); - assert_eq!(frame.buffer[(0, 0)].bg, surface); + let (header_bg, surface_bg) = app.read(|app| { + let builder = TuiUiBuilder::from_app(app); + ( + builder.orchestration_header_background(), + builder.orchestration_surface_background(), + ) + }); + assert_eq!(frame.buffer[(0, 0)].bg, header_bg); + assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); let footer_row = lines .iter() .position(|line| line.contains("Enter to accept")) .expect("configuration footer row"); - assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface); + assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface_bg); // Esc returns to the acceptance card without deciding. act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 5980e0716b8..0cfc8e39522 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -219,6 +219,18 @@ impl TuiUiBuilder { cell_color(self.base_background().blend(&magenta.with_opacity(10))) } + /// Stronger magenta tint for the orchestration permission title row: + /// the surface overlay applied twice, matching the design's stacked + /// header overlays. + pub(crate) fn orchestration_header_background(&self) -> Color { + let magenta = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + cell_color( + self.base_background() + .blend(&magenta.with_opacity(10)) + .blend(&magenta.with_opacity(10)), + ) + } + /// Bold magenta text for a selected option-selector row. pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { TuiStyle::default() diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 93c2b182c70..d32429a37c0 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -34,18 +34,17 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ### Acceptance card 9. The initial card shows: - - “Can I start additional agents for this task?” - - The agent-provided summary of why orchestration is requested. - - Every proposed agent's name. - - The current run-wide location, harness, and model. - - For Cloud runs, the current API-key choice when applicable, host, and environment. + - “Can I start additional agents for this task?” on a header row with a stronger tint than the body. + - Every proposed agent's name, on one wrapping line with muted bullet separators. (The agent-provided summary streams into the transcript above the card and is not repeated inside it.) + - The current run-wide location, harness, and model as one wrapping inline `Label: value` row with muted bullet separators and bold values. + - For Cloud runs, the current API-key choice when applicable, host, and environment, appended to the same inline row. 10. Returning from configuration updates the displayed run-wide values. The user always reviews the final values on the acceptance card before launching. -11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. +11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. The agent's glyph and name render in the identity color, with the name bolded. 12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. 13. If a future request exceeds the number of unique combinations, the palette cycles deterministically. No agent is omitted and rendering does not fail. -14. The card uses the orchestration treatment from the designs: one 10%-magenta-tinted body/header surface, a yellow square attention glyph, primary text for content, muted separators and metadata, and bold magenta emphasis for selected configuration options. +14. The card uses the orchestration treatment from the designs: a 10%-magenta-tinted body under a doubly-tinted header row, a yellow square attention glyph, primary text for content, muted separators, and bold magenta emphasis for selected configuration options. 15. Text and agent identities wrap and reflow at narrow terminal widths. If the complete card cannot fit vertically, it remains navigable without clipping required configuration or actions. -16. On the acceptance card: +16. On the acceptance card (footer copy: `Enter to accept Ctrl + E to edit Ctrl + C to reject`): - Enter accepts the current configuration. - Ctrl+E opens configuration. - Ctrl+C rejects the request. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index fd70fb2b13f..39e9c69cab6 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -35,7 +35,7 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. -- Shared card chrome: a persistent yellow-square permission title over a single 10%-magenta surface in both modes. Acceptance renders summary, agent identities, and run-wide metadata. Configuration adds one blank row, then an inner two-cell inset containing `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface. +- Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). - Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option highlight. - Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. - Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. @@ -52,7 +52,7 @@ Input visibility is a pure function of the front-of-queue blocker rather than a - Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. ### 3. Theming and agent identity -`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_option_selected_style()` (bold full-strength magenta), `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. +`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_header_background()` (the overlay applied twice for the title row), `orchestration_selected_value_style()`, and `agent_identity_palette()`, while selected configuration rows use the shared `option_selector_selected_style()` recipe. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. From 2c1dba44b76de5d17020a50bd42477e78fbef93d Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 19:09:46 -0400 Subject: [PATCH 09/40] remeasure the card on selector scroll, hide warping row, unique agent identities Map the selector's LayoutChanged to BlockingStateChanged so the transcript remeasures the card when overflow markers appear, hide the warping/summary row while a blocker is active, add a footer margin, and keep agent glyphs and colors unique within a request until each set is exhausted. Co-Authored-By: Oz --- crates/warp_tui/src/agent_block.rs | 13 ++--- crates/warp_tui/src/agent_block_tests.rs | 11 ++-- crates/warp_tui/src/agent_identity.rs | 40 ++++++++------ crates/warp_tui/src/agent_identity_tests.rs | 19 +++++++ crates/warp_tui/src/run_agents_card_view.rs | 16 ++++-- .../src/run_agents_card_view_tests.rs | 54 +++++++++++++++++-- crates/warp_tui/src/terminal_session_view.rs | 43 ++++++++------- specs/CODE-1822/PRODUCT.md | 6 +-- 8 files changed, 144 insertions(+), 58 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index c2411375564..82ee51cb995 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -184,9 +184,8 @@ impl TuiToolCallView { } /// The front-of-queue blocking interaction owned by an agent block: the -/// pending action awaiting a decision plus the child view that renders it. +/// child view rendering the pending action that awaits a decision. pub(super) struct TuiBlockingChild { - pub(super) action_id: AIAgentActionId, pub(super) view: ViewHandle, } @@ -486,12 +485,10 @@ impl TuiAIBlock { return None; } match self.action_views.get(&action_id)? { - TuiToolCallView::RunAgents(view) => { - view.as_ref(ctx).wants_focus(ctx).then(|| TuiBlockingChild { - action_id, - view: view.clone(), - }) - } + TuiToolCallView::RunAgents(view) => view + .as_ref(ctx) + .wants_focus(ctx) + .then(|| TuiBlockingChild { view: view.clone() }), // These tool views render inline and never replace the input. TuiToolCallView::FileEdits(_) | TuiToolCallView::ShellCommand(_) => None, } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 789ae44c30a..5b9ba479b1b 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -1406,7 +1406,6 @@ fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmati }); let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); let blocker = blocker.expect("the blocked RunAgents card blocks the input"); - assert_eq!(blocker.action_id, action.id); assert_eq!(blocker.view.id(), card.id()); // Reject through the card: the block maps it to manual cancellation @@ -1448,12 +1447,12 @@ fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { // Pending requests behind the front blocker do not affect input // visibility. + let first_card = run_agents_card_view(&app, &block, &first.id); let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - assert_eq!(blocker.expect("front blocker").action_id, first.id); + assert_eq!(blocker.expect("front blocker").view.id(), first_card.id()); // Resolving the front blocker hands off directly to the next queued // blocking interaction. - let first_card = run_agents_card_view(&app, &block, &first.id); app.update(|ctx| { ctx.dispatch_typed_action_for_view( first_card.window_id(ctx), @@ -1461,8 +1460,12 @@ fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { &TuiRunAgentsCardAction::Reject, ); }); + let second_card = run_agents_card_view(&app, &block, &second.id); let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - assert_eq!(blocker.expect("handed-off blocker").action_id, second.id); + assert_eq!( + blocker.expect("handed-off blocker").view.id(), + second_card.id() + ); }); } diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/agent_identity.rs index 29d6d08ed56..d74798b7434 100644 --- a/crates/warp_tui/src/agent_identity.rs +++ b/crates/warp_tui/src/agent_identity.rs @@ -94,9 +94,12 @@ pub(crate) fn stable_hash(name: &str) -> u64 { hash } -/// Assigns a palette index to each agent name: `stable_hash(name) % len`, -/// with a first-come linear-probe fallback so identities within one request -/// stay distinct, cycling deterministically once the palette is exhausted. +/// Assigns a palette index to each agent name, starting from +/// `stable_hash(name) % len` and probing forward first-come. The palette is a +/// glyph × color grid, so the probe prefers a candidate whose glyph and color +/// are both unused, relaxing one dimension at a time as glyphs or colors run +/// out, and cycling deterministically by raw hash slot once every index is +/// taken. pub(crate) fn assign_agent_identity_indices( names: impl IntoIterator>, palette_len: usize, @@ -105,24 +108,31 @@ pub(crate) fn assign_agent_identity_indices( if palette_len == 0 { return assigned; } - let mut used = vec![false; palette_len]; - let mut used_count = 0; + // The palette lays glyph rows over color columns (color varies fastest); + // degenerate palettes smaller than the glyph set collapse to one column. + let color_count = (palette_len / AGENT_IDENTITY_GLYPHS.len()).max(1); + let glyph_of = |index: usize| index / color_count; + let color_of = |index: usize| index % color_count; + let mut used_index = vec![false; palette_len]; + let mut used_glyph = vec![false; palette_len.div_ceil(color_count)]; + let mut used_color = vec![false; color_count]; for name in names { let base = usize::try_from(stable_hash(name.as_ref()) % palette_len as u64).unwrap_or_default(); - let index = if used_count >= palette_len { - // Palette exhausted: cycle deterministically by raw hash slot. - base - } else { + let probe = |unused: &dyn Fn(usize) -> bool| { (0..palette_len) .map(|offset| (base + offset) % palette_len) - .find(|candidate| !used[*candidate]) - .unwrap_or(base) + .find(|candidate| unused(*candidate)) }; - if !used[index] { - used[index] = true; - used_count += 1; - } + let index = + probe(&|c| !used_index[c] && !used_glyph[glyph_of(c)] && !used_color[color_of(c)]) + .or_else(|| probe(&|c| !used_index[c] && !used_glyph[glyph_of(c)])) + .or_else(|| probe(&|c| !used_index[c] && !used_color[color_of(c)])) + .or_else(|| probe(&|c| !used_index[c])) + .unwrap_or(base); + used_index[index] = true; + used_glyph[glyph_of(index)] = true; + used_color[color_of(index)] = true; assigned.push(index); } assigned diff --git a/crates/warp_tui/src/agent_identity_tests.rs b/crates/warp_tui/src/agent_identity_tests.rs index d49927d07c2..1b67a9f72a7 100644 --- a/crates/warp_tui/src/agent_identity_tests.rs +++ b/crates/warp_tui/src/agent_identity_tests.rs @@ -52,6 +52,25 @@ fn assignment_keeps_identities_distinct_within_one_request() { assert_eq!(unique.len(), palette_len); } +#[test] +fn assignment_keeps_glyphs_and_colors_unique_until_exhausted() { + // 8 glyph rows × 5 color columns. + let palette_len = 40; + let color_count = 5; + let names: Vec = (0..8).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + // All eight agents get distinct glyph rows. + let glyphs: HashSet = indices.iter().map(|index| index / color_count).collect(); + assert_eq!(glyphs.len(), names.len()); + // The first five agents also get distinct color columns; the sixth + // onward must reuse one of the five colors. + let colors: HashSet = indices[..color_count] + .iter() + .map(|index| index % color_count) + .collect(); + assert_eq!(colors.len(), color_count); +} + #[test] fn assignment_cycles_deterministically_beyond_palette_exhaustion() { let palette_len = 3; diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index e8a41f140e2..96e9e84aeba 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -633,6 +633,13 @@ impl TuiRunAgentsCardView { self.refresh_active_page(ctx); } TuiOptionSelectorEvent::Dismissed => self.handle_back(ctx), + TuiOptionSelectorEvent::LayoutChanged => { + // The selector grew or shrank (e.g. scrolling toggled an + // overflow marker); ancestors re-measure the card's cached + // height so the footer is not clipped. + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } } } @@ -998,13 +1005,16 @@ impl TuiView for TuiRunAgentsCardView { let body = TuiContainer::new(body) .with_background(builder.orchestration_surface_background()) .with_padding_x(3) - .with_padding_top(1) - .with_padding_bottom(1) + .with_padding_y(1) .finish(); TuiFlex::column() .child(header) .child(body) - .child(self.render_footer(&builder)) + .child( + TuiContainer::new(self.render_footer(&builder)) + .with_padding_top(1) + .finish(), + ) .finish() } } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index 697918d5d0b..b56c91fb3ee 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -6,8 +6,8 @@ use ai::agent::orchestration_config::{ }; use warp::tui_export::{ register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, - AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, RunAgentsAgentRunConfig, - RunAgentsExecutionMode, RunAgentsRequest, TaskId, + AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, OptionRow, OptionSnapshot, + OptionSourceStatus, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, TaskId, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle}; @@ -393,13 +393,16 @@ fn acceptance_card_matches_the_design_layout_and_styles() { // Distinct header tint over the body tint; footer stays untinted. assert_ne!(header_bg, surface_bg); assert_eq!(frame.buffer[(0, 0)].bg, header_bg); - assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); + assert_eq!(frame.buffer[(0, 1)].bg, surface_bg); let footer_row = render_card_lines(&mut app, &fixture.card, 80) .iter() .position(|line| line.contains("Enter to accept")) .expect("acceptance footer row") as u16; assert_ne!(frame.buffer[(0, footer_row)].bg, header_bg); assert_ne!(frame.buffer[(0, footer_row)].bg, surface_bg); + // The row above the footer is an untinted margin row. + assert_ne!(frame.buffer[(0, footer_row - 1)].bg, header_bg); + assert_ne!(frame.buffer[(0, footer_row - 1)].bg, surface_bg); // The agent glyph and name share the identity color, with the // name bolded; identity colors are set (not default foreground). let glyph_cell = &frame.buffer[(3, 3)]; @@ -552,6 +555,51 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { }); } +#[test] +fn scrolling_a_long_option_list_requests_a_card_remeasure() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + // Give the page more rows than the viewport so moving the highlight + // eventually scrolls and toggles an overflow marker. + selector.update(&mut app, |selector, ctx| { + let rows = (0..6) + .map(|index| OptionRow { + id: format!("row-{index}"), + label: format!("row-{index}"), + harness: None, + badge: None, + disabled_reason: None, + }) + .collect(); + selector.refresh_snapshot( + OptionSnapshot { + rows, + selected_id: Some("row-0".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + }); + fixture.events.borrow_mut().clear(); + + // Scrolling past the viewport reveals the `↑` marker: the card asks + // its ancestors to re-measure so the taller card is not clipped. + for _ in 0..4 { + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + } + assert!(fixture + .events + .borrow() + .iter() + .any(|event| matches!(event, TuiRunAgentsCardViewEvent::BlockingStateChanged))); + }); +} + #[test] fn switching_to_local_mid_flow_collapses_the_sequence() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 6315c87943e..d88ae75c68a 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -2260,24 +2260,31 @@ impl TuiView for TuiTerminalSessionView { content = content.flex_child(TuiChildView::new(&self.transcript).finish()); } + // While a `RunAgents` card (or another blocking interaction) is the + // active front-of-queue blocker, the input box, inline menus, normal + // footer, and the warping/summary row are omitted; the blocker + // renders its own action hints in their place. Visibility is derived + // fresh each pass — no stored suppression flag — and the hidden + // input model is never written to, so its draft/cursor/selection/ + // scroll survive untouched. + let blocker_active = self.active_blocking_child(ctx).is_some(); + // While the selected conversation is in progress (the GUI warping // indicator's core condition), the animated warping indicator sits // between the transcript and the input box. Hide it while a process - // owns input: user takeover intentionally leaves the conversation in - // progress, so the indicator would otherwise appear stuck. Its elapsed - // counter is anchored to the latest exchange's start so animation - // survives element-tree rebuilds; the conversation's final status - // update re-renders the view without it. - let selected_conversation = (!inline_process_owns_input) - .then(|| { - self.conversation_selection - .as_ref(ctx) - .selected_conversation_id(ctx) - .and_then(|conversation_id| { - BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) - }) + // owns input or a blocker is active: user takeover intentionally leaves + // the conversation in progress, and blockers render their own status + // and actions. Its elapsed counter is anchored to the latest exchange's + // start so animation survives element-tree rebuilds; the conversation's + // final status update re-renders the view without it. + let selected_conversation = self + .conversation_selection + .as_ref(ctx) + .selected_conversation_id(ctx) + .and_then(|conversation_id| { + BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) }) - .flatten(); + .filter(|_| !blocker_active && !inline_process_owns_input); if let Some(conversation) = selected_conversation { if conversation.status().is_in_progress() { let warping_elapsed = conversation @@ -2318,14 +2325,6 @@ impl TuiView for TuiTerminalSessionView { } } } - // While a `RunAgents` card (or another blocking interaction) is the - // active front-of-queue blocker, the input box, inline menus, and - // normal footer are omitted; the blocker renders its own action - // hints in their place. Visibility is derived fresh - // each pass — no stored suppression flag — and the hidden input - // model is never written to, so its draft/cursor/selection/scroll - // survive untouched. - let blocker_active = self.active_blocking_child(ctx).is_some(); if !blocker_active && !inline_process_owns_input { if let Some(menu) = inline_menu { content = content.child( diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index d32429a37c0..625f0af72dd 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -24,7 +24,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ## Behavior ### Active interaction and input visibility 1. When a `RunAgents` request reaches the front of the confirmation queue and awaits a decision, its permission card becomes the active interaction. -2. While the permission card or one of its configuration pages is active, the main input row, input cursor, and normal input footer are hidden. The permission surface provides the relevant action hints in their place. +2. While the permission card or one of its configuration pages is active, the main input row, input cursor, normal input footer, and the in-progress warping indicator / last-response summary row are hidden. The permission surface provides the relevant action hints in their place. 3. Hiding the input preserves its draft text, cursor position, selection, editor mode, and scroll state without modification. 4. Pending requests behind the active front-of-queue blocker do not independently affect input visibility or focus. 5. Accepting or rejecting the request ends the blocking interaction immediately, even while an accepted request continues into a spawning state. The main input and footer reappear with their preserved state, and prior focus is restored. @@ -40,9 +40,9 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ - For Cloud runs, the current API-key choice when applicable, host, and environment, appended to the same inline row. 10. Returning from configuration updates the displayed run-wide values. The user always reviews the final values on the acceptance card before launching. 11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. The agent's glyph and name render in the identity color, with the name bolded. -12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. +12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. Within one request, agents keep both a unique glyph and a unique color until the glyph or color set runs out; only then does that dimension repeat. 13. If a future request exceeds the number of unique combinations, the palette cycles deterministically. No agent is omitted and rendering does not fail. -14. The card uses the orchestration treatment from the designs: a 10%-magenta-tinted body under a doubly-tinted header row, a yellow square attention glyph, primary text for content, muted separators, and bold magenta emphasis for selected configuration options. +14. The card uses the orchestration treatment from the designs: a 10%-magenta-tinted body under a doubly-tinted header row, a yellow square attention glyph, primary text for content, muted separators, and bold magenta emphasis for selected configuration options. One blank untinted row separates the card from its keybinding footer. 15. Text and agent identities wrap and reflow at narrow terminal widths. If the complete card cannot fit vertically, it remains navigable without clipping required configuration or actions. 16. On the acceptance card (footer copy: `Enter to accept Ctrl + E to edit Ctrl + C to reject`): - Enter accepts the current configuration. From 2fea7a2141587252ad8dcd17571d37628d364996 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 09:34:50 -0400 Subject: [PATCH 10/40] stretch --- crates/warp_tui/src/run_agents_card_view.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 96e9e84aeba..54f3c6dd65a 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -28,6 +28,7 @@ use warpui::SingletonEntity; use warpui_core::elements::tui::{ Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, }; +use warpui_core::elements::CrossAxisAlignment; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, FixedBinding}; use warpui_core::{ @@ -1007,7 +1008,10 @@ impl TuiView for TuiRunAgentsCardView { .with_padding_x(3) .with_padding_y(1) .finish(); + // Stretch so the tinted header and body fill the full row width + // rather than sizing to their text content. TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .child(header) .child(body) .child( From 8348eae37d57acc401aa39cd9def685c942afdc7 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 19:04:56 -0400 Subject: [PATCH 11/40] Rename OrchestrationHarnessKind::from_str to from_harness_type Fixes clippy::should_implement_trait on the now-pub method. Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 2 +- app/src/ai/blocklist/telemetry.rs | 5 ++++- .../ai/document/orchestration_config_block.rs | 4 ++-- crates/warp_tui/src/keybindings_tests.rs | 2 +- crates/warp_tui/src/run_agents_card_view.rs | 10 ++++++++-- .../warp_tui/src/run_agents_card_view_tests.rs | 16 ++++++++++++++++ specs/CODE-1822/TECH.md | 7 +++++++ 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 341222a74d9..16293596c43 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -721,7 +721,7 @@ impl RunAgentsCardView { plan_id: (!self.card.plan_id.is_empty()).then(|| self.card.plan_id.clone()), decision, agent_count: self.card.agent_run_configs.len(), - harness: OrchestrationHarnessKind::from_str( + harness: OrchestrationHarnessKind::from_harness_type( &self .orchestration_edit_state .orchestration_config_state diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index 1a8428326bf..db21288c526 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -112,7 +112,10 @@ pub enum OrchestrationHarnessKind { } impl OrchestrationHarnessKind { - pub fn from_str(harness_type: &str) -> Self { + /// Buckets a raw harness_type string into the closed telemetry set. + /// Not `FromStr`: infallible and analytics-shaped, so a distinct name + /// avoids clashing with the standard trait method. + pub fn from_harness_type(harness_type: &str) -> Self { match harness_type { "oz" | "" => Self::Oz, "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index f53ba99d355..484df0e5875 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -973,7 +973,7 @@ impl OrchestrationConfigBlockView { .orchestration_config_state .execution_mode, ), - harness: OrchestrationHarnessKind::from_str( + harness: OrchestrationHarnessKind::from_harness_type( &self .orchestration_edit_state .orchestration_config_state @@ -1013,7 +1013,7 @@ impl OrchestrationConfigBlockView { BlocklistOrchestrationTelemetryEvent::AgentProposedConfig(AgentProposedConfigEvent { conversation_id: self.conversation_id, plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()), - harness: OrchestrationHarnessKind::from_str( + harness: OrchestrationHarnessKind::from_harness_type( &self .orchestration_edit_state .orchestration_config_state diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 9fbca234e46..13c631316ce 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -23,6 +23,6 @@ fn tui_ownership_is_by_name_prefix_or_group() { #[test] fn tui_binding_registration_passes_the_cross_surface_validators() { App::test((), |mut app| async move { - app.update(|ctx| super::init(ctx)); + app.update(super::init); }); } diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 54f3c6dd65a..9f91dc08fc0 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -135,6 +135,11 @@ impl ConfigPage { Self::Model => format!("Which model should the {agent} use?"), } } + + /// Whether this page opts into the selector's pinned search editor. + fn is_searchable(self) -> bool { + matches!(self, Self::Model) + } } /// Whether the card shows the acceptance summary or a configuration page. @@ -198,6 +203,7 @@ pub(crate) struct TuiRunAgentsCardView { impl TuiRunAgentsCardView { /// Creates a card for one pending `RunAgents` action and wires its model /// subscriptions. + #[allow(clippy::too_many_arguments)] pub(crate) fn new( action: AIAgentAction, request: &RunAgentsRequest, @@ -208,7 +214,7 @@ impl TuiRunAgentsCardView { is_restored: bool, ctx: &mut ViewContext, ) -> Self { - let selector = ctx.add_typed_action_tui_view(|_| TuiOptionSelector::new()); + let selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); ctx.subscribe_to_view(&selector, |me, _, event, ctx| { me.handle_selector_event(event, ctx); }); @@ -473,7 +479,7 @@ impl TuiRunAgentsCardView { }; let snapshot = self.snapshot_for_page(page, ctx); self.selector.update(ctx, |selector, ctx| { - selector.set_page(header, snapshot, ctx); + selector.set_page(header, snapshot, page.is_searchable(), ctx); }); ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); ctx.notify(); diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index b56c91fb3ee..e016f0a74bd 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -44,6 +44,20 @@ fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRe } } +#[test] +fn only_the_model_page_is_searchable() { + assert!(ConfigPage::Model.is_searchable()); + for page in [ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::ApiKey, + ConfigPage::Host, + ConfigPage::Environment, + ] { + assert!(!page.is_searchable(), "{page:?}"); + } +} + /// A Cloud execution mode with the given env/host. fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { RunAgentsExecutionMode::Remote { @@ -524,6 +538,7 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { assert!(lines[3].trim().is_empty()); assert!(lines[4].starts_with(" Where should the agent run?")); assert!(lines[5].starts_with(" (1) Cloud")); + assert!(!all.contains("Search:")); assert!(all.contains("Enter to accept")); assert!(all.contains("Tab or ← → to navigate")); assert!(all.contains("Esc to go back")); @@ -619,6 +634,7 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); assert!(all.contains("Which model should the agent use?"), "{all}"); assert!(all.contains("2 of 2"), "{all}"); + assert!(all.contains("Search:"), "{all}"); }); } diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 39e9c69cab6..b85b179ae20 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -38,6 +38,10 @@ View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_c - Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). - Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option highlight. - Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. +- Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned + `Search:` editor stays above the model viewport; the list starts on the selected + model so numeric shortcuts remain immediate, while Up from the top model focuses + search and Down restores the first filtered model. - Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. - Reject: emit an event the owning `TuiAIBlock` maps to `cancel_action_with_id(conversation_id, &action_id, CancellationReason::ManuallyCancelled, ctx)`, matching the GUI's `RejectRequested` semantics (`deny_run_agents` remains reserved for disapproved-config denial, which the TUI does not surface). - Subscriptions: `RunAgentsExecutorEvent` (spawning presentation), `BlocklistAIActionEvent` (blocked/finished transitions), `HarnessAvailabilityEvent` (`Changed`, `AuthSecretsLoaded`, `AuthSecretsFetchFailed`, `AuthSecretDeleted` → `revalidate_after_catalog_change` + refresh the active selector snapshot), `LLMPreferencesEvent` (Oz model catalog), `ConnectedSelfHostedWorkersEvent` (host list). Retry from a `Failed` API-key page calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched` — the same lazy fetch the GUI triggers on picker population. @@ -70,6 +74,9 @@ TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tes - Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, four-row viewport, and external footer styling. - Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed highlights. - Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). +- Model-page search: selector-owned `Search:` chrome, list-first focus, digit + shortcuts, digit-containing queries, filtering/no-match rendering, and first-match + confirmation; non-model pages render no search editor. - Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). - Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). - Accept dispatch asserts `execute_run_agents` receives exactly the edited request (via the real `BlocklistAIActionModel` fixture used in `agent_block_tests.rs`); reject asserts `cancel_action_with_id` and terminal render — PRODUCT (55-57). From 67b59346773fdbfd5758c14045c74e674e3ed39a Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 13:44:39 -0400 Subject: [PATCH 12/40] adapt orchestration card to reviewed selector API Co-Authored-By: Oz --- crates/warp_tui/src/run_agents_card_view.rs | 25 ++++++++++--------- .../src/run_agents_card_view_tests.rs | 12 ++++----- specs/CODE-1822/TECH.md | 16 ++++++------ 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 9f91dc08fc0..f30c2dcd7b0 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -38,7 +38,7 @@ use warpui_core::{ use crate::agent_block_sections::render_fallback_tool_call_section; use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; use crate::keybindings::TUI_BINDING_GROUP; -use crate::option_selector::{OptionSelectorHeader, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; const RUN_AGENTS_CARD_TITLE: &str = "Can I start additional agents for this task?"; @@ -460,7 +460,7 @@ impl TuiRunAgentsCardView { } } - /// Opens `page`: swaps the selector to its snapshot and header, and + /// Opens `page`: swaps the selector to its page fields, and /// lazily fetches auth secrets for the API-key page (the same lazy fetch /// the GUI triggers on picker population). fn open_page(&mut self, page: ConfigPage, ctx: &mut ViewContext) { @@ -472,14 +472,15 @@ impl TuiRunAgentsCardView { let sequence = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; - let header = OptionSelectorHeader { - title: "Edit agent configuration".to_string(), + let selector_page = OptionSelectorPage { + field_label: "Edit agent configuration".to_string(), position: (position, sequence.len()), - question: page.question(self.request_fields.agent_run_configs.len()), + prompt: page.question(self.request_fields.agent_run_configs.len()), + snapshot: self.snapshot_for_page(page, ctx), + searchable: page.is_searchable(), }; - let snapshot = self.snapshot_for_page(page, ctx); self.selector.update(ctx, |selector, ctx| { - selector.set_page(header, snapshot, page.is_searchable(), ctx); + selector.set_page(selector_page, ctx); }); ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); ctx.notify(); @@ -592,7 +593,7 @@ impl TuiRunAgentsCardView { } } - /// Moves to an adjacent page without applying the current highlight. + /// Moves to an adjacent page without applying the current selection. fn navigate_page(&mut self, forward: bool, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -640,7 +641,7 @@ impl TuiRunAgentsCardView { self.refresh_active_page(ctx); } TuiOptionSelectorEvent::Dismissed => self.handle_back(ctx), - TuiOptionSelectorEvent::LayoutChanged => { + TuiOptionSelectorEvent::LayoutInvalidated => { // The selector grew or shrank (e.g. scrolling toggled an // overflow marker); ancestors re-measure the card's cached // height so the footer is not clipped. @@ -713,7 +714,7 @@ impl TuiRunAgentsCardView { } /// Escape from configuration: completed pages keep their confirmed - /// selections; the current page's unconfirmed highlight is discarded. + /// selections; the current page's unconfirmed selection is discarded. /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { let consumed = self @@ -729,10 +730,10 @@ impl TuiRunAgentsCardView { } } - /// Confirms the selector's highlighted option (Enter). + /// Confirms the selector's selected option (Enter). fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { self.selector.update(ctx, |selector, ctx| { - selector.confirm_highlighted(ctx); + selector.confirm_selected(ctx); }); } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index e016f0a74bd..df73c01736a 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -576,10 +576,10 @@ fn scrolling_a_long_option_list_requests_a_card_remeasure() { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - // Give the page more rows than the viewport so moving the highlight + // Give the page more rows than the viewport so moving the selection // eventually scrolls and toggles an overflow marker. selector.update(&mut app, |selector, ctx| { - let rows = (0..6) + let rows = (0..8) .map(|index| OptionRow { id: format!("row-{index}"), label: format!("row-{index}"), @@ -602,7 +602,7 @@ fn scrolling_a_long_option_list_requests_a_card_remeasure() { // Scrolling past the viewport reveals the `↑` marker: the card asks // its ancestors to re-measure so the taller card is not clipped. - for _ in 0..4 { + for _ in 0..6 { selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); }); @@ -620,7 +620,7 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - // Highlight "Local" (second row) and confirm it: the sequence + // Select "Local" (second row) and confirm it: the sequence // collapses to Location, Model and advances to Model. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); selector.update(&mut app, |selector, ctx| { @@ -639,12 +639,12 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { } #[test] -fn horizontal_navigation_moves_between_pages_without_applying_highlights() { +fn horizontal_navigation_moves_between_pages_without_applying_selections() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - // Highlight Local, then navigate away without confirming it. + // Select Local, then navigate away without confirming it. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index b85b179ae20..f08869a9f63 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -36,12 +36,14 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. - Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). -- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option highlight. +- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option selection. - Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected - model so numeric shortcuts remain immediate, while Up from the top model focuses - search and Down restores the first filtered model. + model so numeric shortcuts remain immediate. Search is the final item in the + navigation cycle: Up from the first model focuses Search, Up from Search selects + the last filtered model, Down from the last model focuses Search, and Down from + Search selects the first filtered model. - Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. - Reject: emit an event the owning `TuiAIBlock` maps to `cancel_action_with_id(conversation_id, &action_id, CancellationReason::ManuallyCancelled, ctx)`, matching the GUI's `RejectRequested` semantics (`deny_run_agents` remains reserved for disapproved-config denial, which the TUI does not surface). - Subscriptions: `RunAgentsExecutorEvent` (spawning presentation), `BlocklistAIActionEvent` (blocked/finished transitions), `HarnessAvailabilityEvent` (`Changed`, `AuthSecretsLoaded`, `AuthSecretsFetchFailed`, `AuthSecretDeleted` → `revalidate_after_catalog_change` + refresh the active selector snapshot), `LLMPreferencesEvent` (Oz model catalog), `ConnectedSelfHostedWorkersEvent` (host list). Retry from a `Failed` API-key page calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched` — the same lazy fetch the GUI triggers on picker population. @@ -71,9 +73,9 @@ So the TUI card tests can exercise the real accept/reject paths: TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): - Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). - Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). -- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, four-row viewport, and external footer styling. -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed highlights. -- Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). +- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, six-row viewport, and external footer styling. +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed selections. +- Selector behavior: selection movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). - Model-page search: selector-owned `Search:` chrome, list-first focus, digit shortcuts, digit-containing queries, filtering/no-match rendering, and first-match confirmation; non-model pages render no search editor. @@ -94,6 +96,6 @@ The work ships as a four-PR Graphite stack, each mergeable on its own: 4. The final PR (this spec's remaining scope) — the TUI RunAgents card, generalized input replacement, theming and agent identity, and the frontend test seams, reviewed against the PRODUCT invariants. ## Risks and mitigations -- Catalog events arriving mid-configuration can reshape option lists — the selector preserves the highlighted id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. +- Catalog events arriving mid-configuration can reshape option lists — the selector preserves the selected id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. - Focus derivation vs. event ordering: `SpawningStarted` must flip `wants_focus` before the next render; both arrive through the same entity-event loop, and the render-time derivation (not cached state) makes late events self-correcting. - Theme switches would rebuild the identity palette; the card pins its palette at construction so in-flight requests keep stable identities, at the cost of using pre-switch colors until the next request. From 40251d345ee60ff66edba85adc129e4505b1c089 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 15:43:34 -0400 Subject: [PATCH 13/40] arrow commits --- crates/warp_tui/src/run_agents_card_view.rs | 94 +++++++++++++++++-- .../src/run_agents_card_view_tests.rs | 56 ++++++----- specs/CODE-1822/PRODUCT.md | 11 ++- specs/CODE-1822/TECH.md | 6 +- 4 files changed, 120 insertions(+), 47 deletions(-) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index f30c2dcd7b0..48bb2feb3b7 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -78,10 +78,18 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP), FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), - FixedBinding::new("left", TuiRunAgentsCardAction::PreviousPage, configuring()) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new("right", TuiRunAgentsCardAction::NextPage, configuring()) - .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "left", + TuiRunAgentsCardAction::CommitAndPreviousPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "right", + TuiRunAgentsCardAction::CommitAndNextPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), FixedBinding::new("tab", TuiRunAgentsCardAction::NextPage, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( @@ -148,6 +156,12 @@ enum CardMode { Acceptance, Configuring { page: ConfigPage }, } +/// Page direction requested by an arrow-key confirmation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PageNavigation { + Previous, + Next, +} /// Events emitted to the owning agent block. #[derive(Clone, Debug)] @@ -165,7 +179,8 @@ pub(crate) enum TuiRunAgentsCardAction { Accept, Configure, ConfirmSelection, - PreviousPage, + CommitAndPreviousPage, + CommitAndNextPage, NextPage, Back, Reject, @@ -183,6 +198,9 @@ pub(crate) struct TuiRunAgentsCardView { request_fields: RunAgentsRequest, mode: CardMode, selector: ViewHandle, + /// Arrow direction to apply after the selector confirms its current + /// value. Enter leaves this unset and follows the normal forward flow. + confirmation_navigation: Option, action_model: ModelHandle, /// Approved/disapproved plan config used to resolve inherited fields. active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, @@ -312,6 +330,7 @@ impl TuiRunAgentsCardView { request_fields: request.clone(), mode: CardMode::Acceptance, selector, + confirmation_navigation: None, action_model, active_config, fallback_base_model_id, @@ -570,7 +589,16 @@ impl TuiRunAgentsCardView { .model_id = id.to_string(); } } - self.advance_after(page, ctx); + self.finish_page_confirmation(page, ctx); + } + + /// Completes a page confirmation using an arrow's requested direction, + /// or the normal Enter behavior when no arrow direction is pending. + fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + match self.confirmation_navigation.take() { + Some(navigation) => self.navigate_after_confirmation(page, navigation, ctx), + None => self.advance_after(page, ctx), + } } /// Advances past `page` in the (freshly recomputed) sequence, returning @@ -593,7 +621,34 @@ impl TuiRunAgentsCardView { } } - /// Moves to an adjacent page without applying the current selection. + /// Moves after committing `page`, recomputing the dynamic sequence so + /// choices such as Local navigate within the newly applicable pages. + fn navigate_after_confirmation( + &mut self, + page: ConfigPage, + navigation: PageNavigation, + ctx: &mut ViewContext, + ) { + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + return; + }; + let target = match navigation { + PageNavigation::Previous => index + .checked_sub(1) + .and_then(|index| sequence.get(index)) + .copied(), + PageNavigation::Next => sequence.get(index + 1).copied(), + } + .unwrap_or(page); + self.open_page(target, ctx); + } + + /// Moves to the next page without applying the current selection. fn navigate_page(&mut self, forward: bool, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -633,14 +688,18 @@ impl TuiRunAgentsCardView { .orchestration_config_state .set_worker_host(value.clone()); persist_host_selection(value, ctx); - self.advance_after(ConfigPage::Host, ctx); + self.finish_page_confirmation(ConfigPage::Host, ctx); } } TuiOptionSelectorEvent::RetryRequested => { + self.confirmation_navigation = None; self.ensure_auth_secrets_fetched(ctx); self.refresh_active_page(ctx); } - TuiOptionSelectorEvent::Dismissed => self.handle_back(ctx), + TuiOptionSelectorEvent::Dismissed => { + self.confirmation_navigation = None; + self.handle_back(ctx); + } TuiOptionSelectorEvent::LayoutInvalidated => { // The selector grew or shrank (e.g. scrolling toggled an // overflow marker); ancestors re-measure the card's cached @@ -717,6 +776,7 @@ impl TuiRunAgentsCardView { /// selections; the current page's unconfirmed selection is discarded. /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { + self.confirmation_navigation = None; let consumed = self .selector .update(ctx, |selector, ctx| selector.handle_back(ctx)); @@ -732,6 +792,15 @@ impl TuiRunAgentsCardView { /// Confirms the selector's selected option (Enter). fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { + self.confirmation_navigation = None; + self.selector.update(ctx, |selector, ctx| { + selector.confirm_selected(ctx); + }); + } + /// Confirms the selector's selected option, then navigates in the arrow's + /// direction once the selector emits its confirmation event. + fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { + self.confirmation_navigation = Some(navigation); self.selector.update(ctx, |selector, ctx| { selector.confirm_selected(ctx); }); @@ -1038,7 +1107,12 @@ impl TypedActionView for TuiRunAgentsCardView { TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), - TuiRunAgentsCardAction::PreviousPage => self.navigate_page(false, ctx), + TuiRunAgentsCardAction::CommitAndPreviousPage => { + self.handle_arrow_navigation(PageNavigation::Previous, ctx) + } + TuiRunAgentsCardAction::CommitAndNextPage => { + self.handle_arrow_navigation(PageNavigation::Next, ctx) + } TuiRunAgentsCardAction::NextPage => self.navigate_page(true, ctx), TuiRunAgentsCardAction::Back => self.handle_back(ctx), TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index df73c01736a..dff0eb74d2a 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -639,22 +639,26 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { } #[test] -fn horizontal_navigation_moves_between_pages_without_applying_selections() { +fn horizontal_navigation_commits_the_selection_before_moving() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - - // Select Local, then navigate away without confirming it. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + + // Left commits Local and clamps on the first page. selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); }); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::CommitAndPreviousPage, + ); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 2 of 5 →")); - assert!(all.contains("Which harness should the agent use?")); + assert!(all.contains("Where should the agent run?"), "{all}"); + assert!(all.contains("← 1 of 2 →"), "{all}"); assert!(app.read(|app| { - fixture + !fixture .card .as_ref(app) .orchestration_edit_state @@ -663,32 +667,26 @@ fn horizontal_navigation_moves_between_pages_without_applying_selections() { .is_remote() })); + // Right commits Cloud, expands the sequence, and moves to Harness. + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveUp, ctx); + }); act( &mut app, &fixture.card, - TuiRunAgentsCardAction::PreviousPage, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 1 of 5 →")); - - // Previous on the first page is clamped. - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::PreviousPage, + TuiRunAgentsCardAction::CommitAndNextPage, ); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 1 of 5 →")); - - for _ in 0..10 { - act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); - } - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 5 of 5 →")); - - // Next on the final page is clamped. - act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 5 of 5 →")); + assert!(all.contains("Which harness should the agent use?"), "{all}"); + assert!(all.contains("← 2 of 5 →"), "{all}"); + assert!(app.read(|app| { + fixture + .card + .as_ref(app) + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + })); }); } diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 625f0af72dd..93a86c7b37f 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -64,11 +64,12 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 22. Selecting Cloud restores the applicable Cloud page sequence and valid selections from the current edit session when possible. 23. Each page initially highlights the request's current value, including values inherited from an approved plan configuration. If the current value is unavailable, the page highlights the appropriate valid default and clearly reflects the replacement. 24. A confirmed selection is saved immediately to the edit session. - - Tab and Right move to the next applicable page without confirming the current highlight. - - Left moves to the previous applicable page without confirming the current highlight. - - Navigation clamps at the first and final pages; the unavailable boundary arrow is muted. -25. Confirming a selection on a non-final page advances to the next applicable page. -26. Confirming the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. + - Right confirms the current highlight and moves to the next applicable page. + - Left confirms the current highlight and moves to the previous applicable page. + - Tab moves to the next applicable page without confirming the current highlight. + - Arrow navigation clamps at the first and final pages after confirming the current highlight; the unavailable boundary arrow is muted. +25. Pressing Enter to confirm a selection on a non-final page advances to the next applicable page. +26. Pressing Enter to confirm the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. 27. Esc returns to the acceptance card and retains selections confirmed on completed pages. The current page's highlighted but unconfirmed option is discarded. 28. Ctrl+C rejects the entire request from any configuration page. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index f08869a9f63..199c91b5c99 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -36,8 +36,8 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. - Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). -- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option selection. -- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment) and advance; the final page returns to Acceptance. +- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. +- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected model so numeric shortcuts remain immediate. Search is the final item in the @@ -74,7 +74,7 @@ TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tes - Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). - Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). - Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, six-row viewport, and external footer styling. -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed selections. +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, arrow navigation that commits before moving, and Tab navigation that leaves the current highlight uncommitted. - Selector behavior: selection movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). - Model-page search: selector-owned `Search:` chrome, list-first focus, digit shortcuts, digit-containing queries, filtering/no-match rendering, and first-match From fa5009f036c3bf952753ae1dfdb02ba6fd470df2 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:16:17 -0400 Subject: [PATCH 14/40] clean up massive tests --- app/Cargo.toml | 7 +- app/src/ai/blocklist/action_model.rs | 21 - app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 4 +- app/src/tui_export.rs | 96 --- app/src/tui_export_tests.rs | 28 - app/src/user_config/mod.rs | 4 +- app/src/workspaces/user_workspaces.rs | 2 +- crates/warp_tui/src/agent_block.rs | 28 +- crates/warp_tui/src/agent_block_tests.rs | 193 +---- crates/warp_tui/src/keybindings.rs | 6 +- crates/warp_tui/src/lib.rs | 2 +- ...ts_card_view.rs => orchestration_block.rs} | 399 ++++++---- .../warp_tui/src/orchestration_block_tests.rs | 407 ++++++++++ .../src/run_agents_card_view_tests.rs | 692 ------------------ crates/warp_tui/src/test_fixtures.rs | 37 +- specs/CODE-1822/TECH.md | 33 +- 22 files changed, 729 insertions(+), 1254 deletions(-) delete mode 100644 app/src/tui_export_tests.rs rename crates/warp_tui/src/{run_agents_card_view.rs => orchestration_block.rs} (82%) create mode 100644 crates/warp_tui/src/orchestration_block_tests.rs delete mode 100644 crates/warp_tui/src/run_agents_card_view_tests.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index ca354e536e2..7ddcf236938 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -845,12 +845,7 @@ voice_input = ["dep:voice_input"] system_theme = [] tab_close_button_on_left = [] team_features_override = [] -test-util = [ - "cloud_object_client/test-util", - "warp_server_auth/test-util", - "http_client/test-util", - "warp_core/test-util", -] +test-util = ["cloud_object_client/test-util", "warp_server_auth/test-util"] team_workflows = ["team_features_override"] terminal_lifecycle_recovery = [] toggle_bootstrap_block = [] diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 90362ee6cf1..3a9f1685ec3 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -955,27 +955,6 @@ impl BlocklistAIActionModel { }); } - /// Synchronously enqueues a pending action, bypassing async - /// preprocessing, so tests can deterministically drive an action into - /// `Blocked` status and exercise confirmation flows. - #[cfg(any(test, feature = "test-util"))] - pub fn queue_pending_action_for_test( - &mut self, - conversation_id: AIConversationId, - action: AIAgentAction, - ctx: &mut ModelContext, - ) { - let action_id = action.id.clone(); - self.pending_actions - .entry(conversation_id) - .or_default() - .push_back(action); - ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id.clone())); - ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation( - action_id, - )); - } - fn handle_preprocess_actions_results( &mut self, conversation_id: AIConversationId, diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index d6b74ad3478..80abe931e21 100644 --- a/app/src/ai/document/ai_document_model.rs +++ b/app/src/ai/document/ai_document_model.rs @@ -228,7 +228,7 @@ impl AIDocumentModel { } } - #[cfg(any(test, feature = "test-util"))] + #[cfg(test)] pub fn new_for_test() -> Self { let (save_tx, _save_rx) = async_channel::unbounded(); Self { diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index 70a1227098c..1bc116efc2c 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,9 +115,7 @@ impl AuthManager { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn new_for_test(ctx: &mut ModelContext) -> Self { use crate::server::server_api::ServerApiProvider; diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index 2cb902d5655..bef0f00bacb 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -1684,7 +1684,7 @@ impl CloudModel { .collect::>() } - #[cfg(any(test, feature = "test-util"))] + #[cfg(test)] pub fn mock(_ctx: &mut ModelContext) -> Self { Self::new(None, Vec::new(), None) } diff --git a/app/src/lib.rs b/app/src/lib.rs index c1fd2ab19ec..10a733d79cb 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -648,9 +648,7 @@ impl LaunchMode { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub(crate) fn new_for_unit_test() -> Self { LaunchMode::Test { driver: Box::new(None), diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 8ff728ce47b..f3da4863500 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,9 +449,7 @@ impl ServerApi { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1289,9 +1287,7 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn new_for_test() -> Self { let server_api = Arc::new(ServerApi::new_for_test()); let auth_client = Arc::new(AuthClientImpl::new(server_api.base_client.clone())); diff --git a/app/src/server/sync_queue.rs b/app/src/server/sync_queue.rs index f563ad475cb..b3ceac851ab 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,9 +356,7 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn mock(ctx: &mut ModelContext) -> Self { use super::server_api::ServerApiProvider; diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index eb0e15552a1..b138854c94c 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -280,7 +280,7 @@ fn handle_warp_config_change( /// settings when the settings file feature flag is disabled. fn init_platform_native_preferences() -> user_preferences::Model { cfg_if::cfg_if! { - if #[cfg(any(test, feature = "test-util"))] { + if #[cfg(test)] { Box::::default() } else if #[cfg(any(target_os = "linux", target_os = "freebsd", feature = "integration_tests"))] { match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) { @@ -448,7 +448,7 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(any(test, feature = "test-util"))] +#[cfg(test)] pub fn init_and_register_user_preferences(ctx: &mut AppContext) { let (public_prefs, _parse_error) = init_public_user_preferences(); ctx.add_singleton_model(move |_| settings::PublicPreferences::new(public_prefs)); diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 769518b64a6..a96f313e965 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,14 +2,10 @@ pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; -#[cfg(any(test, feature = "test-util"))] -use ai::api_keys::ApiKeyManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; -#[cfg(any(test, feature = "test-util"))] -use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; pub use crate::ai::agent::api::ServerConversationToken; @@ -67,8 +63,6 @@ pub use crate::ai::blocklist::telemetry::{ RunAgentsCardDecisionEvent, }; pub use crate::ai::blocklist::view_util::format_credits; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::BlocklistAIPermissions; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, @@ -76,8 +70,6 @@ pub use crate::ai::blocklist::{ PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::cloud_agent_settings::CloudAgentSettings; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -85,16 +77,12 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, }; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -107,36 +95,20 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::auth_manager::AuthManager; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; -#[cfg(any(test, feature = "test-util"))] -use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; -#[cfg(any(test, feature = "test-util"))] -use crate::server::server_api::ServerApiProvider; -#[cfg(any(test, feature = "test-util"))] -use crate::server::sync_queue::SyncQueue; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ @@ -196,14 +168,8 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; -#[cfg(any(test, feature = "test-util"))] -use crate::user_config::WarpConfig; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; -#[cfg(any(test, feature = "test-util"))] -use crate::workspaces::user_workspaces::UserWorkspaces; -#[cfg(any(test, feature = "test-util"))] -use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -263,65 +229,3 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } - -/// Registers the minimal singleton set needed to construct, render, and -/// accept the TUI orchestration (`RunAgents`) card against real app models: -/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the -/// auth/server/cloud-object singletons the catalog models read, and the -/// catalog + permission models the card's snapshot builders and accept-path -/// permission checks use. Intended for `warp_tui` tests (via the `test-util` -/// feature) and this crate's own unit tests. Registration order matters: -/// each model subscribes to singletons registered before it. -#[cfg(any(test, feature = "test-util"))] -pub fn register_orchestration_test_singletons(app: &mut warpui::App) { - // Settings machinery required by CloudAgentSettings/AISettings reads. - app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); - app.update(init_and_register_user_preferences); - app.add_singleton_model(|_| SettingsManager::default()); - app.add_singleton_model(WarpConfig::mock); - app.update(|ctx| { - // No-op secure storage backs ApiKeyManager in tests. - warpui_extras::secure_storage::register_noop("test", ctx); - }); - app.update(AISettings::register_and_subscribe_to_events); - CloudAgentSettings::register(app); - // Secure-storage-backed; LLMPreferences subscribes to it. - app.add_singleton_model(ApiKeyManager::new); - - // Auth / server / cloud-object singletons the catalog models read. - app.add_singleton_model(|_| NetworkStatus::new()); - app.add_singleton_model(|_| ServerApiProvider::new_for_test()); - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - app.add_singleton_model(AuthManager::new_for_test); - app.add_singleton_model(|ctx| { - // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), - // so back the mock with the test ServerApi's clients instead. - let (team_client, workspace_client) = { - let provider = ServerApiProvider::as_ref(ctx); - (provider.get_team_client(), provider.get_workspace_client()) - }; - UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) - }); - app.add_singleton_model(SyncQueue::mock); - app.add_singleton_model(CloudModel::mock); - app.add_singleton_model(|_| crate::appearance::Appearance::mock()); - - // Catalog + permission singletons read by the card's construction, - // snapshot builders, and accept path. - app.add_singleton_model(|_| TemplatableMCPServerManager::default()); - app.add_singleton_model(LLMPreferences::new); - app.add_singleton_model(HarnessAvailabilityModel::new); - app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); - app.add_singleton_model(BlocklistAIPermissions::new); - app.add_singleton_model(|ctx| { - AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) - }); - // Plan publication during the accept path reads the document model. - app.add_singleton_model(|_| { - crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() - }); -} - -#[cfg(test)] -#[path = "tui_export_tests.rs"] -mod tests; diff --git a/app/src/tui_export_tests.rs b/app/src/tui_export_tests.rs deleted file mode 100644 index d32445a46b2..00000000000 --- a/app/src/tui_export_tests.rs +++ /dev/null @@ -1,28 +0,0 @@ -use warpui::{App, SingletonEntity}; - -use super::register_orchestration_test_singletons; -use crate::ai::blocklist::BlocklistAIPermissions; -use crate::ai::cloud_agent_settings::CloudAgentSettings; -use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; -use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; -use crate::ai::harness_availability::HarnessAvailabilityModel; -use crate::ai::llms::LLMPreferences; -use crate::appearance::Appearance; - -#[test] -fn orchestration_test_singletons_are_self_consistent() { - App::test((), |mut app| async move { - register_orchestration_test_singletons(&mut app); - app.update(|ctx| { - // Touch each registered accessor the orchestration card path - // reads to prove the registered set is self-consistent. - let _ = CloudAgentSettings::as_ref(ctx); - let _ = Appearance::as_ref(ctx); - let _ = LLMPreferences::as_ref(ctx); - let _ = HarnessAvailabilityModel::as_ref(ctx); - let _ = ConnectedSelfHostedWorkersModel::as_ref(ctx); - let _ = BlocklistAIPermissions::as_ref(ctx); - let _ = AIExecutionProfilesModel::as_ref(ctx); - }); - }); -} diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index 5d22b60794c..b410f026290 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,9 +107,7 @@ pub struct WarpConfig { /// Additional platform-dependent functionality can be found in impl blocks /// in native.rs and wasm.rs. impl WarpConfig { - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn mock(_ctx: &mut ModelContext) -> Self { Self { theme_config: WarpThemeConfig::new(), diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 2f24191f015..0bd9292f212 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -122,7 +122,7 @@ pub struct CreateTeamResponse { } impl UserWorkspaces { - #[cfg(any(test, feature = "test-util"))] + #[cfg(test)] pub fn mock( team_client: Arc, workspace_client: Arc, diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 82ee51cb995..8fd7e4a4bde 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -39,7 +39,7 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; -use crate::run_agents_card_view::{TuiRunAgentsCardView, TuiRunAgentsCardViewEvent}; +use crate::orchestration_block::{TuiOrchestrationBlock, TuiOrchestrationBlockEvent}; use crate::transcript_view::BLOCK_TOP_PADDING_ROWS; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::TuiCLISubagentView; @@ -160,7 +160,7 @@ impl CollapsibleSectionStates { enum TuiToolCallView { FileEdits(ViewHandle), ShellCommand(ViewHandle), - RunAgents(ViewHandle), + OrchestrationBlock(ViewHandle), } impl TuiToolCallView { @@ -169,7 +169,7 @@ impl TuiToolCallView { match self { Self::FileEdits(view) => view.id(), Self::ShellCommand(view) => view.id(), - Self::RunAgents(view) => view.id(), + Self::OrchestrationBlock(view) => view.id(), } } @@ -178,7 +178,7 @@ impl TuiToolCallView { match self { Self::FileEdits(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), - Self::RunAgents(view) => TuiChildView::new(view), + Self::OrchestrationBlock(view) => TuiChildView::new(view), } } } @@ -186,7 +186,7 @@ impl TuiToolCallView { /// The front-of-queue blocking interaction owned by an agent block: the /// child view rendering the pending action that awaits a decision. pub(super) struct TuiBlockingChild { - pub(super) view: ViewHandle, + pub(super) view: ViewHandle, } /// Events emitted to the transcript that owns this rich-content block. @@ -397,9 +397,11 @@ impl TuiAIBlock { let AIAgentActionType::RunAgents(request) = &action.action else { continue; }; - // Existing card: re-sync its edit state from the latest streamed + // Existing block: re-sync its edit state from the latest streamed // chunk (the request may have grown since the view was created). - if let Some(TuiToolCallView::RunAgents(view)) = self.action_views.get(&action.id) { + if let Some(TuiToolCallView::OrchestrationBlock(view)) = + self.action_views.get(&action.id) + { let request = request.clone(); view.update(ctx, |view, ctx| view.update_request(&request, ctx)); continue; @@ -425,7 +427,7 @@ impl TuiAIBlock { let fallback_base_model_id = self.block_model.base_model(ctx).map(|id| id.to_string()); let is_restored = self.block_model.is_restored(); let view = ctx.add_typed_action_tui_view(move |ctx| { - TuiRunAgentsCardView::new( + TuiOrchestrationBlock::new( action, &request, active_config, @@ -438,16 +440,16 @@ impl TuiAIBlock { }); let action_id_for_events = action_id.clone(); ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { - TuiRunAgentsCardViewEvent::RejectRequested => { + TuiOrchestrationBlockEvent::RejectRequested => { me.cancel_action(&action_id_for_events, ctx); } - TuiRunAgentsCardViewEvent::BlockingStateChanged => { + TuiOrchestrationBlockEvent::BlockingStateChanged => { ctx.emit(TuiAIBlockEvent::BlockingStateChanged); me.invalidate_layout(ctx); } }); self.action_views - .insert(action_id, TuiToolCallView::RunAgents(view)); + .insert(action_id, TuiToolCallView::OrchestrationBlock(view)); ctx.notify(); } } @@ -485,7 +487,7 @@ impl TuiAIBlock { return None; } match self.action_views.get(&action_id)? { - TuiToolCallView::RunAgents(view) => view + TuiToolCallView::OrchestrationBlock(view) => view .as_ref(ctx) .wants_focus(ctx) .then(|| TuiBlockingChild { view: view.clone() }), @@ -660,7 +662,7 @@ impl TuiAIBlock { self.last_measured_width.get() != Some(width) || self.block_model.status(app).is_streaming() || self.action_views.values().any(|view| match view { - TuiToolCallView::FileEdits(_) | TuiToolCallView::RunAgents(_) => false, + TuiToolCallView::FileEdits(_) | TuiToolCallView::OrchestrationBlock(_) => false, TuiToolCallView::ShellCommand(view) => { view.as_ref(app).needs_continuous_height_measurement() } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 5b9ba479b1b..44445b00687 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -6,20 +6,18 @@ use std::time::Duration; use markdown_parser::parse_markdown; use parking_lot::FairMutex; use warp::tui_export::{ - register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, - AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, AIAgentExchangeId, - AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, - AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, - AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, - AgentOutputMermaidDiagram, AgentOutputTable, Appearance, BlocklistAIActionModel, LLMId, - MessageId, ModelEventDispatcher, OutputStatusUpdateCallback, RequestCommandOutputResult, - RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, ServerOutputId, Shared, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, + AIAgentActionType, AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, + AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoList, + AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, AgentOutputImage, + AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, Appearance, LLMId, + MessageId, OutputStatusUpdateCallback, RequestCommandOutputResult, ServerOutputId, Shared, SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, ModelHandle, SingletonEntity}; +use warpui::{AddWindowOptions, SingletonEntity}; use warpui_core::elements::tui::{ Color, Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPoint, TuiRect, TuiScreenPosition, @@ -37,11 +35,7 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; -use crate::run_agents_card_view::TuiRunAgentsCardAction; -use crate::test_fixtures::{ - add_active_test_conversation, add_test_action_model_and_events, - add_test_action_model_with_surface, TestHostView, -}; +use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_shell_command_view::TuiShellCommandViewAction; #[test] @@ -1298,177 +1292,6 @@ struct FakeAgentBlockModel { status: AIBlockOutputStatus, } -/// Builds a Local/Oz `RunAgents` tool call with one child agent. -fn run_agents_action(id: &str) -> AIAgentAction { - AIAgentAction { - id: AIAgentActionId::from(id.to_owned()), - task_id: TaskId::new("task-1".to_owned()), - action: AIAgentActionType::RunAgents(RunAgentsRequest { - summary: "Parallelize the task.".to_owned(), - base_prompt: "base".to_owned(), - skills: Vec::new(), - model_id: "auto".to_owned(), - harness_type: "oz".to_owned(), - execution_mode: RunAgentsExecutionMode::Local, - agent_run_configs: vec![RunAgentsAgentRunConfig { - name: "researcher".to_owned(), - prompt: "research".to_owned(), - title: "Researcher".to_owned(), - }], - plan_id: String::new(), - harness_auth_secret_name: None, - }), - requires_result: true, - } -} - -/// Builds an agent block over `actions` against the caller's action model -/// and conversation, so confirmation-queue state is observable on the block. -fn test_agent_block_for_actions( - app: &mut App, - conversation_id: AIConversationId, - action_model: &ModelHandle, - model_events: &ModelHandle, - actions: Vec, -) -> ViewHandle { - let messages = actions - .into_iter() - .enumerate() - .map(|(index, action)| action_message(&format!("message-{index}"), action)) - .collect(); - let model = FakeAgentBlockModel { - inputs: Vec::new(), - status: complete_output_messages(messages), - }; - let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); - let action_model = action_model.clone(); - let model_events = model_events.clone(); - app.update(|ctx| { - let (window_id, _) = ctx.add_tui_window( - AddWindowOptions { - window_style: WindowStyle::NotStealFocus, - ..Default::default() - }, - |_| TestHostView, - ); - ctx.add_typed_action_tui_view(window_id, move |ctx| { - TuiAIBlock::new( - conversation_id, - AIAgentExchangeId::new(), - Rc::new(model), - action_model, - &model_events, - terminal_model, - ctx, - ) - }) - }) -} - -/// The registered RunAgents card view for `action_id`, panicking otherwise. -fn run_agents_card_view( - app: &App, - block: &ViewHandle, - action_id: &AIAgentActionId, -) -> ViewHandle { - app.read(|ctx| match block.as_ref(ctx).action_views.get(action_id) { - Some(TuiToolCallView::RunAgents(view)) => view.clone(), - Some(TuiToolCallView::FileEdits(_)) | Some(TuiToolCallView::ShellCommand(_)) | None => { - panic!("expected a registered RunAgents card view") - } - }) -} - -#[test] -fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmation() { - App::test((), |mut app| async move { - register_orchestration_test_singletons(&mut app); - let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); - let conversation_id = add_active_test_conversation(&mut app, surface_id); - let action = run_agents_action("run-agents-1"); - let block = test_agent_block_for_actions( - &mut app, - conversation_id, - &action_model, - &model_events, - vec![action.clone()], - ); - let card = run_agents_card_view(&app, &block, &action.id); - - // Still streaming / not yet queued: the card renders the fallback - // status and does not hide the input. - assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); - - // Queued and awaiting confirmation: the card is the active blocker - //. - action_model.update(&mut app, |model, ctx| { - model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); - }); - let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - let blocker = blocker.expect("the blocked RunAgents card blocks the input"); - assert_eq!(blocker.view.id(), card.id()); - - // Reject through the card: the block maps it to manual cancellation - // and the blocker resolves, restoring the input. - app.update(|ctx| { - ctx.dispatch_typed_action_for_view( - card.window_id(ctx), - card.id(), - &TuiRunAgentsCardAction::Reject, - ); - }); - assert!(matches!( - app.read(|ctx| action_model.as_ref(ctx).get_action_status(&action.id)), - Some(AIActionStatus::Finished(_)) - )); - assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); - }); -} - -#[test] -fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { - App::test((), |mut app| async move { - register_orchestration_test_singletons(&mut app); - let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); - let conversation_id = add_active_test_conversation(&mut app, surface_id); - let first = run_agents_action("run-agents-1"); - let second = run_agents_action("run-agents-2"); - let block = test_agent_block_for_actions( - &mut app, - conversation_id, - &action_model, - &model_events, - vec![first.clone(), second.clone()], - ); - action_model.update(&mut app, |model, ctx| { - model.queue_pending_action_for_test(conversation_id, first.clone(), ctx); - model.queue_pending_action_for_test(conversation_id, second.clone(), ctx); - }); - - // Pending requests behind the front blocker do not affect input - // visibility. - let first_card = run_agents_card_view(&app, &block, &first.id); - let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - assert_eq!(blocker.expect("front blocker").view.id(), first_card.id()); - - // Resolving the front blocker hands off directly to the next queued - // blocking interaction. - app.update(|ctx| { - ctx.dispatch_typed_action_for_view( - first_card.window_id(ctx), - first_card.id(), - &TuiRunAgentsCardAction::Reject, - ); - }); - let second_card = run_agents_card_view(&app, &block, &second.id); - let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - assert_eq!( - blocker.expect("handed-off blocker").view.id(), - second_card.id() - ); - }); -} - /// Builds an agent block with fresh test identity, registered in a fresh TUI /// window and backed by a real action model. fn test_agent_block(app: &mut App, model: FakeAgentBlockModel) -> ViewHandle { diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index dc1c35ff19b..621b324c315 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -30,8 +30,8 @@ use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; use crate::input::view::TuiInputAction; use crate::input::TuiInputView; use crate::option_selector::TuiOptionSelector; +use crate::orchestration_block::TuiOrchestrationBlock; use crate::root_view::RootTuiView; -use crate::run_agents_card_view::TuiRunAgentsCardView; use crate::terminal_session_view::TuiTerminalSessionView; use crate::transcript_view::TuiTranscriptView; @@ -57,7 +57,7 @@ pub(crate) fn init(app: &mut AppContext) { id!("TuiEditorView"), TuiEditorViewAction::Command, ); - crate::run_agents_card_view::init(app); + crate::orchestration_block::init(app); register_binding_validators(app); } @@ -92,7 +92,7 @@ fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); - app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 6e18041baf5..cfb8df46349 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -33,8 +33,8 @@ mod keybindings; mod mcp_menu; mod model_menu; mod option_selector; +mod orchestration_block; mod resume; -mod run_agents_card_view; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/orchestration_block.rs similarity index 82% rename from crates/warp_tui/src/run_agents_card_view.rs rename to crates/warp_tui/src/orchestration_block.rs index 48bb2feb3b7..b73b786e0f4 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -1,4 +1,4 @@ -//! [`TuiRunAgentsCardView`]: the TUI permission and configuration card for a +//! [`TuiOrchestrationBlock`]: the TUI permission and configuration card for a //! `RunAgents` request. //! //! The card has two interactive modes: an acceptance card summarizing the @@ -6,10 +6,11 @@ //! a dynamic sequence of single-field pages rendered by //! [`TuiOptionSelector`]. Accept dispatches the edited request through the //! shared [`BlocklistAIActionModel::execute_run_agents`] path; Reject emits -//! [`TuiRunAgentsCardViewEvent::RejectRequested`], which the owning +//! [`TuiOrchestrationBlockEvent::RejectRequested`], which the owning //! [`crate::agent_block::TuiAIBlock`] maps to action cancellation. Terminal, //! spawning, streaming, and restored states reuse the existing fallback //! tool-call presentation and its `tool_call_labels` copy. +use std::rc::Rc; use warp::tui_export::{ accept_disabled_reason_with_auth, api_key_snapshot, empty_env_recommendation_message, @@ -41,12 +42,12 @@ use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; -const RUN_AGENTS_CARD_TITLE: &str = "Can I start additional agents for this task?"; +const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; /// Keymap-context flag set while the acceptance card is active. -const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiRunAgentsCardAcceptance"; +const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiOrchestrationBlockAcceptance"; /// Keymap-context flag set while a configuration page is active. -const CONFIGURING_CONTEXT_FLAG: &str = "TuiRunAgentsCardConfiguring"; +const CONFIGURING_CONTEXT_FLAG: &str = "TuiOrchestrationBlockConfiguring"; /// Row ids emitted by `location_snapshot`. const LOCATION_CLOUD_ID: &str = "cloud"; @@ -55,47 +56,55 @@ const LOCATION_CLOUD_ID: &str = "cloud"; /// startup from `keybindings::init`. All bindings are fixed and scoped to /// the card's keymap context, so they only fire while a card is focused. pub(crate) fn init(app: &mut AppContext) { - let acceptance = || id!(TuiRunAgentsCardView::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); - let configuring = || id!(TuiRunAgentsCardView::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); + let acceptance = || id!(TuiOrchestrationBlock::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); + let configuring = || id!(TuiOrchestrationBlock::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); app.register_fixed_bindings([ - FixedBinding::new("enter", TuiRunAgentsCardAction::Accept, acceptance()) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new("numpadenter", TuiRunAgentsCardAction::Accept, acceptance()) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new("ctrl-e", TuiRunAgentsCardAction::Configure, acceptance()) + FixedBinding::new("enter", TuiOrchestrationBlockAction::Accept, acceptance()) .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "numpadenter", + TuiOrchestrationBlockAction::Accept, + acceptance(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-e", + TuiOrchestrationBlockAction::Configure, + acceptance(), + ) + .with_group(TUI_BINDING_GROUP), FixedBinding::new( "enter", - TuiRunAgentsCardAction::ConfirmSelection, + TuiOrchestrationBlockAction::ConfirmSelection, configuring(), ) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "numpadenter", - TuiRunAgentsCardAction::ConfirmSelection, + TuiOrchestrationBlockAction::ConfirmSelection, configuring(), ) .with_group(TUI_BINDING_GROUP), - FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) + FixedBinding::new("escape", TuiOrchestrationBlockAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "left", - TuiRunAgentsCardAction::CommitAndPreviousPage, + TuiOrchestrationBlockAction::CommitAndPreviousPage, configuring(), ) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "right", - TuiRunAgentsCardAction::CommitAndNextPage, + TuiOrchestrationBlockAction::CommitAndNextPage, configuring(), ) .with_group(TUI_BINDING_GROUP), - FixedBinding::new("tab", TuiRunAgentsCardAction::NextPage, configuring()) + FixedBinding::new("tab", TuiOrchestrationBlockAction::NextPage, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "ctrl-c", - TuiRunAgentsCardAction::Reject, - id!(TuiRunAgentsCardView::ui_name()), + TuiOrchestrationBlockAction::Reject, + id!(TuiOrchestrationBlock::ui_name()), ) .with_group(TUI_BINDING_GROUP), ]); @@ -130,6 +139,139 @@ enum ConfigPage { Model, } +/// External orchestration behavior used by the block. +trait OrchestrationBlockController { + /// Returns the current lifecycle status for the action. + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option; + + /// Builds the current option snapshot for a configuration page. + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot; + + /// Commits one page selection into the edit state. + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ); + + /// Returns the reason acceptance is currently unavailable. + fn accept_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option; + + /// Dispatches the edited orchestration request. + fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); +} + +/// Production controller backed by the shared orchestration models. +struct ModelOrchestrationBlockController { + action_model: ModelHandle, +} + +impl OrchestrationBlockController for ModelOrchestrationBlockController { + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option { + self.action_model.as_ref(ctx).get_action_status(action_id) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot { + match page { + ConfigPage::Location => location_snapshot(state, ctx), + ConfigPage::Harness => harness_snapshot(state, ctx), + ConfigPage::ApiKey => api_key_snapshot(state, ctx), + ConfigPage::Host => host_snapshot(state, ctx), + ConfigPage::Environment => environment_snapshot(state, ctx), + ConfigPage::Model => model_snapshot(state, ctx), + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ) { + match page { + ConfigPage::Location => { + edit_state + .orchestration_config_state + .apply_execution_mode_change( + id == LOCATION_CLOUD_ID, + fallback_base_model_id, + ctx, + ); + } + ConfigPage::Harness => { + edit_state.apply_harness_change(id, fallback_base_model_id, ctx); + } + ConfigPage::ApiKey => { + let name = (!id.is_empty()).then(|| id.to_string()); + edit_state + .orchestration_config_state + .apply_auth_secret_change(name, ctx); + } + ConfigPage::Host => { + edit_state + .orchestration_config_state + .set_worker_host(id.to_string()); + persist_host_selection(id, ctx); + } + ConfigPage::Environment => { + edit_state + .orchestration_config_state + .set_environment_id(id.to_string()); + persist_environment_selection(id, ctx); + } + ConfigPage::Model => { + edit_state.orchestration_config_state.model_id = id.to_string(); + } + } + } + + fn accept_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option { + accept_disabled_reason_with_auth(state, ctx) + } + + fn execute( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + ctx: &mut AppContext, + ) { + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(action_id, request, ctx); + }); + } +} + impl ConfigPage { /// The page's question, with agent/agents chosen from the request. fn question(self, agent_count: usize) -> String { @@ -165,7 +307,7 @@ enum PageNavigation { /// Events emitted to the owning agent block. #[derive(Clone, Debug)] -pub(crate) enum TuiRunAgentsCardViewEvent { +pub(crate) enum TuiOrchestrationBlockEvent { /// The user rejected the request; the block cancels the action. RejectRequested, /// The card's blocking/focus state may have changed; ancestors re-derive @@ -175,7 +317,7 @@ pub(crate) enum TuiRunAgentsCardViewEvent { /// Typed actions bound to the card's keybindings. #[derive(Clone, Debug)] -pub(crate) enum TuiRunAgentsCardAction { +pub(crate) enum TuiOrchestrationBlockAction { Accept, Configure, ConfirmSelection, @@ -186,8 +328,8 @@ pub(crate) enum TuiRunAgentsCardAction { Reject, } -/// The TUI `RunAgents` confirmation card view. See the module docs. -pub(crate) struct TuiRunAgentsCardView { +/// The TUI orchestration confirmation block. See the module docs. +pub(crate) struct TuiOrchestrationBlock { action_id: AIAgentActionId, /// The latest streamed tool call, kept in sync by /// [`Self::update_request`]; terminal/streaming states render from it @@ -201,7 +343,7 @@ pub(crate) struct TuiRunAgentsCardView { /// Arrow direction to apply after the selector confirms its current /// value. Enter leaves this unset and follows the normal forward flow. confirmation_navigation: Option, - action_model: ModelHandle, + controller: Rc, /// Approved/disapproved plan config used to resolve inherited fields. active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, /// The conversation's base model, used as the Oz model fallback. @@ -218,8 +360,8 @@ pub(crate) struct TuiRunAgentsCardView { identity_palette: Vec, } -impl TuiRunAgentsCardView { - /// Creates a card for one pending `RunAgents` action and wires its model +impl TuiOrchestrationBlock { + /// Creates a block for one pending `RunAgents` action and wires its model /// subscriptions. #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -232,11 +374,6 @@ impl TuiRunAgentsCardView { is_restored: bool, ctx: &mut ViewContext, ) -> Self { - let selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); - ctx.subscribe_to_view(&selector, |me, _, event, ctx| { - me.handle_selector_event(event, ctx); - }); - let action_id = action.id.clone(); let action_id_for_executor = action_id.clone(); ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event { @@ -246,14 +383,14 @@ impl TuiRunAgentsCardView { } if action_id == &action_id_for_executor => { me.spawning = Some(*snapshot); me.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } RunAgentsExecutorEvent::SpawningFinished { action_id } if action_id == &action_id_for_executor => { me.spawning = None; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } RunAgentsExecutorEvent::SpawningStarted { .. } @@ -266,7 +403,7 @@ impl TuiRunAgentsCardView { if action_id == &action_id_for_actions => { me.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) @@ -276,7 +413,7 @@ impl TuiRunAgentsCardView { // "Configuring agents…" placeholder to the interactive // acceptance card, so resolve display defaults now. me.resolve_interactive_defaults(ctx); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } _ => {} @@ -320,8 +457,40 @@ impl TuiRunAgentsCardView { }, ); - let mut view = Self { - action_id, + let controller = Rc::new(ModelOrchestrationBlockController { action_model }); + let identity_palette = TuiUiBuilder::from_app(ctx).agent_identity_palette(); + let mut view = Self::from_parts( + action, + request, + active_config, + controller, + fallback_base_model_id, + is_restored, + identity_palette, + ctx, + ); + view.resolve_interactive_defaults(ctx); + view + } + + /// Constructs the block from injected external behavior. + #[allow(clippy::too_many_arguments)] + fn from_parts( + action: AIAgentAction, + request: &RunAgentsRequest, + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + controller: Rc, + fallback_base_model_id: Option, + is_restored: bool, + identity_palette: Vec, + ctx: &mut ViewContext, + ) -> Self { + let selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); + ctx.subscribe_to_view(&selector, |me, _, event, ctx| { + me.handle_selector_event(event, ctx); + }); + Self { + action_id: action.id.clone(), action, orchestration_edit_state: OrchestrationEditState::new(Self::config_state_from_request( request, @@ -331,17 +500,35 @@ impl TuiRunAgentsCardView { mode: CardMode::Acceptance, selector, confirmation_navigation: None, - action_model, + controller, active_config, fallback_base_model_id, is_restored, spawning: None, decided: false, accept_error: None, - identity_palette: TuiUiBuilder::from_app(ctx).agent_identity_palette(), - }; - view.resolve_interactive_defaults(ctx); - view + identity_palette, + } + } + + /// Constructs an interactive block around a test controller. + #[cfg(test)] + fn new_for_test( + action: AIAgentAction, + request: &RunAgentsRequest, + controller: Rc, + ctx: &mut ViewContext, + ) -> Self { + Self::from_parts( + action, + request, + None, + controller, + Some("auto".to_string()), + false, + Vec::new(), + ctx, + ) } /// Seeds the run-wide edit state from the streamed request, resolving @@ -433,7 +620,7 @@ impl TuiRunAgentsCardView { self.orchestration_edit_state = OrchestrationEditState::new(new_state); self.resolve_interactive_defaults(ctx); self.refresh_active_page(ctx); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -445,9 +632,7 @@ impl TuiRunAgentsCardView { return false; } matches!( - self.action_model - .as_ref(ctx) - .get_action_status(&self.action_id), + self.controller.action_status(&self.action_id, ctx), Some(AIActionStatus::Blocked) ) } @@ -468,15 +653,11 @@ impl TuiRunAgentsCardView { /// Builds the option snapshot for `page` from the shared builders. fn snapshot_for_page(&self, page: ConfigPage, ctx: &AppContext) -> OptionSnapshot { - let state = &self.orchestration_edit_state.orchestration_config_state; - match page { - ConfigPage::Location => location_snapshot(state, ctx), - ConfigPage::Harness => harness_snapshot(state, ctx), - ConfigPage::ApiKey => api_key_snapshot(state, ctx), - ConfigPage::Host => host_snapshot(state, ctx), - ConfigPage::Environment => environment_snapshot(state, ctx), - ConfigPage::Model => model_snapshot(state, ctx), - } + self.controller.snapshot_for_page( + page, + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) } /// Opens `page`: swaps the selector to its page fields, and @@ -501,7 +682,7 @@ impl TuiRunAgentsCardView { self.selector.update(ctx, |selector, ctx| { selector.set_page(selector_page, ctx); }); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -549,46 +730,13 @@ impl TuiRunAgentsCardView { let CardMode::Configuring { page } = self.mode else { return; }; - match page { - ConfigPage::Location => { - let is_remote = id == LOCATION_CLOUD_ID; - self.orchestration_edit_state - .orchestration_config_state - .apply_execution_mode_change( - is_remote, - self.fallback_base_model_id.clone(), - ctx, - ); - } - ConfigPage::Harness => { - let fallback = self.fallback_base_model_id.clone(); - self.orchestration_edit_state - .apply_harness_change(id, fallback, ctx); - } - ConfigPage::ApiKey => { - let name = (!id.is_empty()).then(|| id.to_string()); - self.orchestration_edit_state - .orchestration_config_state - .apply_auth_secret_change(name, ctx); - } - ConfigPage::Host => { - self.orchestration_edit_state - .orchestration_config_state - .set_worker_host(id.to_string()); - persist_host_selection(id, ctx); - } - ConfigPage::Environment => { - self.orchestration_edit_state - .orchestration_config_state - .set_environment_id(id.to_string()); - persist_environment_selection(id, ctx); - } - ConfigPage::Model => { - self.orchestration_edit_state - .orchestration_config_state - .model_id = id.to_string(); - } - } + self.controller.apply_page_selection( + page, + id, + &mut self.orchestration_edit_state, + self.fallback_base_model_id.clone(), + ctx, + ); self.finish_page_confirmation(page, ctx); } @@ -615,7 +763,7 @@ impl TuiRunAgentsCardView { Some(next) => self.open_page(next, ctx), None => { self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } } @@ -633,7 +781,7 @@ impl TuiRunAgentsCardView { Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); return; }; @@ -704,7 +852,7 @@ impl TuiRunAgentsCardView { // The selector grew or shrank (e.g. scrolling toggled an // overflow marker); ancestors re-measure the card's cached // height so the footer is not clipped. - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } } @@ -726,7 +874,7 @@ impl TuiRunAgentsCardView { if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { return; } - if let Some(reason) = accept_disabled_reason_with_auth( + if let Some(reason) = self.controller.accept_disabled_reason( &self.orchestration_edit_state.orchestration_config_state, ctx, ) { @@ -739,10 +887,8 @@ impl TuiRunAgentsCardView { self.mode = CardMode::Acceptance; let request = self.to_request(); let action_id = self.action_id.clone(); - self.action_model.update(ctx, |action_model, ctx| { - action_model.execute_run_agents(&action_id, request, ctx); - }); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + self.controller.execute(&action_id, request, ctx); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -754,8 +900,8 @@ impl TuiRunAgentsCardView { } self.decided = true; self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::RejectRequested); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::RejectRequested); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -785,7 +931,7 @@ impl TuiRunAgentsCardView { } if matches!(self.mode, CardMode::Configuring { .. }) { self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } } @@ -988,7 +1134,7 @@ impl TuiRunAgentsCardView { TuiText::from_spans([ ("■ ".to_string(), builder.attention_glyph_style()), ( - RUN_AGENTS_CARD_TITLE.to_string(), + ORCHESTRATION_BLOCK_TITLE.to_string(), builder.primary_text_style(), ), ]) @@ -1024,13 +1170,13 @@ impl TuiRunAgentsCardView { } } -impl Entity for TuiRunAgentsCardView { - type Event = TuiRunAgentsCardViewEvent; +impl Entity for TuiOrchestrationBlock { + type Event = TuiOrchestrationBlockEvent; } -impl TuiView for TuiRunAgentsCardView { +impl TuiView for TuiOrchestrationBlock { fn ui_name() -> &'static str { - "TuiRunAgentsCardView" + "TuiOrchestrationBlock" } fn child_view_ids(&self, _app: &AppContext) -> Vec { @@ -1048,10 +1194,7 @@ impl TuiView for TuiRunAgentsCardView { } fn render(&self, app: &AppContext) -> Box { - let status = self - .action_model - .as_ref(app) - .get_action_status(&self.action_id); + let status = self.controller.action_status(&self.action_id, app); // Terminal, spawning, restored, and still-streaming states reuse the // shared fallback tool-call row and its `tool_call_labels` copy. @@ -1099,27 +1242,27 @@ impl TuiView for TuiRunAgentsCardView { } } -impl TypedActionView for TuiRunAgentsCardView { - type Action = TuiRunAgentsCardAction; +impl TypedActionView for TuiOrchestrationBlock { + type Action = TuiOrchestrationBlockAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { - TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), - TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), - TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), - TuiRunAgentsCardAction::CommitAndPreviousPage => { + TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), + TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), + TuiOrchestrationBlockAction::ConfirmSelection => self.handle_confirm_selection(ctx), + TuiOrchestrationBlockAction::CommitAndPreviousPage => { self.handle_arrow_navigation(PageNavigation::Previous, ctx) } - TuiRunAgentsCardAction::CommitAndNextPage => { + TuiOrchestrationBlockAction::CommitAndNextPage => { self.handle_arrow_navigation(PageNavigation::Next, ctx) } - TuiRunAgentsCardAction::NextPage => self.navigate_page(true, ctx), - TuiRunAgentsCardAction::Back => self.handle_back(ctx), - TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), + TuiOrchestrationBlockAction::NextPage => self.navigate_page(true, ctx), + TuiOrchestrationBlockAction::Back => self.handle_back(ctx), + TuiOrchestrationBlockAction::Reject => self.handle_reject(ctx), } } } #[cfg(test)] -#[path = "run_agents_card_view_tests.rs"] +#[path = "orchestration_block_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs new file mode 100644 index 00000000000..e3ad792dbca --- /dev/null +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -0,0 +1,407 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use ai::agent::orchestration_config::{ + OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, +}; +use warp::tui_export::{ + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, Appearance, + AuthSecretSelection, OptionRow, OptionSnapshot, OptionSourceStatus, OrchestrationConfigState, + OrchestrationEditState, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, + TaskId, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, App, ViewHandle}; +use warpui_core::TypedActionView as _; + +use super::{ + build_request, CardMode, ConfigPage, OrchestrationBlockController, TuiOrchestrationBlock, + TuiOrchestrationBlockAction, +}; +use crate::option_selector::TuiOptionSelectorAction; +use crate::test_fixtures::TestHostView; + +/// Builds a request with the given harness and execution mode. +fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { + RunAgentsRequest { + summary: "Parallelize the task.".to_string(), + base_prompt: "base".to_string(), + skills: Vec::new(), + model_id: "auto".to_string(), + harness_type: harness.to_string(), + execution_mode, + agent_run_configs: vec![RunAgentsAgentRunConfig { + name: "researcher".to_string(), + prompt: "research".to_string(), + title: "Researcher".to_string(), + }], + plan_id: "plan-1".to_string(), + harness_auth_secret_name: None, + } +} + +#[test] +fn only_the_model_page_is_searchable() { + assert!(ConfigPage::Model.is_searchable()); + for page in [ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::ApiKey, + ConfigPage::Host, + ConfigPage::Environment, + ] { + assert!(!page.is_searchable(), "{page:?}"); + } +} + +/// A Cloud execution mode with the given env/host. +fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { + RunAgentsExecutionMode::Remote { + environment_id: environment_id.to_string(), + worker_host: worker_host.to_string(), + computer_use_enabled: true, + } +} + +#[test] +fn local_collapses_the_page_sequence_to_two_pages() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("oz", RunAgentsExecutionMode::Local), + None, + ); + assert_eq!( + TuiOrchestrationBlock::page_sequence(&state), + vec![ConfigPage::Location, ConfigPage::Model], + ); +} + +#[test] +fn cloud_oz_uses_five_pages_without_the_api_key_page() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("oz", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiOrchestrationBlock::page_sequence(&state), + vec![ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::Host, + ConfigPage::Environment, + ConfigPage::Model, + ], + ); +} + +#[test] +fn cloud_managed_credential_harness_inserts_the_api_key_page() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("claude", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiOrchestrationBlock::page_sequence(&state), + vec![ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::ApiKey, + ConfigPage::Host, + ConfigPage::Environment, + ConfigPage::Model, + ], + ); +} + +#[test] +fn edit_state_carries_the_request_auth_secret() { + let mut with_secret = request("claude", remote("env-1", "warp")); + with_secret.harness_auth_secret_name = Some("work-key".to_string()); + let state = TuiOrchestrationBlock::config_state_from_request(&with_secret, None); + assert_eq!( + state.auth_secret_selection, + AuthSecretSelection::Named("work-key".to_string()), + ); + // Absence means "no choice yet", not Inherit. + let state = + TuiOrchestrationBlock::config_state_from_request(&request("claude", remote("", "")), None); + assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); +} + +#[test] +fn edit_state_resolves_empty_fields_from_an_approved_config() { + let mut inherit_all = request("", RunAgentsExecutionMode::Local); + inherit_all.model_id = String::new(); + let config = OrchestrationConfig { + model_id: "auto".to_string(), + harness_type: "claude".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-2".to_string(), + worker_host: "warp".to_string(), + }, + }; + let state = TuiOrchestrationBlock::config_state_from_request( + &inherit_all, + Some(&(config.clone(), OrchestrationConfigStatus::Approved)), + ); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "auto"); + assert!(state.execution_mode.is_remote()); + + // A disapproved config does not resolve inherited fields. + let state = TuiOrchestrationBlock::config_state_from_request( + &inherit_all, + Some(&(config, OrchestrationConfigStatus::Disapproved)), + ); + assert_eq!(state.harness_type, ""); + assert!(!state.execution_mode.is_remote()); +} + +#[test] +fn build_request_carries_card_fields_and_edited_run_wide_state() { + let original = request("oz", remote("env-1", "warp")); + let mut state = TuiOrchestrationBlock::config_state_from_request(&original, None); + state.model_id = "gpt-5".to_string(); + state.harness_type = "codex".to_string(); + state.set_environment_id("env-9".to_string()); + state.set_worker_host("self-hosted".to_string()); + state.auth_secret_selection = AuthSecretSelection::Named("codex-key".to_string()); + + let built = build_request(&original, &state); + // Card fields pass through unchanged. + assert_eq!(built.summary, original.summary); + assert_eq!(built.base_prompt, original.base_prompt); + assert_eq!(built.agent_run_configs, original.agent_run_configs); + assert_eq!(built.plan_id, original.plan_id); + // Run-wide fields come from the edited state; the per-call + // computer-use flag is preserved through the round trip. + assert_eq!(built.model_id, "gpt-5"); + assert_eq!(built.harness_type, "codex"); + assert_eq!( + built.execution_mode, + RunAgentsExecutionMode::Remote { + environment_id: "env-9".to_string(), + worker_host: "self-hosted".to_string(), + computer_use_enabled: true, + }, + ); + assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); +} + +#[test] +fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { + // A stale Named(_) selection must not leak into a Local dispatch. + let original = request("claude", RunAgentsExecutionMode::Local); + let mut state = TuiOrchestrationBlock::config_state_from_request(&original, None); + state.auth_secret_selection = AuthSecretSelection::Named("stale".to_string()); + assert_eq!( + build_request(&original, &state).harness_auth_secret_name, + None + ); +} + +#[derive(Default)] +struct TestController { + executed_requests: RefCell>, +} + +impl OrchestrationBlockController for TestController { + fn action_status( + &self, + _action_id: &AIAgentActionId, + _ctx: &warpui::AppContext, + ) -> Option { + Some(AIActionStatus::Blocked) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + _ctx: &warpui::AppContext, + ) -> OptionSnapshot { + let (rows, selected_id) = match page { + ConfigPage::Location => ( + vec![row("cloud", "Cloud"), row("local", "Local")], + if state.execution_mode.is_remote() { + "cloud" + } else { + "local" + }, + ), + ConfigPage::Harness => (vec![row("oz", "Warp")], "oz"), + ConfigPage::ApiKey => (vec![row("", "Skip")], ""), + ConfigPage::Host => (vec![row("warp", "Warp")], "warp"), + ConfigPage::Environment => (vec![row("", "Empty environment")], ""), + ConfigPage::Model => (vec![row("auto", "Auto")], "auto"), + }; + OptionSnapshot { + rows, + selected_id: Some(selected_id.to_string()), + status: OptionSourceStatus::Ready, + footer: None, + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + _fallback_base_model_id: Option, + _ctx: &mut warpui::AppContext, + ) { + let state = &mut edit_state.orchestration_config_state; + match page { + ConfigPage::Location => state.toggle_execution_mode_to_remote(id == "cloud"), + ConfigPage::Harness => state.harness_type = id.to_string(), + ConfigPage::ApiKey => { + state.auth_secret_selection = if id.is_empty() { + AuthSecretSelection::Inherit + } else { + AuthSecretSelection::Named(id.to_string()) + }; + } + ConfigPage::Host => state.set_worker_host(id.to_string()), + ConfigPage::Environment => state.set_environment_id(id.to_string()), + ConfigPage::Model => state.model_id = id.to_string(), + } + } + + fn accept_disabled_reason( + &self, + _state: &OrchestrationConfigState, + _ctx: &warpui::AppContext, + ) -> Option { + None + } + + fn execute( + &self, + _action_id: &AIAgentActionId, + request: RunAgentsRequest, + _ctx: &mut warpui::AppContext, + ) { + self.executed_requests.borrow_mut().push(request); + } +} + +/// Builds an enabled test option row. +fn row(id: &str, label: &str) -> OptionRow { + OptionRow { + id: id.to_string(), + label: label.to_string(), + harness: None, + badge: None, + disabled_reason: None, + } +} + +/// Constructs an interactive block with the local fake controller. +fn test_block( + app: &mut App, + request: &RunAgentsRequest, +) -> (ViewHandle, Rc) { + app.add_singleton_model(|_| Appearance::mock()); + let action = AIAgentAction { + id: AIAgentActionId::from("run-agents-1".to_string()), + task_id: TaskId::new("task-1".to_string()), + action: AIAgentActionType::RunAgents(request.clone()), + requires_result: true, + }; + let controller = Rc::new(TestController::default()); + let controller_for_view: Rc = controller.clone(); + let request = request.clone(); + let view = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiOrchestrationBlock::new_for_test(action, &request, controller_for_view, ctx) + }) + }); + (view, controller) +} + +/// Dispatches a typed action directly to the test block. +fn act( + app: &mut App, + block: &ViewHandle, + action: TuiOrchestrationBlockAction, +) { + block.update(app, |block, ctx| block.handle_action(&action, ctx)); +} + +#[test] +fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &block, TuiOrchestrationBlockAction::Configure); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndPreviousPage, + ); + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(!block + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote()); + assert_eq!( + block.mode, + CardMode::Configuring { + page: ConfigPage::Location + } + ); + }); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveUp, ctx); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndNextPage, + ); + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(block + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote()); + assert_eq!( + block.mode, + CardMode::Configuring { + page: ConfigPage::Harness + } + ); + }); + }); +} + +#[test] +fn accepting_dispatches_once_and_releases_focus() { + App::test((), |mut app| async move { + let request = request("oz", RunAgentsExecutionMode::Local); + let (block, controller) = test_block(&mut app, &request); + assert!(app.read(|ctx| block.as_ref(ctx).wants_focus(ctx))); + + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + act(&mut app, &block, TuiOrchestrationBlockAction::Reject); + + assert_eq!(controller.executed_requests.borrow().as_slice(), &[request]); + assert!(app.read(|ctx| !block.as_ref(ctx).wants_focus(ctx))); + }); +} diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs deleted file mode 100644 index dff0eb74d2a..00000000000 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ /dev/null @@ -1,692 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use ai::agent::orchestration_config::{ - OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, -}; -use warp::tui_export::{ - register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, - AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, OptionRow, OptionSnapshot, - OptionSourceStatus, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, TaskId, -}; -use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, ModelHandle}; -use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; -use warpui_core::presenter::tui::{TuiFrame, TuiPresenter}; -use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; - -use super::{ - build_request, ConfigPage, TuiRunAgentsCardAction, TuiRunAgentsCardView, - TuiRunAgentsCardViewEvent, -}; -use crate::option_selector::TuiOptionSelectorAction; -use crate::test_fixtures::{ - add_active_test_conversation, add_test_action_model_with_surface, TestHostView, -}; -use crate::tui_builder::TuiUiBuilder; - -/// Builds a request with the given harness and execution mode. -fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { - RunAgentsRequest { - summary: "Parallelize the task.".to_string(), - base_prompt: "base".to_string(), - skills: Vec::new(), - model_id: "auto".to_string(), - harness_type: harness.to_string(), - execution_mode, - agent_run_configs: vec![RunAgentsAgentRunConfig { - name: "researcher".to_string(), - prompt: "research".to_string(), - title: "Researcher".to_string(), - }], - plan_id: "plan-1".to_string(), - harness_auth_secret_name: None, - } -} - -#[test] -fn only_the_model_page_is_searchable() { - assert!(ConfigPage::Model.is_searchable()); - for page in [ - ConfigPage::Location, - ConfigPage::Harness, - ConfigPage::ApiKey, - ConfigPage::Host, - ConfigPage::Environment, - ] { - assert!(!page.is_searchable(), "{page:?}"); - } -} - -/// A Cloud execution mode with the given env/host. -fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { - RunAgentsExecutionMode::Remote { - environment_id: environment_id.to_string(), - worker_host: worker_host.to_string(), - computer_use_enabled: true, - } -} - -#[test] -fn local_collapses_the_page_sequence_to_two_pages() { - let state = TuiRunAgentsCardView::config_state_from_request( - &request("oz", RunAgentsExecutionMode::Local), - None, - ); - assert_eq!( - TuiRunAgentsCardView::page_sequence(&state), - vec![ConfigPage::Location, ConfigPage::Model], - ); -} - -#[test] -fn cloud_oz_uses_five_pages_without_the_api_key_page() { - let state = TuiRunAgentsCardView::config_state_from_request( - &request("oz", remote("env-1", "warp")), - None, - ); - assert_eq!( - TuiRunAgentsCardView::page_sequence(&state), - vec![ - ConfigPage::Location, - ConfigPage::Harness, - ConfigPage::Host, - ConfigPage::Environment, - ConfigPage::Model, - ], - ); -} - -#[test] -fn cloud_managed_credential_harness_inserts_the_api_key_page() { - let state = TuiRunAgentsCardView::config_state_from_request( - &request("claude", remote("env-1", "warp")), - None, - ); - assert_eq!( - TuiRunAgentsCardView::page_sequence(&state), - vec![ - ConfigPage::Location, - ConfigPage::Harness, - ConfigPage::ApiKey, - ConfigPage::Host, - ConfigPage::Environment, - ConfigPage::Model, - ], - ); -} - -#[test] -fn edit_state_carries_the_request_auth_secret() { - let mut with_secret = request("claude", remote("env-1", "warp")); - with_secret.harness_auth_secret_name = Some("work-key".to_string()); - let state = TuiRunAgentsCardView::config_state_from_request(&with_secret, None); - assert_eq!( - state.auth_secret_selection, - AuthSecretSelection::Named("work-key".to_string()), - ); - // Absence means "no choice yet", not Inherit. - let state = - TuiRunAgentsCardView::config_state_from_request(&request("claude", remote("", "")), None); - assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); -} - -#[test] -fn edit_state_resolves_empty_fields_from_an_approved_config() { - let mut inherit_all = request("", RunAgentsExecutionMode::Local); - inherit_all.model_id = String::new(); - let config = OrchestrationConfig { - model_id: "auto".to_string(), - harness_type: "claude".to_string(), - execution_mode: OrchestrationExecutionMode::Remote { - environment_id: "env-2".to_string(), - worker_host: "warp".to_string(), - }, - }; - let state = TuiRunAgentsCardView::config_state_from_request( - &inherit_all, - Some(&(config.clone(), OrchestrationConfigStatus::Approved)), - ); - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "auto"); - assert!(state.execution_mode.is_remote()); - - // A disapproved config does not resolve inherited fields. - let state = TuiRunAgentsCardView::config_state_from_request( - &inherit_all, - Some(&(config, OrchestrationConfigStatus::Disapproved)), - ); - assert_eq!(state.harness_type, ""); - assert!(!state.execution_mode.is_remote()); -} - -#[test] -fn build_request_carries_card_fields_and_edited_run_wide_state() { - let original = request("oz", remote("env-1", "warp")); - let mut state = TuiRunAgentsCardView::config_state_from_request(&original, None); - state.model_id = "gpt-5".to_string(); - state.harness_type = "codex".to_string(); - state.set_environment_id("env-9".to_string()); - state.set_worker_host("self-hosted".to_string()); - state.auth_secret_selection = AuthSecretSelection::Named("codex-key".to_string()); - - let built = build_request(&original, &state); - // Card fields pass through unchanged. - assert_eq!(built.summary, original.summary); - assert_eq!(built.base_prompt, original.base_prompt); - assert_eq!(built.agent_run_configs, original.agent_run_configs); - assert_eq!(built.plan_id, original.plan_id); - // Run-wide fields come from the edited state; the per-call - // computer-use flag is preserved through the round trip. - assert_eq!(built.model_id, "gpt-5"); - assert_eq!(built.harness_type, "codex"); - assert_eq!( - built.execution_mode, - RunAgentsExecutionMode::Remote { - environment_id: "env-9".to_string(), - worker_host: "self-hosted".to_string(), - computer_use_enabled: true, - }, - ); - assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); -} - -#[test] -fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { - // A stale Named(_) selection must not leak into a Local dispatch. - let original = request("claude", RunAgentsExecutionMode::Local); - let mut state = TuiRunAgentsCardView::config_state_from_request(&original, None); - state.auth_secret_selection = AuthSecretSelection::Named("stale".to_string()); - assert_eq!( - build_request(&original, &state).harness_auth_secret_name, - None - ); -} - -// ── Blocked-card fixtures ──────────────────────────────────── - -type CapturedCardEvents = Rc>>; - -struct BlockedCard { - card: ViewHandle, - action_model: ModelHandle, - action_id: AIAgentActionId, - events: CapturedCardEvents, -} - -/// Queues `request` as a Blocked `RunAgents` action against the real action -/// model and constructs an interactive card for it. -fn blocked_card(app: &mut App, request: &RunAgentsRequest) -> BlockedCard { - register_orchestration_test_singletons(app); - let (action_model, _, terminal_surface_id) = add_test_action_model_with_surface(app); - let conversation_id = add_active_test_conversation(app, terminal_surface_id); - let action = AIAgentAction { - id: AIAgentActionId::from("run-agents-1".to_string()), - task_id: TaskId::new("task-1".to_string()), - action: AIAgentActionType::RunAgents(request.clone()), - requires_result: true, - }; - let action_id = action.id.clone(); - action_model.update(app, |model, ctx| { - model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); - }); - - let card_action_model = action_model.clone(); - let request = request.clone(); - let card = app.update(|ctx| { - let (window_id, _) = ctx.add_tui_window( - AddWindowOptions { - window_style: WindowStyle::NotStealFocus, - ..Default::default() - }, - |_| TestHostView, - ); - let run_agents_executor = card_action_model.as_ref(ctx).run_agents_executor(ctx); - ctx.add_typed_action_tui_view(window_id, move |ctx| { - TuiRunAgentsCardView::new( - action, - &request, - None, - card_action_model, - run_agents_executor, - Some("auto".to_string()), - false, - ctx, - ) - }) - }); - let events: CapturedCardEvents = Rc::new(RefCell::new(Vec::new())); - let events_for_subscription = events.clone(); - app.update(|ctx| { - ctx.subscribe_to_view(&card, move |_, event, _| { - events_for_subscription.borrow_mut().push(event.clone()); - }); - }); - BlockedCard { - card, - action_model, - action_id, - events, - } -} - -/// Renders the card through the real presenter so child views resolve. -fn render_card_frame( - app: &mut App, - card: &ViewHandle, - width: u16, -) -> TuiFrame { - let mut presenter = TuiPresenter::new(); - app.update(|ctx| { - let window_id = card.window_id(ctx); - // Mirror the runtime's draw: `invalidate` renders the card and its - // selector into the presenter cache, then `present` resolves the - // embedded selector via `TuiChildView`. - let mut invalidation = WindowInvalidation::default(); - invalidation.updated.insert(card.id()); - invalidation.updated.insert(card.as_ref(ctx).selector.id()); - presenter.invalidate(&invalidation, ctx, window_id); - presenter.present(ctx, card, TuiRect::new(0, 0, width, 60)) - }) -} - -/// Returns the card's trimmed rendered lines at `width`. -fn render_card_lines( - app: &mut App, - card: &ViewHandle, - width: u16, -) -> Vec { - render_card_frame(app, card, width) - .buffer - .to_lines() - .into_iter() - .map(|line| line.trim_end().to_owned()) - .collect() -} - -/// Dispatches a card action directly to the view. -fn act(app: &mut App, card: &ViewHandle, action: TuiRunAgentsCardAction) { - card.update(app, |card, ctx| card.handle_action(&action, ctx)); -} - -/// The action's current status in the real action model. -fn action_status(app: &App, fixture: &BlockedCard) -> Option { - app.read(|app| { - fixture - .action_model - .as_ref(app) - .get_action_status(&fixture.action_id) - }) -} - -#[test] -fn acceptance_card_renders_required_content_across_widths() { - App::test((), |mut app| async move { - let mut two_agents = request("oz", remote("", "warp")); - two_agents.agent_run_configs.push(RunAgentsAgentRunConfig { - name: "reviewer".to_string(), - prompt: "review".to_string(), - title: "Reviewer".to_string(), - }); - let fixture = blocked_card(&mut app, &two_agents); - for width in [40u16, 80, 132] { - let lines = render_card_lines(&mut app, &fixture.card, width); - let all = lines.join("\n"); - // question, agent names, and run-wide values. - assert!(all.contains("Can I start"), "width {width}: {all}"); - assert!(all.contains("Agents (2):"), "width {width}"); - assert!(all.contains("researcher"), "width {width}"); - assert!(all.contains("reviewer"), "width {width}"); - assert!(all.contains("Location:"), "width {width}"); - assert!(all.contains("Cloud"), "width {width}"); - assert!(all.contains("Harness:"), "width {width}"); - assert!(all.contains("Model:"), "width {width}"); - assert!(all.contains("Host:"), "width {width}"); - assert!(all.contains("Environment:"), "width {width}"); - // The footer hints replace the input footer and wrap (rather - // than clip) at narrow widths; compare whitespace-normalized - // so a wrapped key name still matches. - let flat = all.split_whitespace().collect::>().join(" "); - assert!(flat.contains("Enter to accept"), "width {width}: {all}"); - assert!(flat.contains("Ctrl + E to edit"), "width {width}: {all}"); - assert!(flat.contains("Ctrl + C to reject"), "width {width}: {all}"); - } - }); -} - -#[test] -fn acceptance_card_matches_the_design_layout_and_styles() { - App::test((), |mut app| async move { - let mut seven_agents = request("oz", remote("", "warp")); - seven_agents.agent_run_configs = [ - "infrastructure-bot", - "ui-implementer", - "dependency-bot", - "verification-bot", - "design-bot", - "event-pipeline-monitor", - "performance-regression-guard", - ] - .into_iter() - .map(|name| RunAgentsAgentRunConfig { - name: name.to_string(), - prompt: "work".to_string(), - title: name.to_string(), - }) - .collect(); - let fixture = blocked_card(&mut app, &seven_agents); - - let lines = render_card_lines(&mut app, &fixture.card, 80); - // Header row, blank body padding row, then the inset agent list. - assert!(lines[0].starts_with(" ■ Can I start additional agents for this task?")); - assert!(lines[1].trim().is_empty()); - assert!(lines[2].starts_with(" Agents (7):")); - // The glyph is hash-assigned; assert the inset and the first name. - assert!(lines[3].starts_with(" "), "{}", lines[3]); - assert!(lines[3].contains("infrastructure-bot"), "{}", lines[3]); - // The identity line wraps with muted bullet separators, and the - // metadata renders as one inline row after a blank separator. - assert!(lines[3].contains(" • "), "{}", lines[3]); - let metadata = lines - .iter() - .find(|line| line.contains("Location: ")) - .expect("inline metadata row"); - assert!(metadata.contains("Location: Cloud")); - assert!(metadata.contains(" • ")); - assert!(metadata.contains("Harness: ")); - - let frame = render_card_frame(&mut app, &fixture.card, 80); - let builder_styles = app.read(|app| { - let builder = TuiUiBuilder::from_app(app); - ( - builder.orchestration_header_background(), - builder.orchestration_surface_background(), - ) - }); - let (header_bg, surface_bg) = builder_styles; - // Distinct header tint over the body tint; footer stays untinted. - assert_ne!(header_bg, surface_bg); - assert_eq!(frame.buffer[(0, 0)].bg, header_bg); - assert_eq!(frame.buffer[(0, 1)].bg, surface_bg); - let footer_row = render_card_lines(&mut app, &fixture.card, 80) - .iter() - .position(|line| line.contains("Enter to accept")) - .expect("acceptance footer row") as u16; - assert_ne!(frame.buffer[(0, footer_row)].bg, header_bg); - assert_ne!(frame.buffer[(0, footer_row)].bg, surface_bg); - // The row above the footer is an untinted margin row. - assert_ne!(frame.buffer[(0, footer_row - 1)].bg, header_bg); - assert_ne!(frame.buffer[(0, footer_row - 1)].bg, surface_bg); - // The agent glyph and name share the identity color, with the - // name bolded; identity colors are set (not default foreground). - let glyph_cell = &frame.buffer[(3, 3)]; - let name_cell = &frame.buffer[(5, 3)]; - assert_eq!(glyph_cell.fg, name_cell.fg); - assert!(name_cell.modifier.contains(Modifier::BOLD)); - assert!(!glyph_cell.modifier.contains(Modifier::BOLD)); - }); -} - -#[test] -fn agent_identities_stay_stable_across_rerenders_and_edits() { - App::test((), |mut app| async move { - let base = request("oz", RunAgentsExecutionMode::Local); - let fixture = blocked_card(&mut app, &base); - fn agent_line(app: &mut App, fixture: &BlockedCard) -> String { - render_card_lines(app, &fixture.card, 80) - .into_iter() - .find(|line| line.contains("researcher")) - .expect("agent row") - .split("•") - .find(|entry| entry.contains("researcher")) - .expect("researcher entry") - .trim() - .to_string() - } - let before = agent_line(&mut app, &fixture); - // Stable across plain re-renders… - assert_eq!(before, agent_line(&mut app, &fixture)); - // …and across a streamed edit that appends an agent. - let mut extended = base.clone(); - extended.agent_run_configs.push(RunAgentsAgentRunConfig { - name: "reviewer".to_string(), - prompt: "review".to_string(), - title: "Reviewer".to_string(), - }); - fixture.card.update(&mut app, |card, ctx| { - card.update_request(&extended, ctx); - }); - assert_eq!(before, agent_line(&mut app, &fixture)); - }); -} - -#[test] -fn accept_dispatches_through_the_action_model_exactly_once() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); - assert!(matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) - )); - assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); - - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); - // The action left the Blocked queue through `execute_run_agents` - // and the card stopped blocking the input. - assert!(!matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) | None - )); - assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); - - // A second decision is a no-op: no reject can follow. - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); - assert!(!fixture - .events - .borrow() - .iter() - .any(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested))); - }); -} - -#[test] -fn reject_emits_the_cancellation_event_exactly_once() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); - let rejects = fixture - .events - .borrow() - .iter() - .filter(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested)) - .count(); - // Exactly one decision; the card stops blocking. - assert_eq!(rejects, 1); - assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); - }); -} - -#[test] -fn invalid_configurations_cannot_launch_and_surface_a_reason() { - App::test((), |mut app| async move { - // OpenCode + Cloud is a hard block. - let fixture = blocked_card(&mut app, &request("opencode", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); - // The card stays active and blocked, showing the reason. - assert!(matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) - )); - assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("OpenCode is not supported on Cloud yet")); - }); -} - -#[test] -fn configure_walks_pages_and_esc_returns_to_acceptance() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let lines = render_card_lines(&mut app, &fixture.card, 80); - let all = lines.join("\n"); - assert!(lines[0].contains("■ Can I start additional agents for this task?")); - assert!(lines[1].trim().is_empty()); - assert!(lines[2].starts_with(" Edit agent configuration")); - assert!(lines[2].contains("← 1 of 5 →")); - assert!(lines[3].trim().is_empty()); - assert!(lines[4].starts_with(" Where should the agent run?")); - assert!(lines[5].starts_with(" (1) Cloud")); - assert!(!all.contains("Search:")); - assert!(all.contains("Enter to accept")); - assert!(all.contains("Tab or ← → to navigate")); - assert!(all.contains("Esc to go back")); - - let frame = render_card_frame(&mut app, &fixture.card, 80); - let (header_bg, surface_bg) = app.read(|app| { - let builder = TuiUiBuilder::from_app(app); - ( - builder.orchestration_header_background(), - builder.orchestration_surface_background(), - ) - }); - assert_eq!(frame.buffer[(0, 0)].bg, header_bg); - assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); - let footer_row = lines - .iter() - .position(|line| line.contains("Enter to accept")) - .expect("configuration footer row"); - assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface_bg); - - // Esc returns to the acceptance card without deciding. - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Enter to accept")); - assert!(matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) - )); - }); -} - -#[test] -fn scrolling_a_long_option_list_requests_a_card_remeasure() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - // Give the page more rows than the viewport so moving the selection - // eventually scrolls and toggles an overflow marker. - selector.update(&mut app, |selector, ctx| { - let rows = (0..8) - .map(|index| OptionRow { - id: format!("row-{index}"), - label: format!("row-{index}"), - harness: None, - badge: None, - disabled_reason: None, - }) - .collect(); - selector.refresh_snapshot( - OptionSnapshot { - rows, - selected_id: Some("row-0".to_string()), - status: OptionSourceStatus::Ready, - footer: None, - }, - ctx, - ); - }); - fixture.events.borrow_mut().clear(); - - // Scrolling past the viewport reveals the `↑` marker: the card asks - // its ancestors to re-measure so the taller card is not clipped. - for _ in 0..6 { - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); - }); - } - assert!(fixture - .events - .borrow() - .iter() - .any(|event| matches!(event, TuiRunAgentsCardViewEvent::BlockingStateChanged))); - }); -} - -#[test] -fn switching_to_local_mid_flow_collapses_the_sequence() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - // Select "Local" (second row) and confirm it: the sequence - // collapses to Location, Model and advances to Model. - let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); - }); - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::ConfirmSelection, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Which model should the agent use?"), "{all}"); - assert!(all.contains("2 of 2"), "{all}"); - assert!(all.contains("Search:"), "{all}"); - }); -} - -#[test] -fn horizontal_navigation_commits_the_selection_before_moving() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - - // Left commits Local and clamps on the first page. - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); - }); - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::CommitAndPreviousPage, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Where should the agent run?"), "{all}"); - assert!(all.contains("← 1 of 2 →"), "{all}"); - assert!(app.read(|app| { - !fixture - .card - .as_ref(app) - .orchestration_edit_state - .orchestration_config_state - .execution_mode - .is_remote() - })); - - // Right commits Cloud, expands the sequence, and moves to Harness. - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveUp, ctx); - }); - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::CommitAndNextPage, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Which harness should the agent use?"), "{all}"); - assert!(all.contains("← 2 of 5 →"), "{all}"); - assert!(app.read(|app| { - fixture - .card - .as_ref(app) - .orchestration_edit_state - .orchestration_config_state - .execution_mode - .is_remote() - })); - }); -} diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index 68922b5495e..ac4d793d6ad 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -7,7 +7,7 @@ use warp::tui_export::{ ModelEventDispatcher, Sessions, TerminalModel, }; use warp_core::semantic_selection::SemanticSelection; -use warpui::{AddSingletonModel, App, EntityId, ModelHandle, SingletonEntity as _}; +use warpui::{AddSingletonModel, App, EntityId, ModelHandle}; use warpui_core::elements::tui::{TuiElement, TuiText}; use warpui_core::{AppContext, Entity, TuiView, TypedActionView}; @@ -48,21 +48,6 @@ pub(crate) fn add_test_action_model_and_events( ) -> ( ModelHandle, ModelHandle, -) { - let (action_model, dispatcher, _) = add_test_action_model_with_surface(app); - (action_model, dispatcher) -} - -/// [`add_test_action_model_and_events`] plus the terminal-surface id the -/// action model was built with, so tests can register an active conversation -/// for that surface in the history model (required by -/// `BlocklistAIActionModel::get_pending_action`). -pub(crate) fn add_test_action_model_with_surface( - app: &mut App, -) -> ( - ModelHandle, - ModelHandle, - EntityId, ) { add_test_semantic_selection(app); // Read as a singleton by the action model's executors. @@ -88,23 +73,5 @@ pub(crate) fn add_test_action_model_with_surface( ctx, ) }); - (action_model, dispatcher, terminal_surface_id) -} - -/// Creates a live, active conversation for `terminal_surface_id` in the -/// history model, returning its id. Combined with -/// `BlocklistAIActionModel::queue_pending_action_for_test`, this drives an -/// action into `Blocked` status for confirmation-flow tests. -pub(crate) fn add_active_test_conversation( - app: &mut App, - terminal_surface_id: EntityId, -) -> warp::tui_export::AIConversationId { - app.update(|ctx| { - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - let conversation_id = - history.start_new_conversation(terminal_surface_id, false, false, false, ctx); - history.set_active_conversation_id(conversation_id, terminal_surface_id, ctx); - conversation_id - }) - }) + (action_model, dispatcher) } diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 199c91b5c99..e5536384379 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -30,13 +30,13 @@ Live catalogs come from `HarnessAvailabilityModel` ([`app/src/ai/harness_availab There is no TUI permission/confirmation UI for `RunAgents` today and no generalized input-hiding mechanism; the closest precedent is the inline-menu overlay, which keeps the input visible and focused. ## Proposed changes -### 1. TUI RunAgents card `crates/warp_tui/src/run_agents_card_view.rs` -New `TuiToolCallView::RunAgents(ViewHandle)` variant, constructed in `TuiAIBlock::sync_action_views` for `AIAgentActionType::RunAgents` actions (mirroring `ensure_run_agents_card_view`'s active-config lookup via `conversation.orchestration_config_for_plan(&request.plan_id)` at [`block.rs:7069-7083`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7069-L7083), including `update_request` re-syncs while streaming). +### 1. TUI orchestration block `crates/warp_tui/src/orchestration_block.rs` +New `TuiToolCallView::OrchestrationBlock(ViewHandle)` variant, constructed in `TuiAIBlock::sync_action_views` for `AIAgentActionType::RunAgents` actions (mirroring `ensure_run_agents_card_view`'s active-config lookup via `conversation.orchestration_config_for_plan(&request.plan_id)` at [`block.rs:7069-7083`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7069-L7083), including `update_request` re-syncs while streaming). View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. - Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). -- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. +- Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. - Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected @@ -52,7 +52,7 @@ View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_c ### 2. Generalized input replacement (derived, no stored flag) Input visibility is a pure function of the front-of-queue blocker rather than a suppression boolean: -- `TuiAIBlock` gains `active_blocking_child(&self, ctx) -> Option` (`{ action_id, view_id }`): the front pending action for the conversation (`BlocklistAIActionModel::get_pending_action`) when its status is `Blocked` and its registered child view reports `wants_focus(ctx)`. `TuiRunAgentsCardView::wants_focus` is true in Acceptance/Configuring and false once accepted, rejected, spawning, or finished — matching PRODUCT (1-8). Deriving from the action queue (not transcript order) keeps semantics identical to the GUI's `focus_subview_if_necessary` ([`block.rs:4913-4954`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4913-L4954)). +- `TuiAIBlock` gains `active_blocking_child(&self, ctx) -> Option` (`{ action_id, view_id }`): the front pending action for the conversation (`BlocklistAIActionModel::get_pending_action`) when its status is `Blocked` and its registered child view reports `wants_focus(ctx)`. `TuiOrchestrationBlock::wants_focus` is true in Acceptance/Configuring and false once accepted, rejected, spawning, or finished — matching PRODUCT (1-8). Deriving from the action queue (not transcript order) keeps semantics identical to the GUI's `focus_subview_if_necessary` ([`block.rs:4913-4954`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4913-L4954)). - `TuiTranscriptView` exposes the same query over its agent blocks; `TuiTerminalSessionView::render` calls it once per pass. When `Some`, the session view omits the input box and normal footer from its element tree and the card renders its own hint footer; when `None`, it renders input + footer as today. - Focus: on the `None → Some` transition the session view records that the input was focused and focuses the blocker view; on `Some(a) → Some(b)` it focuses `b` directly (no intermediate editable input, PRODUCT 6); on `Some → None` it restores focus to the input (PRODUCT 5). Draft/cursor/selection/scroll are untouched by construction — nothing in this path writes to the input model. - Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. @@ -63,26 +63,13 @@ Input visibility is a pure function of the front-of-queue blocker rather than a ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. -### 5. Frontend test seams -So the TUI card tests can exercise the real accept/reject paths: -- `BlocklistAIActionModel::cancel_action_with_id` becomes `pub`, letting the TUI reject path invoke it through the seam. -- `BlocklistAIActionModel::queue_pending_action_for_test` (test/`test-util` only) enqueues a `Blocked` pending action so frontend tests can drive confirmation flows against the real action model. -- `register_orchestration_test_singletons` in `tui_export.rs` (test/`test-util` only) registers the settings machinery, auth/server/cloud-object singletons, and catalog + permission models (including `AIDocumentModel` for plan publication) that the card's snapshot builders and accept path read; `app/Cargo.toml` widens the `test-util` feature to the crates these singletons need, and `tui_export_tests.rs` smoke-tests the bootstrap. ## Testing and validation -TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): -- Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). -- Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). -- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, six-row viewport, and external footer styling. -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, arrow navigation that commits before moving, and Tab navigation that leaves the current highlight uncommitted. -- Selector behavior: selection movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). -- Model-page search: selector-owned `Search:` chrome, list-first focus, digit - shortcuts, digit-containing queries, filtering/no-match rendering, and first-match - confirmation; non-model pages render no search editor. -- Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). -- Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). -- Accept dispatch asserts `execute_run_agents` receives exactly the edited request (via the real `BlocklistAIActionModel` fixture used in `agent_block_tests.rs`); reject asserts `cancel_action_with_id` and terminal render — PRODUCT (55-57). -- `keybindings_tests.rs` validator covers the new bindings as TUI-owned. +Focused unit coverage: +- `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. The two interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. +- `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. +- `agent_identity_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. +- `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. @@ -93,7 +80,7 @@ The work ships as a four-PR Graphite stack, each mergeable on its own: 1. `harry/code-1822-edit-state` — the frontend-neutral orchestration domain module (`app/src/ai/orchestration/`: edit state, session, transitions, providers, validation) and the executor retarget; specified in [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md). 2. `harry/code-1822-option-snapshots` — option snapshots and their builders, plus the behavior-preserving GUI picker adaptation onto them; specified in [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md). 3. `harry/code-1822-tui-option-selector` — the reusable `TuiOptionSelector` primitive; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). -4. The final PR (this spec's remaining scope) — the TUI RunAgents card, generalized input replacement, theming and agent identity, and the frontend test seams, reviewed against the PRODUCT invariants. +4. The final PR (this spec's remaining scope) — the TUI orchestration block, generalized input replacement, theming, and agent identity, reviewed against the PRODUCT invariants. ## Risks and mitigations - Catalog events arriving mid-configuration can reshape option lists — the selector preserves the selected id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. From 2a2761db9cf316718d410e7bfd186a7bd2a08551 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:39:39 -0400 Subject: [PATCH 15/40] remove unnecessary registrations --- crates/warp_tui/src/orchestration_block.rs | 21 --------------------- specs/CODE-1822/TECH.md | 2 +- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index b73b786e0f4..5c82242ebe3 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -73,18 +73,6 @@ pub(crate) fn init(app: &mut AppContext) { acceptance(), ) .with_group(TUI_BINDING_GROUP), - FixedBinding::new( - "enter", - TuiOrchestrationBlockAction::ConfirmSelection, - configuring(), - ) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new( - "numpadenter", - TuiOrchestrationBlockAction::ConfirmSelection, - configuring(), - ) - .with_group(TUI_BINDING_GROUP), FixedBinding::new("escape", TuiOrchestrationBlockAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( @@ -320,7 +308,6 @@ pub(crate) enum TuiOrchestrationBlockEvent { pub(crate) enum TuiOrchestrationBlockAction { Accept, Configure, - ConfirmSelection, CommitAndPreviousPage, CommitAndNextPage, NextPage, @@ -936,13 +923,6 @@ impl TuiOrchestrationBlock { } } - /// Confirms the selector's selected option (Enter). - fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { - self.confirmation_navigation = None; - self.selector.update(ctx, |selector, ctx| { - selector.confirm_selected(ctx); - }); - } /// Confirms the selector's selected option, then navigates in the arrow's /// direction once the selector emits its confirmation event. fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { @@ -1249,7 +1229,6 @@ impl TypedActionView for TuiOrchestrationBlock { match action { TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), - TuiOrchestrationBlockAction::ConfirmSelection => self.handle_confirm_selection(ctx), TuiOrchestrationBlockAction::CommitAndPreviousPage => { self.handle_arrow_navigation(PageNavigation::Previous, ctx) } diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index e5536384379..a9df2cd393b 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -36,7 +36,7 @@ New `TuiToolCallView::OrchestrationBlock(ViewHandle)` var View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. - Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). -- Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. +- Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): acceptance owns `enter`/`numpadenter` → Accept and `ctrl-e` → Configure; configuration owns `esc` → Back, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation; `ctrl-c` → Reject applies in either mode. The embedded selector owns configuration-page Enter/Numpad Enter confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. - Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected From 5b1d3210ecd4aa73b7a89a29cb9ccefbd2d7d5ea Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:51:41 -0400 Subject: [PATCH 16/40] split out responsibilities --- crates/warp_tui/src/orchestration_block.rs | 496 +----------------- .../src/orchestration_block/configuration.rs | 194 +++++++ .../src/orchestration_block/render.rs | 267 ++++++++++ 3 files changed, 483 insertions(+), 474 deletions(-) create mode 100644 crates/warp_tui/src/orchestration_block/configuration.rs create mode 100644 crates/warp_tui/src/orchestration_block/render.rs diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 5c82242ebe3..3ad0baeacc3 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -13,48 +13,41 @@ use std::rc::Rc; use warp::tui_export::{ - accept_disabled_reason_with_auth, api_key_snapshot, empty_env_recommendation_message, - environment_snapshot, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, - persist_environment_selection, persist_host_selection, - resolve_auth_secret_selection_for_harness, resolve_default_environment_id, - resolve_default_host_slug, should_show_auth_secret_picker, AIActionStatus, AIAgentAction, - AIAgentActionId, AIAgentActionType, AuthSecretSelection, BlocklistAIActionEvent, - BlocklistAIActionModel, Harness, HarnessAvailabilityEvent, HarnessAvailabilityModel, - LLMPreferences, LLMPreferencesEvent, OptionSnapshot, OrchestrationConfig, - OrchestrationConfigState, OrchestrationConfigStatus, OrchestrationEditState, - RunAgentsExecutionMode, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsRequest, - RunAgentsSpawningSnapshot, ORCHESTRATION_WARP_WORKER_HOST, + persist_host_selection, resolve_auth_secret_selection_for_harness, + resolve_default_environment_id, resolve_default_host_slug, should_show_auth_secret_picker, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, AuthSecretSelection, + BlocklistAIActionEvent, BlocklistAIActionModel, Harness, HarnessAvailabilityEvent, + HarnessAvailabilityModel, LLMPreferences, LLMPreferencesEvent, OptionSnapshot, + OrchestrationConfig, OrchestrationConfigState, OrchestrationConfigStatus, + OrchestrationEditState, RunAgentsExecutionMode, RunAgentsExecutor, RunAgentsExecutorEvent, + RunAgentsRequest, RunAgentsSpawningSnapshot, ORCHESTRATION_WARP_WORKER_HOST, }; use warpui::SingletonEntity; -use warpui_core::elements::tui::{ - Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, -}; -use warpui_core::elements::CrossAxisAlignment; +use warpui_core::elements::tui::TuiElement; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, FixedBinding}; use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; +mod configuration; +mod render; -use crate::agent_block_sections::render_fallback_tool_call_section; -use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::agent_identity::AgentIdentity; use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; +use configuration::{ + build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, +}; + const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; /// Keymap-context flag set while the acceptance card is active. const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiOrchestrationBlockAcceptance"; /// Keymap-context flag set while a configuration page is active. const CONFIGURING_CONTEXT_FLAG: &str = "TuiOrchestrationBlockConfiguring"; - -/// Row ids emitted by `location_snapshot`. -const LOCATION_CLOUD_ID: &str = "cloud"; - -/// Registers the card's keybindings. Called once at TUI -/// startup from `keybindings::init`. All bindings are fixed and scoped to -/// the card's keymap context, so they only fire while a card is focused. +/// Registers fixed card keybindings scoped to the active card mode. pub(crate) fn init(app: &mut AppContext) { let acceptance = || id!(TuiOrchestrationBlock::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); let configuring = || id!(TuiOrchestrationBlock::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); @@ -98,189 +91,7 @@ pub(crate) fn init(app: &mut AppContext) { ]); } -/// Builds the dispatched request from the card fields and the edited -/// run-wide state, exactly as the GUI's `RunAgentsEditState::to_request` -/// does (auth via `auth_secret_name()`, `computer_use_enabled` preserved -/// through the cloned execution mode). -fn build_request(fields: &RunAgentsRequest, state: &OrchestrationConfigState) -> RunAgentsRequest { - RunAgentsRequest { - summary: fields.summary.clone(), - base_prompt: fields.base_prompt.clone(), - skills: fields.skills.clone(), - model_id: state.model_id.clone(), - harness_type: state.harness_type.clone(), - execution_mode: state.execution_mode.clone(), - agent_run_configs: fields.agent_run_configs.clone(), - plan_id: fields.plan_id.clone(), - harness_auth_secret_name: state.auth_secret_name().map(str::to_string), - } -} - -/// One single-field configuration page. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ConfigPage { - Location, - Harness, - ApiKey, - Host, - Environment, - Model, -} - -/// External orchestration behavior used by the block. -trait OrchestrationBlockController { - /// Returns the current lifecycle status for the action. - fn action_status( - &self, - action_id: &AIAgentActionId, - ctx: &AppContext, - ) -> Option; - - /// Builds the current option snapshot for a configuration page. - fn snapshot_for_page( - &self, - page: ConfigPage, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> OptionSnapshot; - - /// Commits one page selection into the edit state. - fn apply_page_selection( - &self, - page: ConfigPage, - id: &str, - edit_state: &mut OrchestrationEditState, - fallback_base_model_id: Option, - ctx: &mut AppContext, - ); - - /// Returns the reason acceptance is currently unavailable. - fn accept_disabled_reason( - &self, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option; - - /// Dispatches the edited orchestration request. - fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); -} - -/// Production controller backed by the shared orchestration models. -struct ModelOrchestrationBlockController { - action_model: ModelHandle, -} - -impl OrchestrationBlockController for ModelOrchestrationBlockController { - fn action_status( - &self, - action_id: &AIAgentActionId, - ctx: &AppContext, - ) -> Option { - self.action_model.as_ref(ctx).get_action_status(action_id) - } - - fn snapshot_for_page( - &self, - page: ConfigPage, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> OptionSnapshot { - match page { - ConfigPage::Location => location_snapshot(state, ctx), - ConfigPage::Harness => harness_snapshot(state, ctx), - ConfigPage::ApiKey => api_key_snapshot(state, ctx), - ConfigPage::Host => host_snapshot(state, ctx), - ConfigPage::Environment => environment_snapshot(state, ctx), - ConfigPage::Model => model_snapshot(state, ctx), - } - } - - fn apply_page_selection( - &self, - page: ConfigPage, - id: &str, - edit_state: &mut OrchestrationEditState, - fallback_base_model_id: Option, - ctx: &mut AppContext, - ) { - match page { - ConfigPage::Location => { - edit_state - .orchestration_config_state - .apply_execution_mode_change( - id == LOCATION_CLOUD_ID, - fallback_base_model_id, - ctx, - ); - } - ConfigPage::Harness => { - edit_state.apply_harness_change(id, fallback_base_model_id, ctx); - } - ConfigPage::ApiKey => { - let name = (!id.is_empty()).then(|| id.to_string()); - edit_state - .orchestration_config_state - .apply_auth_secret_change(name, ctx); - } - ConfigPage::Host => { - edit_state - .orchestration_config_state - .set_worker_host(id.to_string()); - persist_host_selection(id, ctx); - } - ConfigPage::Environment => { - edit_state - .orchestration_config_state - .set_environment_id(id.to_string()); - persist_environment_selection(id, ctx); - } - ConfigPage::Model => { - edit_state.orchestration_config_state.model_id = id.to_string(); - } - } - } - - fn accept_disabled_reason( - &self, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option { - accept_disabled_reason_with_auth(state, ctx) - } - - fn execute( - &self, - action_id: &AIAgentActionId, - request: RunAgentsRequest, - ctx: &mut AppContext, - ) { - self.action_model.update(ctx, |action_model, ctx| { - action_model.execute_run_agents(action_id, request, ctx); - }); - } -} - -impl ConfigPage { - /// The page's question, with agent/agents chosen from the request. - fn question(self, agent_count: usize) -> String { - let agent = if agent_count == 1 { "agent" } else { "agents" }; - match self { - Self::Location => format!("Where should the {agent} run?"), - Self::Harness => format!("Which harness should the {agent} use?"), - Self::ApiKey => format!("Which API key should the {agent} use?"), - Self::Host => format!("Which host should run the {agent}?"), - Self::Environment => format!("Which environment should the {agent} use?"), - Self::Model => format!("Which model should the {agent} use?"), - } - } - - /// Whether this page opts into the selector's pinned search editor. - fn is_searchable(self) -> bool { - matches!(self, Self::Model) - } -} - -/// Whether the card shows the acceptance summary or a configuration page. +/// The card's active interactive presentation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CardMode { Acceptance, @@ -923,230 +734,11 @@ impl TuiOrchestrationBlock { } } - /// Confirms the selector's selected option, then navigates in the arrow's - /// direction once the selector emits its confirmation event. + /// Confirms the selection, then applies the requested arrow navigation. fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { self.confirmation_navigation = Some(navigation); - self.selector.update(ctx, |selector, ctx| { - selector.confirm_selected(ctx); - }); - } - - // ── Rendering ─────────────────────────────────────────────────── - - /// Deterministic identities for the proposed agents, from the pinned - /// palette. - fn agent_identities(&self) -> Vec<&AgentIdentity> { - let names = self - .request_fields - .agent_run_configs - .iter() - .map(|config| config.name.as_str()); - assign_agent_identity_indices(names, self.identity_palette.len()) - .into_iter() - .filter_map(|index| self.identity_palette.get(index)) - .collect() - } - - /// The harness display label for the current selection. - fn harness_label(&self, ctx: &AppContext) -> String { - match Harness::parse_orchestration_harness( - &self - .orchestration_edit_state - .orchestration_config_state - .harness_type, - ) { - Some(harness) => HarnessAvailabilityModel::as_ref(ctx) - .display_name_for(harness) - .to_string(), - None => "Warp".to_string(), - } - } - - /// The display label for an id within a snapshot, falling back to the id. - fn label_for_id(snapshot: &OptionSnapshot, id: &str, fallback: &str) -> String { - snapshot - .rows - .iter() - .find(|row| row.id == id) - .map(|row| row.label.clone()) - .unwrap_or_else(|| { - if id.is_empty() { - fallback.to_string() - } else { - id.to_string() - } - }) - } - - /// The wrapping agent-identity line: every proposed agent's glyph and - /// name in its identity color, separated by muted bullets. - fn render_agent_identity_line(&self, builder: &TuiUiBuilder) -> Box { - let mut spans: Vec<(String, _)> = Vec::new(); - for (index, (config, identity)) in self - .request_fields - .agent_run_configs - .iter() - .zip(self.agent_identities()) - .enumerate() - { - if index > 0 { - spans.push((" • ".to_string(), builder.muted_text_style())); - } - spans.push((format!("{} ", identity.glyph), identity.style)); - spans.push(( - config.name.clone(), - identity.style.add_modifier(Modifier::BOLD), - )); - } - TuiText::from_spans(spans).finish() - } - - /// The wrapping inline `Label: value` metadata line with muted bullet - /// separators and bold values. - fn render_metadata_line( - &self, - app: &AppContext, - builder: &TuiUiBuilder, - ) -> Box { - let state = &self.orchestration_edit_state.orchestration_config_state; - let is_remote = state.execution_mode.is_remote(); - let mut entries: Vec<(&str, String)> = vec![( - "Location", - if is_remote { "Cloud" } else { "Local" }.to_string(), - )]; - entries.push(("Harness", self.harness_label(app))); - if is_remote { - if should_show_auth_secret_picker(state) { - let api_key = match &state.auth_secret_selection { - AuthSecretSelection::Named(name) => name.clone(), - AuthSecretSelection::Inherit => "Skip (advanced)".to_string(), - AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { - "Select an API key".to_string() - } - }; - entries.push(("API key", api_key)); - } - let host = match &state.execution_mode { - RunAgentsExecutionMode::Remote { worker_host, .. } - if !worker_host.trim().is_empty() => - { - worker_host.clone() - } - RunAgentsExecutionMode::Remote { .. } | RunAgentsExecutionMode::Local => { - ORCHESTRATION_WARP_WORKER_HOST.to_string() - } - }; - entries.push(("Host", host)); - let environment_id = match &state.execution_mode { - RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), - RunAgentsExecutionMode::Local => String::new(), - }; - entries.push(( - "Environment", - Self::label_for_id( - &environment_snapshot(state, app), - &environment_id, - "Empty environment", - ), - )); - } - entries.push(( - "Model", - Self::label_for_id( - &model_snapshot(state, app), - &state.model_id, - "Default model", - ), - )); - - let mut spans: Vec<(String, _)> = Vec::new(); - for (index, (label, value)) in entries.into_iter().enumerate() { - if index > 0 { - spans.push((" • ".to_string(), builder.muted_text_style())); - } - spans.push((format!("{label}: "), builder.primary_text_style())); - spans.push((value, builder.orchestration_selected_value_style())); - } - TuiText::from_spans(spans).finish() - } - - /// The acceptance card body: the agent list and the inline run-wide - /// configuration values. The request summary is not repeated here; it - /// streams into the transcript above the card. - fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { - let state = &self.orchestration_edit_state.orchestration_config_state; - let mut column = TuiFlex::column(); - - column.add_child( - TuiText::new(format!( - "Agents ({}):", - self.request_fields.agent_run_configs.len() - )) - .with_style(builder.primary_text_style()) - .truncate() - .finish(), - ); - column.add_child(self.render_agent_identity_line(builder)); - column.add_child(TuiText::new(" ").finish()); - column.add_child(self.render_metadata_line(app, builder)); - - // Inline validation or the soft empty-env nudge. - if let Some(error) = &self.accept_error { - column.add_child( - TuiText::new(error.clone()) - .with_style(builder.error_text_style()) - .finish(), - ); - } else if let Some(message) = empty_env_recommendation_message(&state.execution_mode, app) { - column.add_child( - TuiText::new(message) - .with_style(builder.attention_glyph_style()) - .truncate() - .finish(), - ); - } - - column.finish() - } - /// The persistent title row shared by acceptance and configuration. - fn render_title(&self, builder: &TuiUiBuilder) -> Box { - TuiText::from_spans([ - ("■ ".to_string(), builder.attention_glyph_style()), - ( - ORCHESTRATION_BLOCK_TITLE.to_string(), - builder.primary_text_style(), - ), - ]) - .finish() - } - - /// The configuring body: the active selector page. - fn render_configuring(&self) -> Box { - TuiChildView::new(&self.selector).finish() - } - - /// Key hints shown below (not inside) the tinted card. - fn render_footer(&self, builder: &TuiUiBuilder) -> Box { - let spans = match self.mode { - CardMode::Acceptance => vec![ - ("Enter ".to_string(), builder.primary_text_style()), - ("to accept ".to_string(), builder.muted_text_style()), - ("Ctrl + E".to_string(), builder.primary_text_style()), - (" to edit ".to_string(), builder.muted_text_style()), - ("Ctrl + C".to_string(), builder.primary_text_style()), - (" to reject".to_string(), builder.muted_text_style()), - ], - CardMode::Configuring { .. } => vec![ - ("Enter ".to_string(), builder.primary_text_style()), - ("to accept ".to_string(), builder.muted_text_style()), - ("Tab or ← →".to_string(), builder.primary_text_style()), - (" to navigate ".to_string(), builder.muted_text_style()), - ("Esc ".to_string(), builder.primary_text_style()), - ("to go back".to_string(), builder.muted_text_style()), - ], - }; - TuiText::from_spans(spans).finish() + self.selector + .update(ctx, |selector, ctx| selector.confirm_selected(ctx)); } } @@ -1174,51 +766,7 @@ impl TuiView for TuiOrchestrationBlock { } fn render(&self, app: &AppContext) -> Box { - let status = self.controller.action_status(&self.action_id, app); - - // Terminal, spawning, restored, and still-streaming states reuse the - // shared fallback tool-call row and its `tool_call_labels` copy. - let interactive = !self.is_restored - && self.spawning.is_none() - && matches!(status, Some(AIActionStatus::Blocked)); - if !interactive { - return render_fallback_tool_call_section( - &self.action, - status.as_ref(), - false, - None, - app, - ); - } - - let builder = TuiUiBuilder::from_app(app); - // The header row carries a stronger tint than the body, per the - // design's stacked header overlays. - let header = TuiContainer::new(self.render_title(&builder)) - .with_background(builder.orchestration_header_background()) - .with_padding_x(1) - .finish(); - let body = match self.mode { - CardMode::Acceptance => self.render_acceptance(app, &builder), - CardMode::Configuring { .. } => self.render_configuring(), - }; - let body = TuiContainer::new(body) - .with_background(builder.orchestration_surface_background()) - .with_padding_x(3) - .with_padding_y(1) - .finish(); - // Stretch so the tinted header and body fill the full row width - // rather than sizing to their text content. - TuiFlex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .child(header) - .child(body) - .child( - TuiContainer::new(self.render_footer(&builder)) - .with_padding_top(1) - .finish(), - ) - .finish() + render::render(self, app) } } diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs new file mode 100644 index 00000000000..f499fc15b66 --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -0,0 +1,194 @@ +//! Configuration pages and shared-model adapters for the orchestration card. + +use warp::tui_export::{ + accept_disabled_reason_with_auth, api_key_snapshot, environment_snapshot, harness_snapshot, + host_snapshot, location_snapshot, model_snapshot, persist_environment_selection, + persist_host_selection, AIActionStatus, AIAgentActionId, BlocklistAIActionModel, + OptionSnapshot, OrchestrationConfigState, OrchestrationEditState, RunAgentsRequest, +}; +use warpui_core::{AppContext, ModelHandle}; + +/// Row id emitted by `location_snapshot` for remote execution. +const LOCATION_CLOUD_ID: &str = "cloud"; + +/// Builds a dispatched request from immutable card fields and edited run-wide state. +pub(super) fn build_request( + fields: &RunAgentsRequest, + state: &OrchestrationConfigState, +) -> RunAgentsRequest { + RunAgentsRequest { + summary: fields.summary.clone(), + base_prompt: fields.base_prompt.clone(), + skills: fields.skills.clone(), + model_id: state.model_id.clone(), + harness_type: state.harness_type.clone(), + execution_mode: state.execution_mode.clone(), + agent_run_configs: fields.agent_run_configs.clone(), + plan_id: fields.plan_id.clone(), + harness_auth_secret_name: state.auth_secret_name().map(str::to_string), + } +} + +/// One single-field configuration page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ConfigPage { + Location, + Harness, + ApiKey, + Host, + Environment, + Model, +} + +impl ConfigPage { + /// Returns the page question with the request's agent count pluralized. + pub(super) fn question(self, agent_count: usize) -> String { + let agent = if agent_count == 1 { "agent" } else { "agents" }; + match self { + Self::Location => format!("Where should the {agent} run?"), + Self::Harness => format!("Which harness should the {agent} use?"), + Self::ApiKey => format!("Which API key should the {agent} use?"), + Self::Host => format!("Which host should run the {agent}?"), + Self::Environment => format!("Which environment should the {agent} use?"), + Self::Model => format!("Which model should the {agent} use?"), + } + } + + /// Whether this page opts into the selector's pinned search editor. + pub(super) fn is_searchable(self) -> bool { + matches!(self, Self::Model) + } +} + +/// External orchestration behavior used by the block. +pub(super) trait OrchestrationBlockController { + /// Returns the current lifecycle status for the action. + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option; + + /// Builds the current option snapshot for a configuration page. + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot; + + /// Commits one page selection into the edit state. + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ); + + /// Returns the reason acceptance is currently unavailable. + fn accept_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option; + + /// Dispatches the edited orchestration request. + fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); +} + +/// Production controller backed by the shared orchestration models. +pub(super) struct ModelOrchestrationBlockController { + pub(super) action_model: ModelHandle, +} + +impl OrchestrationBlockController for ModelOrchestrationBlockController { + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option { + self.action_model.as_ref(ctx).get_action_status(action_id) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot { + match page { + ConfigPage::Location => location_snapshot(state, ctx), + ConfigPage::Harness => harness_snapshot(state, ctx), + ConfigPage::ApiKey => api_key_snapshot(state, ctx), + ConfigPage::Host => host_snapshot(state, ctx), + ConfigPage::Environment => environment_snapshot(state, ctx), + ConfigPage::Model => model_snapshot(state, ctx), + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ) { + match page { + ConfigPage::Location => { + edit_state + .orchestration_config_state + .apply_execution_mode_change( + id == LOCATION_CLOUD_ID, + fallback_base_model_id, + ctx, + ); + } + ConfigPage::Harness => { + edit_state.apply_harness_change(id, fallback_base_model_id, ctx); + } + ConfigPage::ApiKey => { + let name = (!id.is_empty()).then(|| id.to_string()); + edit_state + .orchestration_config_state + .apply_auth_secret_change(name, ctx); + } + ConfigPage::Host => { + edit_state + .orchestration_config_state + .set_worker_host(id.to_string()); + persist_host_selection(id, ctx); + } + ConfigPage::Environment => { + edit_state + .orchestration_config_state + .set_environment_id(id.to_string()); + persist_environment_selection(id, ctx); + } + ConfigPage::Model => { + edit_state.orchestration_config_state.model_id = id.to_string(); + } + } + } + + fn accept_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option { + accept_disabled_reason_with_auth(state, ctx) + } + + fn execute( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + ctx: &mut AppContext, + ) { + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(action_id, request, ctx); + }); + } +} diff --git a/crates/warp_tui/src/orchestration_block/render.rs b/crates/warp_tui/src/orchestration_block/render.rs new file mode 100644 index 00000000000..d15839a727d --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/render.rs @@ -0,0 +1,267 @@ +//! Element construction for the orchestration card. + +use warp::tui_export::{ + empty_env_recommendation_message, environment_snapshot, model_snapshot, + should_show_auth_secret_picker, AIActionStatus, AuthSecretSelection, Harness, + HarnessAvailabilityModel, OptionSnapshot, RunAgentsExecutionMode, + ORCHESTRATION_WARP_WORKER_HOST, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, +}; +use warpui_core::elements::CrossAxisAlignment; +use warpui_core::AppContext; + +use super::{CardMode, TuiOrchestrationBlock, ORCHESTRATION_BLOCK_TITLE}; +use crate::agent_block_sections::render_fallback_tool_call_section; +use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::tui_builder::TuiUiBuilder; + +impl TuiOrchestrationBlock { + /// Returns deterministic identities for the proposed agents. + fn agent_identities(&self) -> Vec<&AgentIdentity> { + let names = self + .request_fields + .agent_run_configs + .iter() + .map(|config| config.name.as_str()); + assign_agent_identity_indices(names, self.identity_palette.len()) + .into_iter() + .filter_map(|index| self.identity_palette.get(index)) + .collect() + } + + /// Returns the harness display label for the current selection. + fn harness_label(&self, ctx: &AppContext) -> String { + match Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) { + Some(harness) => HarnessAvailabilityModel::as_ref(ctx) + .display_name_for(harness) + .to_string(), + None => "Warp".to_string(), + } + } + + /// Resolves an id to its display label, falling back to the id. + fn label_for_id(snapshot: &OptionSnapshot, id: &str, fallback: &str) -> String { + snapshot + .rows + .iter() + .find(|row| row.id == id) + .map(|row| row.label.clone()) + .unwrap_or_else(|| { + if id.is_empty() { + fallback.to_string() + } else { + id.to_string() + } + }) + } + + /// Renders every proposed agent with its stable identity. + fn render_agent_identity_line(&self, builder: &TuiUiBuilder) -> Box { + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (config, identity)) in self + .request_fields + .agent_run_configs + .iter() + .zip(self.agent_identities()) + .enumerate() + { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{} ", identity.glyph), identity.style)); + spans.push(( + config.name.clone(), + identity.style.add_modifier(Modifier::BOLD), + )); + } + TuiText::from_spans(spans).finish() + } + + /// Renders the inline run-wide configuration values. + fn render_metadata_line( + &self, + app: &AppContext, + builder: &TuiUiBuilder, + ) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let is_remote = state.execution_mode.is_remote(); + let mut entries: Vec<(&str, String)> = vec![( + "Location", + if is_remote { "Cloud" } else { "Local" }.to_string(), + )]; + entries.push(("Harness", self.harness_label(app))); + if is_remote { + if should_show_auth_secret_picker(state) { + let api_key = match &state.auth_secret_selection { + AuthSecretSelection::Named(name) => name.clone(), + AuthSecretSelection::Inherit => "Skip (advanced)".to_string(), + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { + "Select an API key".to_string() + } + }; + entries.push(("API key", api_key)); + } + let host = match &state.execution_mode { + RunAgentsExecutionMode::Remote { worker_host, .. } + if !worker_host.trim().is_empty() => + { + worker_host.clone() + } + RunAgentsExecutionMode::Remote { .. } | RunAgentsExecutionMode::Local => { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } + }; + entries.push(("Host", host)); + let environment_id = match &state.execution_mode { + RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + entries.push(( + "Environment", + Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ), + )); + } + entries.push(( + "Model", + Self::label_for_id( + &model_snapshot(state, app), + &state.model_id, + "Default model", + ), + )); + + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (label, value)) in entries.into_iter().enumerate() { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{label}: "), builder.primary_text_style())); + spans.push((value, builder.orchestration_selected_value_style())); + } + TuiText::from_spans(spans).finish() + } + + /// Renders the acceptance card body. + fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let mut column = TuiFlex::column(); + + column.add_child( + TuiText::new(format!( + "Agents ({}):", + self.request_fields.agent_run_configs.len() + )) + .with_style(builder.primary_text_style()) + .truncate() + .finish(), + ); + column.add_child(self.render_agent_identity_line(builder)); + column.add_child(TuiText::new(" ").finish()); + column.add_child(self.render_metadata_line(app, builder)); + + if let Some(error) = &self.accept_error { + column.add_child( + TuiText::new(error.clone()) + .with_style(builder.error_text_style()) + .finish(), + ); + } else if let Some(message) = empty_env_recommendation_message(&state.execution_mode, app) { + column.add_child( + TuiText::new(message) + .with_style(builder.attention_glyph_style()) + .truncate() + .finish(), + ); + } + + column.finish() + } + + /// Renders the title shared by acceptance and configuration. + fn render_title(&self, builder: &TuiUiBuilder) -> Box { + TuiText::from_spans([ + ("■ ".to_string(), builder.attention_glyph_style()), + ( + ORCHESTRATION_BLOCK_TITLE.to_string(), + builder.primary_text_style(), + ), + ]) + .finish() + } + + /// Renders the active selector page. + fn render_configuring(&self) -> Box { + TuiChildView::new(&self.selector).finish() + } + + /// Renders the key hints below the tinted card. + fn render_footer(&self, builder: &TuiUiBuilder) -> Box { + let spans = match self.mode { + CardMode::Acceptance => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Ctrl + E".to_string(), builder.primary_text_style()), + (" to edit ".to_string(), builder.muted_text_style()), + ("Ctrl + C".to_string(), builder.primary_text_style()), + (" to reject".to_string(), builder.muted_text_style()), + ], + CardMode::Configuring { .. } => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Tab or ← →".to_string(), builder.primary_text_style()), + (" to navigate ".to_string(), builder.muted_text_style()), + ("Esc ".to_string(), builder.primary_text_style()), + ("to go back".to_string(), builder.muted_text_style()), + ], + }; + TuiText::from_spans(spans).finish() + } +} + +/// Renders the orchestration block in interactive or fallback form. +pub(super) fn render(block: &TuiOrchestrationBlock, app: &AppContext) -> Box { + let status = block.controller.action_status(&block.action_id, app); + let interactive = !block.is_restored + && block.spawning.is_none() + && matches!(status, Some(AIActionStatus::Blocked)); + if !interactive { + return render_fallback_tool_call_section(&block.action, status.as_ref(), false, None, app); + } + + let builder = TuiUiBuilder::from_app(app); + let header = TuiContainer::new(block.render_title(&builder)) + .with_background(builder.orchestration_header_background()) + .with_padding_x(1) + .finish(); + let body = match block.mode { + CardMode::Acceptance => block.render_acceptance(app, &builder), + CardMode::Configuring { .. } => block.render_configuring(), + }; + let body = TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(3) + .with_padding_y(1) + .finish(); + TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .child(header) + .child(body) + .child( + TuiContainer::new(block.render_footer(&builder)) + .with_padding_top(1) + .finish(), + ) + .finish() +} From 3854fd76de30ab19dcde16d360eaee075695c26e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 17:13:51 -0400 Subject: [PATCH 17/40] fix focus issue Co-Authored-By: Oz --- crates/warp_tui/src/orchestration_block.rs | 33 +++++++++---------- .../warp_tui/src/orchestration_block_tests.rs | 25 ++++++++++++++ pr_diff.txt | 0 specs/CODE-1822/TECH.md | 6 ++-- 4 files changed, 44 insertions(+), 20 deletions(-) delete mode 100644 pr_diff.txt diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 3ad0baeacc3..79ac34ce7b5 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -32,15 +32,15 @@ use warpui_core::{ mod configuration; mod render; +use configuration::{ + build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, +}; + use crate::agent_identity::AgentIdentity; use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; -use configuration::{ - build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, -}; - const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; /// Keymap-context flag set while the acceptance card is active. @@ -483,6 +483,14 @@ impl TuiOrchestrationBlock { ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } + /// Returns from configuration to the interactive acceptance card. + fn return_to_acceptance(&mut self, ctx: &mut ViewContext) { + self.mode = CardMode::Acceptance; + self.confirmation_navigation = None; + ctx.focus_self(); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } /// Refreshes the active page's snapshot in place after a catalog or /// state change, updating the header so the dynamic page count stays @@ -496,8 +504,7 @@ impl TuiOrchestrationBlock { if !sequence.contains(&page) { // The active page no longer applies (e.g. auth page removed by a // catalog change); fall back to the acceptance card. - self.mode = CardMode::Acceptance; - ctx.notify(); + self.return_to_acceptance(ctx); return; } let snapshot = self.snapshot_for_page(page, ctx); @@ -559,11 +566,7 @@ impl TuiOrchestrationBlock { .copied(); match next { Some(next) => self.open_page(next, ctx), - None => { - self.mode = CardMode::Acceptance; - ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); - ctx.notify(); - } + None => self.return_to_acceptance(ctx), } } @@ -578,9 +581,7 @@ impl TuiOrchestrationBlock { let sequence = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { - self.mode = CardMode::Acceptance; - ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); - ctx.notify(); + self.return_to_acceptance(ctx); return; }; let target = match navigation { @@ -728,9 +729,7 @@ impl TuiOrchestrationBlock { return; } if matches!(self.mode, CardMode::Configuring { .. }) { - self.mode = CardMode::Acceptance; - ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); - ctx.notify(); + self.return_to_acceptance(ctx); } } diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index e3ad792dbca..40fc912ebbf 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -390,6 +390,31 @@ fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { }); } +#[test] +fn confirming_a_search_result_returns_focus_to_the_acceptance_card() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + block.update(&mut app, |block, ctx| { + block.open_page(ConfigPage::Model, ctx); + }); + let window_id = app.read(|ctx| block.window_id(ctx)); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::FocusSearchAndInsert('a'), ctx); + }); + assert_ne!(app.focused_view_id(window_id), Some(block.id())); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::SelectItem(0), ctx); + }); + + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Acceptance + ); + assert_eq!(app.focused_view_id(window_id), Some(block.id())); + }); +} #[test] fn accepting_dispatches_once_and_releases_focus() { App::test((), |mut app| async move { diff --git a/pr_diff.txt b/pr_diff.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index a9df2cd393b..0179743c4b1 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -37,7 +37,7 @@ View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_c - Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). - Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): acceptance owns `enter`/`numpadenter` → Accept and `ctrl-e` → Configure; configuration owns `esc` → Back, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation; `ctrl-c` → Reject applies in either mode. The embedded selector owns configuration-page Enter/Numpad Enter confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. -- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. +- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. Every interactive Configuration → Acceptance transition reclaims focus on `TuiOrchestrationBlock` before the selector stops rendering, so a hidden search/custom-text editor cannot keep its more-specific editor bindings and shadow acceptance bindings such as `Ctrl+E`. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected model so numeric shortcuts remain immediate. Search is the final item in the @@ -66,12 +66,12 @@ Input visibility is a pure function of the front-of-queue blocker rather than a ## Testing and validation Focused unit coverage: -- `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. The two interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. +- `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. Focus regression coverage drives the model-page search editor, confirms a result as a row click does, then verifies that Acceptance owns focus so `Ctrl+E` is no longer shadowed by the hidden editor. The interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. - `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. - `agent_identity_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. - `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. -Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. +Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, model search → click result → `Ctrl+E` reopening configuration from Acceptance, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. From 81e6b77f8be616652e887e732a72969241d8cf0c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 18:12:06 -0400 Subject: [PATCH 18/40] comments --- crates/warp_tui/src/agent_block.rs | 4 +- crates/warp_tui/src/orchestration_block.rs | 119 +++++++++--------- .../src/orchestration_block/configuration.rs | 31 +++-- .../warp_tui/src/orchestration_block_tests.rs | 76 +++++++++-- specs/CODE-1822/PRODUCT.md | 4 +- 5 files changed, 139 insertions(+), 95 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 8fd7e4a4bde..434753fbf59 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -471,7 +471,7 @@ impl TuiAIBlock { /// The front-of-queue blocking interaction owned by this block, if any: /// the conversation's front pending action when it is `Blocked`, rendered /// by one of this block's child views, and that view reports - /// `wants_focus`. Deriving from the action queue (not transcript order) + /// `is_active_blocker`. Deriving from the action queue (not transcript order) /// keeps semantics identical to the GUI's `focus_subview_if_necessary`. pub(super) fn active_blocking_child(&self, ctx: &AppContext) -> Option { let action_model = self.action_model.as_ref(ctx); @@ -489,7 +489,7 @@ impl TuiAIBlock { match self.action_views.get(&action_id)? { TuiToolCallView::OrchestrationBlock(view) => view .as_ref(ctx) - .wants_focus(ctx) + .is_active_blocker(ctx) .then(|| TuiBlockingChild { view: view.clone() }), // These tool views render inline and never replace the input. TuiToolCallView::FileEdits(_) | TuiToolCallView::ShellCommand(_) => None, diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 79ac34ce7b5..2df30ee6b76 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -47,6 +47,7 @@ const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiOrchestrationBlockAcceptance"; /// Keymap-context flag set while a configuration page is active. const CONFIGURING_CONTEXT_FLAG: &str = "TuiOrchestrationBlockConfiguring"; + /// Registers fixed card keybindings scoped to the active card mode. pub(crate) fn init(app: &mut AppContext) { let acceptance = || id!(TuiOrchestrationBlock::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); @@ -97,9 +98,11 @@ enum CardMode { Acceptance, Configuring { page: ConfigPage }, } -/// Page direction requested by an arrow-key confirmation. + +/// Direction to navigate after the selector confirms the current page. +/// Arrow actions retain this until the selector emits its confirmation event. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PageNavigation { +enum PageConfirmationNavigation { Previous, Next, } @@ -128,31 +131,35 @@ pub(crate) enum TuiOrchestrationBlockAction { /// The TUI orchestration confirmation block. See the module docs. pub(crate) struct TuiOrchestrationBlock { + // Request and action identity. action_id: AIAgentActionId, /// The latest streamed tool call, kept in sync by /// [`Self::update_request`]; terminal/streaming states render from it /// through the shared fallback tool-call presentation. action: AIAgentAction, - orchestration_edit_state: OrchestrationEditState, /// Card fields carried through editing into the dispatched request. request_fields: RunAgentsRequest, - mode: CardMode, - selector: ViewHandle, - /// Arrow direction to apply after the selector confirms its current - /// value. Enter leaves this unset and follows the normal forward flow. - confirmation_navigation: Option, - controller: Rc, /// Approved/disapproved plan config used to resolve inherited fields. active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, /// The conversation's base model, used as the Oz model fallback. fallback_base_model_id: Option, /// Whether the block was restored from history (non-interactive). is_restored: bool, + + // Interactive card state. + orchestration_edit_state: OrchestrationEditState, + mode: CardMode, + selector: ViewHandle, + /// Arrow direction awaiting the selector's confirmation event. + pending_page_navigation: Option, + /// Validation reason shown inline after a blocked Accept. + accept_error: Option, + + // Execution state. + controller: Rc, spawning: Option, /// Set once the request is accepted or rejected. decided: bool, - /// Validation reason shown inline after a blocked Accept. - accept_error: Option, /// Identity palette pinned at construction so identities stay stable /// across re-renders, edits, and theme switches. identity_palette: Vec, @@ -287,48 +294,28 @@ impl TuiOrchestrationBlock { ctx.subscribe_to_view(&selector, |me, _, event, ctx| { me.handle_selector_event(event, ctx); }); + let orchestration_edit_state = OrchestrationEditState::new( + Self::config_state_from_request(request, active_config.as_ref()), + ); Self { action_id: action.id.clone(), action, - orchestration_edit_state: OrchestrationEditState::new(Self::config_state_from_request( - request, - active_config.as_ref(), - )), request_fields: request.clone(), - mode: CardMode::Acceptance, - selector, - confirmation_navigation: None, - controller, active_config, fallback_base_model_id, is_restored, + orchestration_edit_state, + mode: CardMode::Acceptance, + selector, + pending_page_navigation: None, + accept_error: None, + controller, spawning: None, decided: false, - accept_error: None, identity_palette, } } - /// Constructs an interactive block around a test controller. - #[cfg(test)] - fn new_for_test( - action: AIAgentAction, - request: &RunAgentsRequest, - controller: Rc, - ctx: &mut ViewContext, - ) -> Self { - Self::from_parts( - action, - request, - None, - controller, - Some("auto".to_string()), - false, - Vec::new(), - ctx, - ) - } - /// Seeds the run-wide edit state from the streamed request, resolving /// empty fields from an approved plan config (state parity with the /// GUI card's `RunAgentsEditState::from_request` + `resolve_from_config`). @@ -425,7 +412,7 @@ impl TuiOrchestrationBlock { /// Whether this card is the active blocking interaction: interactive in /// Acceptance/Configuring while the action awaits confirmation, and /// false once accepted, rejected, spawning, finished, or restored. - pub(crate) fn wants_focus(&self, ctx: &AppContext) -> bool { + pub(super) fn is_active_blocker(&self, ctx: &AppContext) -> bool { if self.decided || self.spawning.is_some() || self.is_restored { return false; } @@ -483,10 +470,11 @@ impl TuiOrchestrationBlock { ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } + /// Returns from configuration to the interactive acceptance card. fn return_to_acceptance(&mut self, ctx: &mut ViewContext) { self.mode = CardMode::Acceptance; - self.confirmation_navigation = None; + self.pending_page_navigation = None; ctx.focus_self(); ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); @@ -548,7 +536,7 @@ impl TuiOrchestrationBlock { /// Completes a page confirmation using an arrow's requested direction, /// or the normal Enter behavior when no arrow direction is pending. fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { - match self.confirmation_navigation.take() { + match self.pending_page_navigation.take() { Some(navigation) => self.navigate_after_confirmation(page, navigation, ctx), None => self.advance_after(page, ctx), } @@ -575,7 +563,7 @@ impl TuiOrchestrationBlock { fn navigate_after_confirmation( &mut self, page: ConfigPage, - navigation: PageNavigation, + navigation: PageConfirmationNavigation, ctx: &mut ViewContext, ) { let sequence = @@ -585,11 +573,11 @@ impl TuiOrchestrationBlock { return; }; let target = match navigation { - PageNavigation::Previous => index + PageConfirmationNavigation::Previous => index .checked_sub(1) .and_then(|index| sequence.get(index)) .copied(), - PageNavigation::Next => sequence.get(index + 1).copied(), + PageConfirmationNavigation::Next => sequence.get(index + 1).copied(), } .unwrap_or(page); self.open_page(target, ctx); @@ -639,12 +627,12 @@ impl TuiOrchestrationBlock { } } TuiOptionSelectorEvent::RetryRequested => { - self.confirmation_navigation = None; + self.pending_page_navigation = None; self.ensure_auth_secrets_fetched(ctx); self.refresh_active_page(ctx); } TuiOptionSelectorEvent::Dismissed => { - self.confirmation_navigation = None; + self.pending_page_navigation = None; self.handle_back(ctx); } TuiOptionSelectorEvent::LayoutInvalidated => { @@ -670,10 +658,14 @@ impl TuiOrchestrationBlock { /// accept renders the reason inline and stays active, a valid one /// dispatches the edited request through `execute_run_agents`. fn handle_accept(&mut self, ctx: &mut ViewContext) { - if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { + if self.decided || self.spawning.is_some() || !self.is_active_blocker(ctx) { return; } - if let Some(reason) = self.controller.accept_disabled_reason( + let request = self.to_request(); + let action_id = self.action_id.clone(); + if let Err(reason) = self.controller.accept( + &action_id, + request, &self.orchestration_edit_state.orchestration_config_state, ctx, ) { @@ -684,9 +676,6 @@ impl TuiOrchestrationBlock { self.decided = true; self.accept_error = None; self.mode = CardMode::Acceptance; - let request = self.to_request(); - let action_id = self.action_id.clone(); - self.controller.execute(&action_id, request, ctx); ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -694,7 +683,7 @@ impl TuiOrchestrationBlock { /// Reject: resolves the request as rejected exactly once, /// from the acceptance card or any configuration page. fn handle_reject(&mut self, ctx: &mut ViewContext) { - if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { + if self.decided || self.spawning.is_some() || !self.is_active_blocker(ctx) { return; } self.decided = true; @@ -706,7 +695,7 @@ impl TuiOrchestrationBlock { /// Opens configuration on the first page. fn handle_configure(&mut self, ctx: &mut ViewContext) { - if !self.wants_focus(ctx) { + if !self.is_active_blocker(ctx) { return; } let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) @@ -721,7 +710,7 @@ impl TuiOrchestrationBlock { /// selections; the current page's unconfirmed selection is discarded. /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { - self.confirmation_navigation = None; + self.pending_page_navigation = None; let consumed = self .selector .update(ctx, |selector, ctx| selector.handle_back(ctx)); @@ -734,10 +723,18 @@ impl TuiOrchestrationBlock { } /// Confirms the selection, then applies the requested arrow navigation. - fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { - self.confirmation_navigation = Some(navigation); - self.selector + fn handle_arrow_navigation( + &mut self, + navigation: PageConfirmationNavigation, + ctx: &mut ViewContext, + ) { + self.pending_page_navigation = Some(navigation); + let confirmation_started = self + .selector .update(ctx, |selector, ctx| selector.confirm_selected(ctx)); + if !confirmation_started { + self.pending_page_navigation = None; + } } } @@ -777,10 +774,10 @@ impl TypedActionView for TuiOrchestrationBlock { TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), TuiOrchestrationBlockAction::CommitAndPreviousPage => { - self.handle_arrow_navigation(PageNavigation::Previous, ctx) + self.handle_arrow_navigation(PageConfirmationNavigation::Previous, ctx) } TuiOrchestrationBlockAction::CommitAndNextPage => { - self.handle_arrow_navigation(PageNavigation::Next, ctx) + self.handle_arrow_navigation(PageConfirmationNavigation::Next, ctx) } TuiOrchestrationBlockAction::NextPage => self.navigate_page(true, ctx), TuiOrchestrationBlockAction::Back => self.handle_back(ctx), diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index f499fc15b66..3c58775e343 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -87,15 +87,15 @@ pub(super) trait OrchestrationBlockController { ctx: &mut AppContext, ); - /// Returns the reason acceptance is currently unavailable. - fn accept_disabled_reason( + /// Validates and dispatches an accepted request, returning the blocking + /// reason when the edited configuration cannot launch. + fn accept( &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option; - - /// Dispatches the edited orchestration request. - fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); + ctx: &mut AppContext, + ) -> Result<(), String>; } /// Production controller backed by the shared orchestration models. @@ -173,22 +173,19 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { } } - fn accept_disabled_reason( - &self, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option { - accept_disabled_reason_with_auth(state, ctx) - } - - fn execute( + fn accept( &self, action_id: &AIAgentActionId, request: RunAgentsRequest, + state: &OrchestrationConfigState, ctx: &mut AppContext, - ) { + ) -> Result<(), String> { + if let Some(reason) = accept_disabled_reason_with_auth(state, ctx) { + return Err(reason); + } self.action_model.update(ctx, |action_model, ctx| { action_model.execute_run_agents(action_id, request, ctx); }); + Ok(()) } } diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 40fc912ebbf..e6abee1fb09 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -267,21 +267,15 @@ impl OrchestrationBlockController for TestController { } } - fn accept_disabled_reason( - &self, - _state: &OrchestrationConfigState, - _ctx: &warpui::AppContext, - ) -> Option { - None - } - - fn execute( + fn accept( &self, _action_id: &AIAgentActionId, request: RunAgentsRequest, + _state: &OrchestrationConfigState, _ctx: &mut warpui::AppContext, - ) { + ) -> Result<(), String> { self.executed_requests.borrow_mut().push(request); + Ok(()) } } @@ -320,7 +314,16 @@ fn test_block( |_| TestHostView, ); ctx.add_typed_action_tui_view(window_id, move |ctx| { - TuiOrchestrationBlock::new_for_test(action, &request, controller_for_view, ctx) + TuiOrchestrationBlock::from_parts( + action, + &request, + None, + controller_for_view, + Some("auto".to_string()), + false, + Vec::new(), + ctx, + ) }) }); (view, controller) @@ -390,6 +393,53 @@ fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { }); } +#[test] +fn failed_arrow_confirmation_does_not_change_later_enter_navigation() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + act(&mut app, &block, TuiOrchestrationBlockAction::Configure); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + + let mut disabled_local = row("local", "Local"); + disabled_local.disabled_reason = Some("Unavailable".to_string()); + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot( + OptionSnapshot { + rows: vec![disabled_local], + selected_id: Some("local".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndPreviousPage, + ); + + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot( + OptionSnapshot { + rows: vec![row("local", "Local")], + selected_id: Some("local".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + selector.handle_action(&TuiOptionSelectorAction::ConfirmSelected, ctx); + }); + + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Configuring { + page: ConfigPage::Model + } + ); + }); +} #[test] fn confirming_a_search_result_returns_focus_to_the_acceptance_card() { App::test((), |mut app| async move { @@ -420,13 +470,13 @@ fn accepting_dispatches_once_and_releases_focus() { App::test((), |mut app| async move { let request = request("oz", RunAgentsExecutionMode::Local); let (block, controller) = test_block(&mut app, &request); - assert!(app.read(|ctx| block.as_ref(ctx).wants_focus(ctx))); + assert!(app.read(|ctx| block.as_ref(ctx).is_active_blocker(ctx))); act(&mut app, &block, TuiOrchestrationBlockAction::Accept); act(&mut app, &block, TuiOrchestrationBlockAction::Accept); act(&mut app, &block, TuiOrchestrationBlockAction::Reject); assert_eq!(controller.executed_requests.borrow().as_slice(), &[request]); - assert!(app.read(|ctx| !block.as_ref(ctx).wants_focus(ctx))); + assert!(app.read(|ctx| !block.as_ref(ctx).is_active_blocker(ctx))); }); } diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 93a86c7b37f..b89dbdaf044 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -75,10 +75,10 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ### General option selection 29. Every configuration page uses the same option-selection behavior and presentation. -30. Up and Down move the highlight through options without confirming a value. Four rows are visible at once; navigation scrolls when the highlight moves beyond that viewport and shows `↑` / `↓` overflow markers. +30. Up and Down move the highlight through options without confirming a value. Six rows are visible at once; navigation scrolls when the highlight moves beyond that viewport and shows `↑` / `↓` overflow markers. 31. Enter confirms the highlighted option. 32. Number keys 1–9 confirm the corresponding visible option, when present, and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. -33. Options beyond the four-row viewport remain reachable with scrolling, Up and Down, and Enter. +33. Options beyond the six-row viewport remain reachable with scrolling, Up and Down, and Enter. 34. Clicking an enabled option confirms it and advances exactly like Enter. 35. Mouse-wheel and trackpad input scroll lists that exceed the available height. 36. Rows render with `(n)` number prefixes. The selected row is bold magenta without a separate marker or background. Disabled rows can be highlighted for context but cannot be confirmed; they show a concise reason when available. From 0aace5c78a93d51d76429c914227abcb87a9014c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 23:10:54 -0400 Subject: [PATCH 19/40] address comments --- crates/warp_tui/src/agent_block.rs | 24 ++-- crates/warp_tui/src/lib.rs | 2 +- ...=> orchestrated_agent_identity_styling.rs} | 6 +- ...hestrated_agent_identity_styling_tests.rs} | 0 crates/warp_tui/src/orchestration_block.rs | 105 +++++++----------- .../src/orchestration_block/render.rs | 2 +- .../warp_tui/src/orchestration_block_tests.rs | 4 +- crates/warp_tui/src/terminal_session_view.rs | 8 +- crates/warp_tui/src/transcript_view.rs | 8 +- crates/warp_tui/src/tui_builder.rs | 5 +- 10 files changed, 74 insertions(+), 90 deletions(-) rename crates/warp_tui/src/{agent_identity.rs => orchestrated_agent_identity_styling.rs} (96%) rename crates/warp_tui/src/{agent_identity_tests.rs => orchestrated_agent_identity_styling_tests.rs} (100%) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 434753fbf59..845022fddbc 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -183,12 +183,6 @@ impl TuiToolCallView { } } -/// The front-of-queue blocking interaction owned by an agent block: the -/// child view rendering the pending action that awaits a decision. -pub(super) struct TuiBlockingChild { - pub(super) view: ViewHandle, -} - /// Events emitted to the transcript that owns this rich-content block. pub(super) enum TuiAIBlockEvent { /// The block's cached canonical height must be remeasured. @@ -393,10 +387,13 @@ impl TuiAIBlock { ctx.notify(); } + // Create or update the interactive orchestration card for each + // streamed RunAgents tool call. for action in run_agents_actions { let AIAgentActionType::RunAgents(request) = &action.action else { continue; }; + // Existing block: re-sync its edit state from the latest streamed // chunk (the request may have grown since the view was created). if let Some(TuiToolCallView::OrchestrationBlock(view)) = @@ -420,6 +417,7 @@ impl TuiAIBlock { .map(|(config, status)| (config.clone(), status)) }) }; + let action_id = action.id.clone(); let request = request.clone(); let card_action_model = action_model.clone(); @@ -438,6 +436,7 @@ impl TuiAIBlock { ctx, ) }); + let action_id_for_events = action_id.clone(); ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { TuiOrchestrationBlockEvent::RejectRequested => { @@ -470,10 +469,13 @@ impl TuiAIBlock { /// The front-of-queue blocking interaction owned by this block, if any: /// the conversation's front pending action when it is `Blocked`, rendered - /// by one of this block's child views, and that view reports - /// `is_active_blocker`. Deriving from the action queue (not transcript order) + /// by one of this block's child views, and that view is still awaiting + /// confirmation. Deriving from the action queue (not transcript order) /// keeps semantics identical to the GUI's `focus_subview_if_necessary`. - pub(super) fn active_blocking_child(&self, ctx: &AppContext) -> Option { + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { let action_model = self.action_model.as_ref(ctx); let pending = action_model.get_pending_action(ctx)?; let action_id = pending.id.clone(); @@ -489,8 +491,8 @@ impl TuiAIBlock { match self.action_views.get(&action_id)? { TuiToolCallView::OrchestrationBlock(view) => view .as_ref(ctx) - .is_active_blocker(ctx) - .then(|| TuiBlockingChild { view: view.clone() }), + .is_awaiting_confirmation(ctx) + .then(|| view.clone()), // These tool views render inline and never replace the input. TuiToolCallView::FileEdits(_) | TuiToolCallView::ShellCommand(_) => None, } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index cfb8df46349..0887bb4858e 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,7 +9,6 @@ mod agent_block; mod agent_block_sections; -mod agent_identity; mod alt_screen_view; mod autoupdate; mod clipboard; @@ -33,6 +32,7 @@ mod keybindings; mod mcp_menu; mod model_menu; mod option_selector; +mod orchestrated_agent_identity_styling; mod orchestration_block; mod resume; mod skills_menu; diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs similarity index 96% rename from crates/warp_tui/src/agent_identity.rs rename to crates/warp_tui/src/orchestrated_agent_identity_styling.rs index d74798b7434..97058f9f1c3 100644 --- a/crates/warp_tui/src/agent_identity.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -1,5 +1,5 @@ -//! Deterministic color-and-glyph agent identities for the TUI orchestration -//! card: a theme-derived palette of ANSI colors crossed with +//! Deterministic color-and-glyph identity styling for orchestrated agents in +//! the TUI card: a theme-derived palette of ANSI colors crossed with //! a curated glyph set, plus the stable hash and per-request assignment //! policy that keep identities stable across re-renders and edits. @@ -139,5 +139,5 @@ pub(crate) fn assign_agent_identity_indices( } #[cfg(test)] -#[path = "agent_identity_tests.rs"] +#[path = "orchestrated_agent_identity_styling_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/agent_identity_tests.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs similarity index 100% rename from crates/warp_tui/src/agent_identity_tests.rs rename to crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 2df30ee6b76..7a0f3fa55a2 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -10,6 +10,7 @@ //! [`crate::agent_block::TuiAIBlock`] maps to action cancellation. Terminal, //! spawning, streaming, and restored states reuse the existing fallback //! tool-call presentation and its `tool_call_labels` copy. + use std::rc::Rc; use warp::tui_export::{ @@ -36,9 +37,9 @@ use configuration::{ build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, }; -use crate::agent_identity::AgentIdentity; use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::orchestrated_agent_identity_styling::AgentIdentity; use crate::tui_builder::TuiUiBuilder; const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; @@ -181,6 +182,8 @@ impl TuiOrchestrationBlock { ) -> Self { let action_id = action.id.clone(); let action_id_for_executor = action_id.clone(); + // Spawning events replace the confirmation UI with launch progress + // and release its input-blocking focus while agents start. ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event { RunAgentsExecutorEvent::SpawningStarted { action_id, @@ -203,6 +206,8 @@ impl TuiOrchestrationBlock { }); let action_id_for_actions = action_id.clone(); + // Action lifecycle events enable the card once streaming finishes and + // release its focus when this RunAgents action reaches a terminal state. ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event { BlocklistAIActionEvent::FinishedAction { action_id, .. } if action_id == &action_id_for_actions => @@ -224,8 +229,8 @@ impl TuiOrchestrationBlock { _ => {} }); - // Live catalog changes revalidate the edit state and refresh the - // active page. + // Harness and auth-secret catalog changes can invalidate selections, + // so revalidate the edit state and rebuild the active page. ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), |me, _, event, ctx| match event { @@ -244,6 +249,9 @@ impl TuiOrchestrationBlock { | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, ); + + // Model catalog updates can invalidate the selected model, so + // revalidate the edit state and rebuild the active model page. ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { me.orchestration_edit_state @@ -253,6 +261,9 @@ impl TuiOrchestrationBlock { ctx.notify(); } }); + + // Connected worker changes alter the remote host choices shown on + // the active page. ctx.subscribe_to_model( &warp::tui_export::ConnectedSelfHostedWorkersModel::handle(ctx), |me, _, event, ctx| { @@ -409,13 +420,12 @@ impl TuiOrchestrationBlock { ctx.notify(); } - /// Whether this card is the active blocking interaction: interactive in - /// Acceptance/Configuring while the action awaits confirmation, and - /// false once accepted, rejected, spawning, finished, or restored. - pub(super) fn is_active_blocker(&self, ctx: &AppContext) -> bool { + /// Whether this card still awaits a user decision. + pub(super) fn is_awaiting_confirmation(&self, ctx: &AppContext) -> bool { if self.decided || self.spawning.is_some() || self.is_restored { return false; } + matches!( self.controller.action_status(&self.action_id, ctx), Some(AIActionStatus::Blocked) @@ -517,70 +527,28 @@ impl TuiOrchestrationBlock { }); } - /// Applies a confirmed selection to the edit state and - /// advances to the next applicable page. - fn handle_page_confirmed(&mut self, id: &str, ctx: &mut ViewContext) { - let CardMode::Configuring { page } = self.mode else { - return; - }; - self.controller.apply_page_selection( - page, - id, - &mut self.orchestration_edit_state, - self.fallback_base_model_id.clone(), - ctx, - ); - self.finish_page_confirmation(page, ctx); - } - - /// Completes a page confirmation using an arrow's requested direction, - /// or the normal Enter behavior when no arrow direction is pending. + /// Navigates after confirmation using an arrow's requested direction, or + /// advances normally for Enter. fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { - match self.pending_page_navigation.take() { - Some(navigation) => self.navigate_after_confirmation(page, navigation, ctx), - None => self.advance_after(page, ctx), - } - } - - /// Advances past `page` in the (freshly recomputed) sequence, returning - /// to the acceptance card after the final page. - fn advance_after(&mut self, page: ConfigPage, ctx: &mut ViewContext) { - let sequence = - Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); - let next = sequence - .iter() - .position(|p| *p == page) - .and_then(|index| sequence.get(index + 1)) - .copied(); - match next { - Some(next) => self.open_page(next, ctx), - None => self.return_to_acceptance(ctx), - } - } - - /// Moves after committing `page`, recomputing the dynamic sequence so - /// choices such as Local navigate within the newly applicable pages. - fn navigate_after_confirmation( - &mut self, - page: ConfigPage, - navigation: PageConfirmationNavigation, - ctx: &mut ViewContext, - ) { let sequence = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { self.return_to_acceptance(ctx); return; }; + let navigation = self.pending_page_navigation.take(); let target = match navigation { - PageConfirmationNavigation::Previous => index + Some(PageConfirmationNavigation::Previous) => index .checked_sub(1) .and_then(|index| sequence.get(index)) .copied(), - PageConfirmationNavigation::Next => sequence.get(index + 1).copied(), + Some(PageConfirmationNavigation::Next) | None => sequence.get(index + 1).copied(), + }; + match target { + Some(target) => self.open_page(target, ctx), + None if navigation.is_some() => self.open_page(page, ctx), + None => self.return_to_acceptance(ctx), } - .unwrap_or(page); - self.open_page(target, ctx); } /// Moves to the next page without applying the current selection. @@ -611,8 +579,17 @@ impl TuiOrchestrationBlock { ) { match event { TuiOptionSelectorEvent::Confirmed { id } => { - let id = id.clone(); - self.handle_page_confirmed(&id, ctx); + let CardMode::Configuring { page } = self.mode else { + return; + }; + self.controller.apply_page_selection( + page, + id, + &mut self.orchestration_edit_state, + self.fallback_base_model_id.clone(), + ctx, + ); + self.finish_page_confirmation(page, ctx); } TuiOptionSelectorEvent::CustomTextSubmitted { value } => { if let CardMode::Configuring { @@ -658,7 +635,7 @@ impl TuiOrchestrationBlock { /// accept renders the reason inline and stays active, a valid one /// dispatches the edited request through `execute_run_agents`. fn handle_accept(&mut self, ctx: &mut ViewContext) { - if self.decided || self.spawning.is_some() || !self.is_active_blocker(ctx) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { return; } let request = self.to_request(); @@ -683,7 +660,7 @@ impl TuiOrchestrationBlock { /// Reject: resolves the request as rejected exactly once, /// from the acceptance card or any configuration page. fn handle_reject(&mut self, ctx: &mut ViewContext) { - if self.decided || self.spawning.is_some() || !self.is_active_blocker(ctx) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { return; } self.decided = true; @@ -695,7 +672,7 @@ impl TuiOrchestrationBlock { /// Opens configuration on the first page. fn handle_configure(&mut self, ctx: &mut ViewContext) { - if !self.is_active_blocker(ctx) { + if !self.is_awaiting_confirmation(ctx) { return; } let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) diff --git a/crates/warp_tui/src/orchestration_block/render.rs b/crates/warp_tui/src/orchestration_block/render.rs index d15839a727d..b63c15ed6dd 100644 --- a/crates/warp_tui/src/orchestration_block/render.rs +++ b/crates/warp_tui/src/orchestration_block/render.rs @@ -15,7 +15,7 @@ use warpui_core::AppContext; use super::{CardMode, TuiOrchestrationBlock, ORCHESTRATION_BLOCK_TITLE}; use crate::agent_block_sections::render_fallback_tool_call_section; -use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::orchestrated_agent_identity_styling::{assign_agent_identity_indices, AgentIdentity}; use crate::tui_builder::TuiUiBuilder; impl TuiOrchestrationBlock { diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index e6abee1fb09..2a2519c3d7f 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -470,13 +470,13 @@ fn accepting_dispatches_once_and_releases_focus() { App::test((), |mut app| async move { let request = request("oz", RunAgentsExecutionMode::Local); let (block, controller) = test_block(&mut app, &request); - assert!(app.read(|ctx| block.as_ref(ctx).is_active_blocker(ctx))); + assert!(app.read(|ctx| block.as_ref(ctx).is_awaiting_confirmation(ctx))); act(&mut app, &block, TuiOrchestrationBlockAction::Accept); act(&mut app, &block, TuiOrchestrationBlockAction::Accept); act(&mut app, &block, TuiOrchestrationBlockAction::Reject); assert_eq!(controller.executed_requests.borrow().as_slice(), &[request]); - assert!(app.read(|ctx| !block.as_ref(ctx).is_active_blocker(ctx))); + assert!(app.read(|ctx| !block.as_ref(ctx).is_awaiting_confirmation(ctx))); }); } diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index d88ae75c68a..9b876c5551e 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -51,7 +51,6 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use crate::agent_block::TuiBlockingChild; use crate::alt_screen_view::AltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; @@ -65,6 +64,7 @@ use crate::input_suggestions_mode::TuiInputSuggestionsModeModel; use crate::keybindings::TUI_BINDING_GROUP; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; +use crate::orchestration_block::TuiOrchestrationBlock; use crate::resume::TuiExitSummaryHandle; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; @@ -1023,7 +1023,7 @@ impl TuiTerminalSessionView { } /// The active front-of-queue blocking interaction, if any. - fn active_blocking_child(&self, ctx: &AppContext) -> Option { + fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) } @@ -1034,14 +1034,14 @@ impl TuiTerminalSessionView { /// draft/cursor/selection are untouched. fn sync_blocker_focus(&mut self, ctx: &mut ViewContext) { let blocker = self.active_blocking_child(ctx); - let blocker_view_id = blocker.as_ref().map(|child| child.view.id()); + let blocker_view_id = blocker.as_ref().map(ViewHandle::id); if blocker_view_id != self.active_blocker_view_id { // A foreground process owns the rendered pane and keyboard. Track // blocker changes while it is active, but defer focus handoff // until the process releases input. if !self.process_owns_input() { match &blocker { - Some(child) => ctx.focus(&child.view), + Some(child) => ctx.focus(child), None => ctx.focus(&self.input_view), } } diff --git a/crates/warp_tui/src/transcript_view.rs b/crates/warp_tui/src/transcript_view.rs index b57f6099da9..53a122e3798 100644 --- a/crates/warp_tui/src/transcript_view.rs +++ b/crates/warp_tui/src/transcript_view.rs @@ -22,7 +22,8 @@ use warpui_core::{ ViewContext, ViewHandle, }; -use super::agent_block::{TuiAIBlock, TuiAIBlockEvent, TuiBlockingChild}; +use super::agent_block::{TuiAIBlock, TuiAIBlockEvent}; +use super::orchestration_block::TuiOrchestrationBlock; use super::terminal_block::should_render_terminal_block; use super::tui_block_list_viewport_source::{ AgentBlockRegistry, CLISubagentBlockRegistry, TuiBlockListViewportSource, @@ -537,7 +538,10 @@ impl TuiTranscriptView { /// The front-of-queue blocking interaction across this transcript's /// agent blocks, if any. A pure query over the shared action queue; the /// session surface derives input visibility and focus from it. - pub(super) fn active_blocking_child(&self, ctx: &AppContext) -> Option { + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { self.agent_blocks .borrow() .values() diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 0cfc8e39522..2666af41182 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -17,7 +17,7 @@ use warpui_core::elements::tui::{ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; -use crate::agent_identity::{agent_identity_palette, AgentIdentity}; +use crate::orchestrated_agent_identity_styling::{agent_identity_palette, AgentIdentity}; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -246,7 +246,8 @@ impl TuiUiBuilder { } /// The deterministic agent identity palette for this theme, resolved - /// against the probed terminal background. See [`crate::agent_identity`]. + /// against the probed terminal background. See + /// [`crate::orchestrated_agent_identity_styling`]. pub(crate) fn agent_identity_palette(&self) -> Vec { agent_identity_palette( self.warp_theme.terminal_colors(), From 7d57ffd0f4ffa4beb7c457a6b9fe9bc905e9c39f Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 09:28:22 -0400 Subject: [PATCH 20/40] Remove unused orchestration card diff churn Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 2 +- app/src/ai/blocklist/telemetry.rs | 47 +++++++++---------- .../ai/document/orchestration_config_block.rs | 4 +- app/src/tui_export.rs | 6 --- pr_diff.txt | 0 5 files changed, 25 insertions(+), 34 deletions(-) create mode 100644 pr_diff.txt diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 16293596c43..341222a74d9 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -721,7 +721,7 @@ impl RunAgentsCardView { plan_id: (!self.card.plan_id.is_empty()).then(|| self.card.plan_id.clone()), decision, agent_count: self.card.agent_run_configs.len(), - harness: OrchestrationHarnessKind::from_harness_type( + harness: OrchestrationHarnessKind::from_str( &self .orchestration_edit_state .orchestration_config_state diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index db21288c526..28452dfc955 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -7,7 +7,7 @@ use crate::ai::agent::conversation::AIConversationId; #[derive(Debug, EnumDiscriminants)] #[strum_discriminants(derive(EnumIter))] -pub enum BlocklistOrchestrationTelemetryEvent { +pub(crate) enum BlocklistOrchestrationTelemetryEvent { TeamAgentCommunicationFailed(TeamAgentCommunicationFailedEvent), PlanConfigApprovalToggled(PlanConfigApprovalToggledEvent), RunAgentsCardDecision(RunAgentsCardDecisionEvent), @@ -19,7 +19,7 @@ pub enum BlocklistOrchestrationTelemetryEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentCommunicationKind { +pub(crate) enum TeamAgentCommunicationKind { Message, LifecycleEvent, } @@ -27,14 +27,14 @@ pub enum TeamAgentCommunicationKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentCommunicationTransport { +pub(crate) enum TeamAgentCommunicationTransport { Local, ServerApi, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentOrchestrationVersion { +pub(crate) enum TeamAgentOrchestrationVersion { V1, V2, } @@ -42,7 +42,7 @@ pub enum TeamAgentOrchestrationVersion { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentCommunicationFailureReason { +pub(crate) enum TeamAgentCommunicationFailureReason { InvalidLifecycleEventType, MissingSourceConversation, MissingSourceIdentifier, @@ -52,7 +52,7 @@ pub enum TeamAgentCommunicationFailureReason { } #[derive(Debug, Serialize)] -pub struct TeamAgentCommunicationFailedEvent { +pub(crate) struct TeamAgentCommunicationFailedEvent { pub communication_kind: TeamAgentCommunicationKind, pub transport: TeamAgentCommunicationTransport, pub orchestration_version: TeamAgentOrchestrationVersion, @@ -72,7 +72,7 @@ pub struct TeamAgentCommunicationFailedEvent { /// `Use orchestration` toggle. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationApprovalStatus { +pub(crate) enum OrchestrationApprovalStatus { Approved, Disapproved, } @@ -82,13 +82,13 @@ pub enum OrchestrationApprovalStatus { /// environment id or worker host. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationExecutionModeKind { +pub(crate) enum OrchestrationExecutionModeKind { Local, Remote, } impl OrchestrationExecutionModeKind { - pub fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { + pub(crate) fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { if mode.is_remote() { Self::Remote } else { @@ -102,7 +102,7 @@ impl OrchestrationExecutionModeKind { /// low-cardinality. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationHarnessKind { +pub(crate) enum OrchestrationHarnessKind { Oz, ClaudeCode, Codex, @@ -112,10 +112,7 @@ pub enum OrchestrationHarnessKind { } impl OrchestrationHarnessKind { - /// Buckets a raw harness_type string into the closed telemetry set. - /// Not `FromStr`: infallible and analytics-shaped, so a distinct name - /// avoids clashing with the standard trait method. - pub fn from_harness_type(harness_type: &str) -> Self { + pub(crate) fn from_str(harness_type: &str) -> Self { match harness_type { "oz" | "" => Self::Oz, "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, @@ -131,7 +128,7 @@ impl OrchestrationHarnessKind { /// the dispatched orchestration request and either the original tool /// call or an active approved config. Match the server's equivalent /// field-name constants so the two telemetry streams can be joined. -pub mod orchestration_modified_field { +pub(crate) mod orchestration_modified_field { pub const MODEL_ID: &str = "model_id"; pub const HARNESS: &str = "harness"; pub const EXECUTION_MODE: &str = "execution_mode"; @@ -141,7 +138,7 @@ pub mod orchestration_modified_field { } #[derive(Debug, Serialize)] -pub struct PlanConfigApprovalToggledEvent { +pub(crate) struct PlanConfigApprovalToggledEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -159,14 +156,14 @@ pub struct PlanConfigApprovalToggledEvent { /// Decision a user took on the run_agents confirmation card. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum RunAgentsCardDecision { +pub(crate) enum RunAgentsCardDecision { Accept, AcceptWithoutOrchestration, Reject, } #[derive(Debug, Serialize)] -pub struct RunAgentsCardDecisionEvent { +pub(crate) struct RunAgentsCardDecisionEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -195,7 +192,7 @@ pub struct RunAgentsCardDecisionEvent { /// [`PlanConfigApprovalToggledEvent`] (the user's approval toggle). #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationEntrySource { +pub(crate) enum OrchestrationEntrySource { /// `/orchestrate` slash-command mode on a user query. SlashCommandOrchestrate, /// `run_agents` confirmation card was shown (not auto-launched). @@ -203,7 +200,7 @@ pub enum OrchestrationEntrySource { } #[derive(Debug, Serialize)] -pub struct OrchestrationEnteredEvent { +pub(crate) struct OrchestrationEnteredEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -214,7 +211,7 @@ pub struct OrchestrationEnteredEvent { /// becomes visible to the user on a plan card. One emission per /// `OrchestrationConfigBlockView` instance. #[derive(Debug, Serialize)] -pub struct AgentProposedConfigEvent { +pub(crate) struct AgentProposedConfigEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -227,7 +224,7 @@ pub struct AgentProposedConfigEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum PillBarPillKind { +pub(crate) enum PillBarPillKind { Orchestrator, Child, } @@ -235,7 +232,7 @@ pub enum PillBarPillKind { /// Concrete user actions against an orchestration pill bar entry. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum PillBarActionKind { +pub(crate) enum PillBarActionKind { /// User clicked the pill body. See `switch_outcome` for what /// happened next. Switch, @@ -258,7 +255,7 @@ pub enum PillBarActionKind { /// action variants again. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum PillSwitchOutcome { +pub(crate) enum PillSwitchOutcome { /// Pill click navigated within the current pane. SwitchedInPlace, /// Target conversation was already owned by another visible @@ -267,7 +264,7 @@ pub enum PillSwitchOutcome { } #[derive(Debug, Serialize)] -pub struct PillBarInteractionEvent { +pub(crate) struct PillBarInteractionEvent { pub action: PillBarActionKind, pub pill_kind: PillBarPillKind, pub total_pills: usize, diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 484df0e5875..f53ba99d355 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -973,7 +973,7 @@ impl OrchestrationConfigBlockView { .orchestration_config_state .execution_mode, ), - harness: OrchestrationHarnessKind::from_harness_type( + harness: OrchestrationHarnessKind::from_str( &self .orchestration_edit_state .orchestration_config_state @@ -1013,7 +1013,7 @@ impl OrchestrationConfigBlockView { BlocklistOrchestrationTelemetryEvent::AgentProposedConfig(AgentProposedConfigEvent { conversation_id: self.conversation_id, plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()), - harness: OrchestrationHarnessKind::from_harness_type( + harness: OrchestrationHarnessKind::from_str( &self .orchestration_edit_state .orchestration_config_state diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index a96f313e965..a74ed383360 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -56,12 +56,6 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; -pub use crate::ai::blocklist::telemetry::{ - orchestration_modified_field, BlocklistOrchestrationTelemetryEvent, - OrchestrationApprovalStatus, OrchestrationEnteredEvent, OrchestrationEntrySource, - OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision, - RunAgentsCardDecisionEvent, -}; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, diff --git a/pr_diff.txt b/pr_diff.txt new file mode 100644 index 00000000000..e69de29bb2d From fc937f3667647e76d8b1b0cdad945c806dd3aca4 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 09:40:09 -0400 Subject: [PATCH 21/40] Fix orchestration blocker focus handle Co-Authored-By: Oz --- crates/warp_tui/src/terminal_session_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 9b876c5551e..31b980337d9 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -323,7 +323,7 @@ impl TuiTerminalSessionView { } } else if ctx.is_self_focused() { if let Some(blocker) = self.active_blocking_child(ctx) { - ctx.focus(&blocker.view); + ctx.focus(&blocker); } else { ctx.focus(&self.input_view); } From 9e77eed05380e120ad12202325e390cad31c2c22 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 10:10:57 -0400 Subject: [PATCH 22/40] Force Warp harness for local TUI orchestration Co-Authored-By: Oz --- app/src/ai/orchestration/edit_state_tests.rs | 50 +++++++++++++++++++ .../src/orchestration_block/configuration.rs | 12 +++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs index 23e27d99640..4f65dac1204 100644 --- a/app/src/ai/orchestration/edit_state_tests.rs +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -79,6 +79,56 @@ fn execution_mode_change_prefers_valid_fallback_over_default_model() { assert_eq!(state.model_id, "fallback"); } +#[test] +fn forcing_oz_before_local_preserves_codex_model_memory() { + let state = OrchestrationConfigState::from_run_agents_fields( + Some("gpt-5"), + Some("codex"), + &remote_mode(), + ); + let mut edit_state = OrchestrationEditState { + orchestration_config_state: state, + saved_model_per_harness: HashMap::from([("oz".to_string(), "auto".to_string())]), + }; + let model_is_valid = |id: &str, harness: &str, _is_local: bool| match harness { + "oz" => id == "auto", + "codex" => id == "gpt-5", + _ => false, + }; + let default_model_id = |harness: &str| match harness { + "oz" => Some("auto".to_string()), + "codex" => Some(String::new()), + _ => None, + }; + + edit_state.apply_harness_change_core( + "oz", + Some("auto".to_string()), + AuthSecretSelection::Unset, + &model_is_valid, + &default_model_id, + ); + edit_state + .orchestration_config_state + .apply_execution_mode_change_core( + false, + Some("auto".to_string()), + None, + &model_is_valid, + &default_model_id, + ); + + assert_eq!(edit_state.orchestration_config_state.harness_type, "oz"); + assert_eq!(edit_state.orchestration_config_state.model_id, "auto"); + assert!(matches!( + edit_state.orchestration_config_state.execution_mode, + RunAgentsExecutionMode::Local + )); + assert_eq!( + edit_state.saved_model_per_harness.get("codex"), + Some(&"gpt-5".to_string()) + ); +} #[test] fn harness_change_saves_and_restores_per_harness_model_memory() { let state = OrchestrationConfigState::from_run_agents_fields( diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index 3c58775e343..1d5433b9abb 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -138,13 +138,15 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { ) { match page { ConfigPage::Location => { + let is_remote = id == LOCATION_CLOUD_ID; + if !is_remote { + // For now, we only allow local runs to use the oz harness + edit_state.apply_harness_change("oz", fallback_base_model_id.clone(), ctx); + } + edit_state .orchestration_config_state - .apply_execution_mode_change( - id == LOCATION_CLOUD_ID, - fallback_base_model_id, - ctx, - ); + .apply_execution_mode_change(is_remote, fallback_base_model_id, ctx); } ConfigPage::Harness => { edit_state.apply_harness_change(id, fallback_base_model_id, ctx); From 1e64d17514e2c9664b7407254d1cbd9cf6ffa01c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 10:55:08 -0400 Subject: [PATCH 23/40] update colors to be up to design --- .../orchestrated_agent_identity_styling.rs | 61 ++++-------------- ...chestrated_agent_identity_styling_tests.rs | 63 +++++++++++++------ crates/warp_tui/src/tui_builder.rs | 8 +-- specs/CODE-1822/TECH.md | 2 +- 4 files changed, 59 insertions(+), 75 deletions(-) diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs index 97058f9f1c3..736d848bdc7 100644 --- a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -1,5 +1,5 @@ //! Deterministic color-and-glyph identity styling for orchestrated agents in -//! the TUI card: a theme-derived palette of ANSI colors crossed with +//! the TUI card: the design's theme-derived ANSI colors crossed with //! a curated glyph set, plus the stable hash and per-request assignment //! policy that keep identities stable across re-renders and edits. @@ -9,15 +9,7 @@ use warpui_core::elements::tui::TuiStyle; use warpui_core::elements::Fill as CoreFill; /// Glyphs paired with themed colors to form deterministic agent identities. -const AGENT_IDENTITY_GLYPHS: [&str; 8] = ["⟡", "⊹", "✶", "◊", "⊛", "*", "✠", "●"]; - -/// Minimum luma distance from the resolved background for a palette color -/// to count as readable. -const AGENT_IDENTITY_MIN_CONTRAST: f32 = 32.0; - -/// The identity palette must offer at least this many combinations; use the -/// unfiltered ANSI set when contrast filtering would drop below it. -const AGENT_IDENTITY_MIN_COMBOS: usize = 32; +const AGENT_IDENTITY_GLYPHS: [&str; 7] = ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]; /// One deterministic color-and-glyph agent identity. #[derive(Clone, Debug, PartialEq)] @@ -26,45 +18,19 @@ pub(crate) struct AgentIdentity { pub(crate) style: TuiStyle, } -/// Builds the identity palette from the themed ANSI colors (normal + bright, -/// excluding low-contrast slots against `background`) crossed with the glyph -/// set, yielding at least [`AGENT_IDENTITY_MIN_COMBOS`] combinations. All -/// colors derive from the theme; no raw design hex. -pub(crate) fn agent_identity_palette( - colors: &TerminalColors, - background: ColorU, -) -> Vec { - let all_colors: [ColorU; 16] = [ - colors.normal.black.into(), - colors.normal.red.into(), - colors.normal.green.into(), - colors.normal.yellow.into(), +/// Builds the identity palette from the seven color roles in the design: +/// themed cyan, blue, magenta, lilac, pink, green, and yellow. Lilac uses +/// bright magenta while the remaining roles use their normal ANSI slots. +pub(crate) fn agent_identity_palette(colors: &TerminalColors) -> Vec { + let colors: [ColorU; 7] = [ + colors.normal.cyan.into(), colors.normal.blue.into(), colors.normal.magenta.into(), - colors.normal.cyan.into(), - colors.normal.white.into(), - colors.bright.black.into(), - colors.bright.red.into(), - colors.bright.green.into(), - colors.bright.yellow.into(), - colors.bright.blue.into(), colors.bright.magenta.into(), - colors.bright.cyan.into(), - colors.bright.white.into(), + colors.normal.red.into(), + colors.normal.green.into(), + colors.normal.yellow.into(), ]; - let background_luma = luma(background); - let readable: Vec = all_colors - .iter() - .copied() - .filter(|color| (luma(*color) - background_luma).abs() >= AGENT_IDENTITY_MIN_CONTRAST) - .collect(); - // Guarantee the minimum combination count even for unusual themes - // where filtering strips too many slots. - let colors = if readable.len() * AGENT_IDENTITY_GLYPHS.len() >= AGENT_IDENTITY_MIN_COMBOS { - readable - } else { - all_colors.to_vec() - }; // Vary the color fastest so adjacent palette indices differ in color // before repeating a glyph. AGENT_IDENTITY_GLYPHS @@ -78,11 +44,6 @@ pub(crate) fn agent_identity_palette( .collect() } -/// Rec. 709 luma of a solid color, for background-contrast filtering. -fn luma(color: ColorU) -> f32 { - 0.2126 * f32::from(color.r) + 0.7152 * f32::from(color.g) + 0.0722 * f32::from(color.b) -} - /// Stable FNV-1a hash of an agent name; must not vary across runs or /// platforms so identities stay deterministic. pub(crate) fn stable_hash(name: &str) -> u64 { diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs index 1b67a9f72a7..7667a913184 100644 --- a/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs @@ -1,24 +1,56 @@ use std::collections::HashSet; use warp::tui_export::{dark_theme, light_theme}; -use warp_core::ui::theme::WarpTheme; +use warp_core::ui::theme::{Fill as ThemeFill, WarpTheme}; +use warpui_core::elements::tui::Color; +use warpui_core::elements::Fill as CoreFill; -use super::{agent_identity_palette, assign_agent_identity_indices, stable_hash}; +use super::{ + agent_identity_palette, assign_agent_identity_indices, stable_hash, AGENT_IDENTITY_GLYPHS, +}; fn palette_len(theme: &WarpTheme) -> usize { - agent_identity_palette(theme.terminal_colors(), theme.background().into_solid()).len() + agent_identity_palette(theme.terminal_colors()).len() } #[test] -fn palette_offers_at_least_32_combinations_in_dark_and_light_themes() { - assert!(palette_len(&dark_theme()) >= 32); - assert!(palette_len(&light_theme()) >= 32); +fn palette_crosses_the_seven_design_glyphs_and_colors() { + assert_eq!(AGENT_IDENTITY_GLYPHS, ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]); + assert_eq!(palette_len(&dark_theme()), 49); + assert_eq!(palette_len(&light_theme()), 49); +} + +#[test] +fn palette_uses_the_themed_design_color_roles_in_order() { + let theme = dark_theme(); + let colors = theme.terminal_colors(); + let expected: Vec> = [ + colors.normal.cyan, + colors.normal.blue, + colors.normal.magenta, + colors.bright.magenta, + colors.normal.red, + colors.normal.green, + colors.normal.yellow, + ] + .into_iter() + .map(|color| Some(CoreFill::from(ThemeFill::from(color)).into())) + .collect(); + let palette = agent_identity_palette(colors); + + assert_eq!( + palette[..expected.len()] + .iter() + .map(|identity| identity.style.fg) + .collect::>(), + expected, + ); } #[test] fn palette_entries_are_distinct_glyph_color_pairs() { let theme = dark_theme(); - let palette = agent_identity_palette(theme.terminal_colors(), theme.background().into_solid()); + let palette = agent_identity_palette(theme.terminal_colors()); let unique: HashSet = palette .iter() .map(|identity| format!("{}-{:?}", identity.glyph, identity.style.fg)) @@ -54,20 +86,15 @@ fn assignment_keeps_identities_distinct_within_one_request() { #[test] fn assignment_keeps_glyphs_and_colors_unique_until_exhausted() { - // 8 glyph rows × 5 color columns. - let palette_len = 40; - let color_count = 5; - let names: Vec = (0..8).map(|i| format!("agent-{i}")).collect(); + // 7 glyph rows × 7 color columns. + let palette_len = 49; + let color_count = 7; + let names: Vec = (0..7).map(|i| format!("agent-{i}")).collect(); let indices = assign_agent_identity_indices(&names, palette_len); - // All eight agents get distinct glyph rows. + // All seven agents get distinct glyph rows and color columns. let glyphs: HashSet = indices.iter().map(|index| index / color_count).collect(); assert_eq!(glyphs.len(), names.len()); - // The first five agents also get distinct color columns; the sixth - // onward must reuse one of the five colors. - let colors: HashSet = indices[..color_count] - .iter() - .map(|index| index % color_count) - .collect(); + let colors: HashSet = indices.iter().map(|index| index % color_count).collect(); assert_eq!(colors.len(), color_count); } diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 2666af41182..7d9a0e1fd6b 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -245,14 +245,10 @@ impl TuiUiBuilder { self.primary_text_style().add_modifier(Modifier::BOLD) } - /// The deterministic agent identity palette for this theme, resolved - /// against the probed terminal background. See + /// The deterministic agent identity palette for this theme. See /// [`crate::orchestrated_agent_identity_styling`]. pub(crate) fn agent_identity_palette(&self) -> Vec { - agent_identity_palette( - self.warp_theme.terminal_colors(), - self.base_background().into_solid(), - ) + agent_identity_palette(self.warp_theme.terminal_colors()) } /// Collapsible-header style while the pointer hovers it. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 0179743c4b1..21cd6d6f56d 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -58,7 +58,7 @@ Input visibility is a pure function of the front-of-queue blocker rather than a - Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. ### 3. Theming and agent identity -`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_header_background()` (the overlay applied twice for the title row), `orchestration_selected_value_style()`, and `agent_identity_palette()`, while selected configuration rows use the shared `option_selector_selected_style()` recipe. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. +`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_header_background()` (the overlay applied twice for the title row), `orchestration_selected_value_style()`, and `agent_identity_palette()`, while selected configuration rows use the shared `option_selector_selected_style()` recipe. The palette crosses the design's seven glyphs (`⊹ ⟡ ✶ ◊ ⊛ * ✠`) with seven themed ANSI roles: normal cyan, blue, and magenta; bright magenta for lilac; and normal red, green, and yellow for the design's pink, green, and yellow swatches. This yields 49 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. From d2f380bb47f62dfd2a1d1f012816a3eb1400ee81 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 11:14:07 -0400 Subject: [PATCH 24/40] Address orchestration review feedback Co-Authored-By: Oz --- app/src/ai/orchestration/config_state.rs | 58 ++++++++++------- .../ai/orchestration/config_state_tests.rs | 24 +++++++ crates/warp_tui/src/orchestration_block.rs | 22 +++---- .../src/orchestration_block/configuration.rs | 15 ++++- .../warp_tui/src/orchestration_block_tests.rs | 64 +++++++++++++++---- crates/warp_tui/src/terminal_session_view.rs | 8 +-- 6 files changed, 142 insertions(+), 49 deletions(-) diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index a914c3a444a..7ce3f6fbc3d 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -43,6 +43,9 @@ pub struct OrchestrationConfigState { pub model_id: String, pub harness_type: String, pub execution_mode: RunAgentsExecutionMode, + /// Per-call value hidden from the orchestration editors. Kept outside + /// `execution_mode` so a temporary switch to Local does not discard it. + remote_computer_use_enabled: bool, /// Drives the picker display and Accept gate. Persisted as /// `Named(_)` only via `CloudAgentSettings.last_selected_auth_secret`. pub auth_secret_selection: AuthSecretSelection, @@ -92,10 +95,19 @@ impl OrchestrationConfigState { harness_type: Option<&str>, execution_mode: &RunAgentsExecutionMode, ) -> Self { + let remote_computer_use_enabled = match execution_mode { + RunAgentsExecutionMode::Local => false, + RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } => *computer_use_enabled, + }; + Self { model_id: model_id.unwrap_or_default().to_string(), harness_type: harness_type.unwrap_or_default().to_string(), execution_mode: execution_mode.clone(), + remote_computer_use_enabled, auth_secret_selection: AuthSecretSelection::Unset, } } @@ -116,6 +128,7 @@ impl OrchestrationConfigState { model_id: config.model_id.clone(), harness_type: config.harness_type.clone(), execution_mode, + remote_computer_use_enabled: false, auth_secret_selection: AuthSecretSelection::Unset, }; if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { @@ -135,10 +148,17 @@ impl OrchestrationConfigState { self.execution_mode = RunAgentsExecutionMode::Remote { environment_id: String::new(), worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), - computer_use_enabled: false, + computer_use_enabled: self.remote_computer_use_enabled, }; } } else { + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &self.execution_mode + { + self.remote_computer_use_enabled = *computer_use_enabled; + } self.execution_mode = RunAgentsExecutionMode::Local; self.sanitize_for_local_execution(); } @@ -204,34 +224,28 @@ impl OrchestrationConfigState { /// user-approved source of truth — the LLM's run_agents call may /// omit or set these differently, but the config always wins. /// - /// `computer_use_enabled` is preserved from the current state when - /// both sides are Remote, since it is a per-call flag set by the LLM. + /// `computer_use_enabled` is preserved when the config changes execution + /// mode, since it is a per-call flag rather than part of the approved plan + /// configuration. pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) { self.model_id = config.model_id.clone(); self.harness_type = config.harness_type.clone(); - - let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) { - ( - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - OrchestrationExecutionMode::Remote { .. }, - ) => Some(*computer_use_enabled), - _ => None, - }; + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &self.execution_mode + { + self.remote_computer_use_enabled = *computer_use_enabled; + } self.execution_mode = Self::from_orchestration_config(config).execution_mode; - if let ( - Some(cue), - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - ) = (preserve_computer_use, &mut self.execution_mode) + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &mut self.execution_mode { - *computer_use_enabled = cue; + *computer_use_enabled = self.remote_computer_use_enabled; } } diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 0b5c9d0de0d..5bc99114fda 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -33,6 +33,30 @@ fn toggle_to_local_sanitizes_disabled_codex() { )); } +#[test] +fn local_round_trip_preserves_remote_computer_use() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("auto"), + Some("oz"), + &RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: true, + }, + ); + + state.toggle_execution_mode_to_remote(false); + state.toggle_execution_mode_to_remote(true); + + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Remote { + computer_use_enabled: true, + .. + } + )); +} + #[test] fn toggle_to_local_preserves_claude() { let mut state = OrchestrationConfigState::from_run_agents_fields( diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 7a0f3fa55a2..2fcb8901f24 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -327,9 +327,9 @@ impl TuiOrchestrationBlock { } } - /// Seeds the run-wide edit state from the streamed request, resolving - /// empty fields from an approved plan config (state parity with the - /// GUI card's `RunAgentsEditState::from_request` + `resolve_from_config`). + /// Seeds the run-wide edit state from the streamed request. An approved + /// plan config unconditionally supplies the executor-owned fields; + /// otherwise the TUI's Local mode is normalized to its Oz-only policy. fn config_state_from_request( request: &RunAgentsRequest, active_config: Option<&(OrchestrationConfig, OrchestrationConfigStatus)>, @@ -343,14 +343,13 @@ impl TuiOrchestrationBlock { // becomes `Unset`; defaults re-resolve from persisted settings. state.auth_secret_selection = AuthSecretSelection::from_optional_name(request.harness_auth_secret_name.clone()); - if matches!(request.execution_mode, RunAgentsExecutionMode::Local) { - // Re-applying Local sanitizes product-disabled local harnesses. - state.toggle_execution_mode_to_remote(false); - } - if let Some((config, status)) = active_config { - if status.is_approved() { - state.resolve_from_config(config); - } + let approved_config = active_config + .filter(|(_, status)| status.is_approved()) + .map(|(config, _)| config); + if let Some(config) = approved_config { + state.override_from_approved_config(config); + } else { + configuration::normalize_tui_local_harness(&mut state); } state } @@ -647,6 +646,7 @@ impl TuiOrchestrationBlock { ctx, ) { self.accept_error = Some(reason); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); return; } diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index 1d5433b9abb..b2a3b36a09c 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -4,13 +4,25 @@ use warp::tui_export::{ accept_disabled_reason_with_auth, api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, persist_environment_selection, persist_host_selection, AIActionStatus, AIAgentActionId, BlocklistAIActionModel, - OptionSnapshot, OrchestrationConfigState, OrchestrationEditState, RunAgentsRequest, + OptionSnapshot, OrchestrationConfigState, OrchestrationEditState, RunAgentsExecutionMode, + RunAgentsRequest, }; use warpui_core::{AppContext, ModelHandle}; /// Row id emitted by `location_snapshot` for remote execution. const LOCATION_CLOUD_ID: &str = "cloud"; +/// Applies the TUI policy that Local configuration has no harness page and +/// therefore always uses Oz. +pub(super) fn normalize_tui_local_harness(state: &mut OrchestrationConfigState) { + if matches!(state.execution_mode, RunAgentsExecutionMode::Local) + && !state.harness_type.eq_ignore_ascii_case("oz") + { + state.harness_type = "oz".to_string(); + state.model_id.clear(); + } +} + /// Builds a dispatched request from immutable card fields and edited run-wide state. pub(super) fn build_request( fields: &RunAgentsRequest, @@ -147,6 +159,7 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { edit_state .orchestration_config_state .apply_execution_mode_change(is_remote, fallback_base_model_id, ctx); + normalize_tui_local_harness(&mut edit_state.orchestration_config_state); } ConfigPage::Harness => { edit_state.apply_harness_change(id, fallback_base_model_id, ctx); diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 2a2519c3d7f..c2ff227f6ac 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use ai::agent::orchestration_config::{ @@ -16,7 +16,7 @@ use warpui_core::TypedActionView as _; use super::{ build_request, CardMode, ConfigPage, OrchestrationBlockController, TuiOrchestrationBlock, - TuiOrchestrationBlockAction, + TuiOrchestrationBlockAction, TuiOrchestrationBlockEvent, }; use crate::option_selector::TuiOptionSelectorAction; use crate::test_fixtures::TestHostView; @@ -128,11 +128,10 @@ fn edit_state_carries_the_request_auth_secret() { } #[test] -fn edit_state_resolves_empty_fields_from_an_approved_config() { - let mut inherit_all = request("", RunAgentsExecutionMode::Local); - inherit_all.model_id = String::new(); +fn edit_state_is_overridden_by_an_approved_config() { + let incoming = request("oz", RunAgentsExecutionMode::Local); let config = OrchestrationConfig { - model_id: "auto".to_string(), + model_id: "sonnet".to_string(), harness_type: "claude".to_string(), execution_mode: OrchestrationExecutionMode::Remote { environment_id: "env-2".to_string(), @@ -140,22 +139,34 @@ fn edit_state_resolves_empty_fields_from_an_approved_config() { }, }; let state = TuiOrchestrationBlock::config_state_from_request( - &inherit_all, + &incoming, Some(&(config.clone(), OrchestrationConfigStatus::Approved)), ); assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "auto"); + assert_eq!(state.model_id, "sonnet"); assert!(state.execution_mode.is_remote()); - // A disapproved config does not resolve inherited fields. + // A disapproved config does not override the request. let state = TuiOrchestrationBlock::config_state_from_request( - &inherit_all, + &incoming, Some(&(config, OrchestrationConfigStatus::Disapproved)), ); - assert_eq!(state.harness_type, ""); + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, "auto"); assert!(!state.execution_mode.is_remote()); } +#[test] +fn unapproved_local_request_forces_oz_harness() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("claude", RunAgentsExecutionMode::Local), + None, + ); + + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, ""); +} + #[test] fn build_request_carries_card_fields_and_edited_run_wide_state() { let original = request("oz", remote("env-1", "warp")); @@ -202,6 +213,7 @@ fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { #[derive(Default)] struct TestController { executed_requests: RefCell>, + accept_error: RefCell>, } impl OrchestrationBlockController for TestController { @@ -274,6 +286,9 @@ impl OrchestrationBlockController for TestController { _state: &OrchestrationConfigState, _ctx: &mut warpui::AppContext, ) -> Result<(), String> { + if let Some(reason) = self.accept_error.borrow().clone() { + return Err(reason); + } self.executed_requests.borrow_mut().push(request); Ok(()) } @@ -393,6 +408,33 @@ fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { }); } +#[test] +fn blocked_accept_invalidates_card_layout() { + App::test((), |mut app| async move { + let (block, controller) = + test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + *controller.accept_error.borrow_mut() = Some("Choose a model.".to_string()); + let invalidations = Rc::new(Cell::new(0)); + let invalidations_for_subscription = invalidations.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&block, move |_, event, _| match event { + TuiOrchestrationBlockEvent::BlockingStateChanged => { + invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); + } + TuiOrchestrationBlockEvent::RejectRequested => {} + }); + }); + + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + + assert_eq!(invalidations.get(), 1); + assert_eq!( + block.read(&app, |block, _| block.accept_error.clone()), + Some("Choose a model.".to_string()) + ); + }); +} + #[test] fn failed_arrow_confirmation_does_not_change_later_enter_navigation() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 31b980337d9..9c9ffb199d8 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1036,16 +1036,16 @@ impl TuiTerminalSessionView { let blocker = self.active_blocking_child(ctx); let blocker_view_id = blocker.as_ref().map(ViewHandle::id); if blocker_view_id != self.active_blocker_view_id { - // A foreground process owns the rendered pane and keyboard. Track - // blocker changes while it is active, but defer focus handoff - // until the process releases input. + // A foreground process owns the rendered pane and keyboard. Defer + // both the focus handoff and its completion marker until the + // process releases input, so the transition remains retryable. if !self.process_owns_input() { match &blocker { Some(child) => ctx.focus(child), None => ctx.focus(&self.input_view), } + self.active_blocker_view_id = blocker_view_id; } - self.active_blocker_view_id = blocker_view_id; } ctx.notify(); } From 54c615cd161edaa7bbf7c4dad0d2af634ca49001 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 18:36:08 -0400 Subject: [PATCH 25/40] Add TuiSessions multi-session container + focused-session root view TuiSessions (SingletonEntity) owns all session cores with insertion order, focus, and Added/Removed/FocusChanged events; all session creation flows through it. RootTuiView becomes a projection: a lazy, retained view cache keyed by session id, rendering and routing input to only the focused session. The login bootstrap registers session 0 focused, and the terminal manager handle moves onto the session core. Co-Authored-By: Oz --- app/Cargo.toml | 8 +- app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 4 +- .../terminal/local_tty/terminal_manager.rs | 28 +++ app/src/tui/mcp.rs | 12 ++ app/src/tui_export.rs | 162 +++++++++++++++++ app/src/user_config/mod.rs | 4 +- app/src/warp_managed_paths_watcher.rs | 2 +- app/src/workspaces/user_workspaces.rs | 2 +- crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/root_view.rs | 89 +++------ crates/warp_tui/src/root_view_tests.rs | 67 +++++++ crates/warp_tui/src/session.rs | 85 +++------ crates/warp_tui/src/sessions.rs | 172 ++++++++++++++++++ crates/warp_tui/src/sessions_tests.rs | 92 ++++++++++ crates/warp_tui/src/terminal_session_view.rs | 36 +++- crates/warp_tui/src/test_fixtures.rs | 83 ++++++++- specs/code-1822-tui-multi-session/TECH.md | 71 ++++++++ 23 files changed, 806 insertions(+), 136 deletions(-) create mode 100644 crates/warp_tui/src/root_view_tests.rs create mode 100644 crates/warp_tui/src/sessions.rs create mode 100644 crates/warp_tui/src/sessions_tests.rs create mode 100644 specs/code-1822-tui-multi-session/TECH.md diff --git a/app/Cargo.toml b/app/Cargo.toml index 7ddcf236938..6c02928ed18 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -845,7 +845,13 @@ voice_input = ["dep:voice_input"] system_theme = [] tab_close_button_on_left = [] team_features_override = [] -test-util = ["cloud_object_client/test-util", "warp_server_auth/test-util"] +test-util = [ + "cloud_object_client/test-util", + "warp_server_auth/test-util", + "http_client/test-util", + "warp_core/test-util", + "ai/test-util", +] team_workflows = ["team_features_override"] terminal_lifecycle_recovery = [] toggle_bootstrap_block = [] diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index 80abe931e21..d6b74ad3478 100644 --- a/app/src/ai/document/ai_document_model.rs +++ b/app/src/ai/document/ai_document_model.rs @@ -228,7 +228,7 @@ impl AIDocumentModel { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn new_for_test() -> Self { let (save_tx, _save_rx) = async_channel::unbounded(); Self { diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index 1bc116efc2c..70a1227098c 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,7 +115,9 @@ impl AuthManager { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn new_for_test(ctx: &mut ModelContext) -> Self { use crate::server::server_api::ServerApiProvider; diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index bef0f00bacb..2cb902d5655 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -1684,7 +1684,7 @@ impl CloudModel { .collect::>() } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self::new(None, Vec::new(), None) } diff --git a/app/src/lib.rs b/app/src/lib.rs index 10a733d79cb..c1fd2ab19ec 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -648,7 +648,9 @@ impl LaunchMode { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub(crate) fn new_for_unit_test() -> Self { LaunchMode::Test { driver: Box::new(None), diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index f3da4863500..8ff728ce47b 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,7 +449,9 @@ impl ServerApi { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1287,7 +1289,9 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn new_for_test() -> Self { let server_api = Arc::new(ServerApi::new_for_test()); let auth_client = Arc::new(AuthClientImpl::new(server_api.base_client.clone())); diff --git a/app/src/server/sync_queue.rs b/app/src/server/sync_queue.rs index b3ceac851ab..f563ad475cb 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,7 +356,9 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn mock(ctx: &mut ModelContext) -> Self { use super::server_api::ServerApiProvider; diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index b138854c94c..eb0e15552a1 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -280,7 +280,7 @@ fn handle_warp_config_change( /// settings when the settings file feature flag is disabled. fn init_platform_native_preferences() -> user_preferences::Model { cfg_if::cfg_if! { - if #[cfg(test)] { + if #[cfg(any(test, feature = "test-util"))] { Box::::default() } else if #[cfg(any(target_os = "linux", target_os = "freebsd", feature = "integration_tests"))] { match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) { @@ -448,7 +448,7 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(test)] +#[cfg(any(test, feature = "test-util"))] pub fn init_and_register_user_preferences(ctx: &mut AppContext) { let (public_prefs, _parse_error) = init_public_user_preferences(); ctx.add_singleton_model(move |_| settings::PublicPreferences::new(public_prefs)); diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 4b8d5af0031..d78733ead7d 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -122,6 +122,34 @@ pub struct TerminalSurfaceInit { pub colors: ColorList, pub inactive_pty_reads_rx: InactiveReceiver>>, } + +#[cfg(any(test, feature = "test-util"))] +impl TerminalSurfaceInit { + /// Creates mock terminal surface inputs without spawning a PTY. + pub fn new_for_test(ctx: &mut AppContext) -> Self { + let (_wakeups_tx, wakeups_rx) = async_channel::unbounded(); + let (_events_tx, events_rx) = async_channel::unbounded(); + let (pty_reads_tx, pty_reads_rx) = + async_broadcast::broadcast(PTY_READS_BROADCAST_CHANNEL_SIZE); + drop(pty_reads_tx); + let sessions = ctx.add_model(|_| Sessions::new_for_test()); + let model_events = + ctx.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx)); + let model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); + let colors = model.lock().colors(); + let size_info = model.lock().block_list().size().to_owned(); + Self { + wakeups_rx, + model_events, + model, + sessions, + size_info, + colors, + inactive_pty_reads_rx: pty_reads_rx.deactivate(), + } + } +} + /// A newly constructed terminal surface and its manager post-wiring callback. pub struct TerminalSurfaceResult { pub surface: ViewHandle, diff --git a/app/src/tui/mcp.rs b/app/src/tui/mcp.rs index efec313be12..226653f92a2 100644 --- a/app/src/tui/mcp.rs +++ b/app/src/tui/mcp.rs @@ -82,6 +82,18 @@ pub struct TuiMcpManager { } impl TuiMcpManager { + /// Creates an empty MCP aggregate for frontend tests. + #[cfg(any(test, feature = "test-util"))] + pub(crate) fn new_for_test(_ctx: &mut ModelContext) -> Self { + Self { + snapshot: TuiMcpSnapshot { + config_path: PathBuf::new(), + config_state: TuiMcpConfigState::Missing, + servers: Vec::new(), + }, + } + } + pub fn new(ctx: &mut ModelContext) -> Self { ctx.subscribe_to_model(&FileBasedMCPManager::handle(ctx), |me, _, _, ctx| { me.refresh(ctx); diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index a74ed383360..9d5108c6b48 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,12 +2,20 @@ pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; +#[cfg(any(test, feature = "test-util"))] +use ai::api_keys::ApiKeyManager; +#[cfg(any(test, feature = "test-util"))] +use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; +#[cfg(any(test, feature = "test-util"))] +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ AIConversation, AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, @@ -56,6 +64,12 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::orchestration_events::OrchestrationEventService; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, @@ -64,6 +78,10 @@ pub use crate::ai::blocklist::{ PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::{BlocklistAIPermissions, QueuedQueryModel}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::cloud_agent_settings::CloudAgentSettings; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -71,12 +89,16 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, }; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -89,21 +111,39 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::auth_manager::AuthManager; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; +#[cfg(any(test, feature = "test-util"))] +use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; +#[cfg(any(test, feature = "test-util"))] +use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +#[cfg(any(test, feature = "test-util"))] +use crate::server::server_api::ServerApiProvider; +#[cfg(any(test, feature = "test-util"))] +use crate::server::sync_queue::SyncQueue; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; +#[cfg(any(test, feature = "test-util"))] +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ prepare_conversation_block_restoration, ConversationBlockRestorationPlan, @@ -162,8 +202,14 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; +#[cfg(any(test, feature = "test-util"))] +use crate::user_config::WarpConfig; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; +#[cfg(any(test, feature = "test-util"))] +use crate::workspaces::user_workspaces::UserWorkspaces; +#[cfg(any(test, feature = "test-util"))] +use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -223,3 +269,119 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } + +/// Registers the minimal singleton set needed to construct, render, and +/// accept the TUI orchestration (`RunAgents`) card against real app models: +/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the +/// auth/server/cloud-object singletons the catalog models read, and the +/// catalog + permission models the card's snapshot builders and accept-path +/// permission checks use. Intended for `warp_tui` tests (via the `test-util` +/// feature) and this crate's own unit tests. Registration order matters: +/// each model subscribes to singletons registered before it. +#[cfg(any(test, feature = "test-util"))] +pub fn register_orchestration_test_singletons(app: &mut warpui::App) { + // Settings machinery required by CloudAgentSettings/AISettings reads. + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); + app.update(init_and_register_user_preferences); + app.add_singleton_model(|_| SettingsManager::default()); + app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + // No-op secure storage backs ApiKeyManager in tests. + warpui_extras::secure_storage::register_noop("test", ctx); + }); + app.update(AISettings::register_and_subscribe_to_events); + CloudAgentSettings::register(app); + // Secure-storage-backed; LLMPreferences subscribes to it. + app.add_singleton_model(ApiKeyManager::new); + + // Auth / server / cloud-object singletons the catalog models read. + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(|ctx| { + // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), + // so back the mock with the test ServerApi's clients instead. + let (team_client, workspace_client) = { + let provider = ServerApiProvider::as_ref(ctx); + (provider.get_team_client(), provider.get_workspace_client()) + }; + UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) + }); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| crate::appearance::Appearance::mock()); + + // Catalog + permission singletons read by the card's construction, + // snapshot builders, and accept path. + app.add_singleton_model(|_| TemplatableMCPServerManager::default()); + app.add_singleton_model(LLMPreferences::new); + app.add_singleton_model(HarnessAvailabilityModel::new); + app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); + app.add_singleton_model(BlocklistAIPermissions::new); + app.add_singleton_model(|ctx| { + AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) + }); + // Plan publication during the accept path reads the document model. + app.add_singleton_model(|_| { + crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() + }); +} + +/// Registers the singleton set needed to construct a full TUI session view's +/// AI stack in tests, on top of +/// [`register_orchestration_test_singletons`]. Registration order matters: +/// each model subscribes to singletons registered before it. +#[cfg(any(test, feature = "test-util"))] +pub fn register_tui_session_test_singletons(app: &mut warpui::App) { + register_orchestration_test_singletons(app); + app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); + // QueuedQueryModel subscribes to history events; register after the + // history model is in place. + app.add_singleton_model(QueuedQueryModel::new); + app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + app.add_singleton_model(OrchestrationEventService::new); + app.add_singleton_model(LocalAgentTaskSyncModel::new); + app.add_singleton_model(OrchestrationEventStreamer::new); + app.add_singleton_model(|_| ActiveAgentViewsModel::new()); + app.add_singleton_model(|_| GitRepoModels::new()); + app.add_singleton_model(|ctx| { + CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) + }); + app.add_singleton_model(AgentConversationsModel::new); +} + +/// [`register_tui_session_test_singletons`] plus the remaining singletons a +/// full `TuiTerminalSessionView` subscribes to. +#[cfg(any(test, feature = "test-util"))] +pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { + register_tui_session_test_singletons(app); + app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); + app.add_singleton_model(|ctx| { + crate::changelog_model::ChangelogModel::new(ServerApiProvider::as_ref(ctx).get()) + }); + app.add_singleton_model(|_| ai::project_context::model::ProjectContextModel::default()); + // The TUI auto-updater (which the session view subscribes to) reads its + // enablement setting at registration. + app.update(crate::settings::TuiAutoupdateSettings::register); + // Settings groups the editor-backed input view and transcript read. + app.update(crate::settings::CodeSettings::register); + app.update(crate::settings::FontSettings::register); + app.update(crate::settings::InputSettings::register); + app.update(crate::settings::InputModeSettings::register); + app.update(crate::settings::SelectionSettings::register); + app.update(crate::settings::ScrollSettings::register); + app.update(crate::settings::EmacsBindingsSettings::register); + app.update(crate::terminal::general_settings::GeneralSettings::register); + // Filesystem-watcher singletons the workflow/skill sources read. + app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default()); + app.add_singleton_model(watcher::HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(repo_metadata::watcher::DirectoryWatcher::new); + #[cfg(feature = "local_fs")] + app.add_singleton_model(repo_metadata::RepoMetadataModel::new); + app.add_singleton_model( + crate::warp_managed_paths_watcher::WarpManagedPathsWatcher::new_for_testing, + ); + app.add_singleton_model(crate::workflows::local_workflows::LocalWorkflows::new); + app.add_singleton_model(crate::ai::skills::SkillManager::new); +} diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index b410f026290..5d22b60794c 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,7 +107,9 @@ pub struct WarpConfig { /// Additional platform-dependent functionality can be found in impl blocks /// in native.rs and wasm.rs. impl WarpConfig { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self { theme_config: WarpThemeConfig::new(), diff --git a/app/src/warp_managed_paths_watcher.rs b/app/src/warp_managed_paths_watcher.rs index cb20e062b2e..eaae43a09ad 100644 --- a/app/src/warp_managed_paths_watcher.rs +++ b/app/src/warp_managed_paths_watcher.rs @@ -241,7 +241,7 @@ impl WarpManagedPathsWatcher { Self::new_internal(ctx, true) } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub(crate) fn new_for_testing(ctx: &mut ModelContext) -> Self { Self::new_internal(ctx, false) } diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 0bd9292f212..2f24191f015 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -122,7 +122,7 @@ pub struct CreateTeamResponse { } impl UserWorkspaces { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn mock( team_client: Arc, workspace_client: Arc, diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 0887bb4858e..b63b5263b17 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -35,6 +35,7 @@ mod option_selector; mod orchestrated_agent_identity_styling; mod orchestration_block; mod resume; +mod sessions; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index 224e4280cc6..deade2556ad 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -1,50 +1,31 @@ //! [`RootTuiView`]: the login-gated root view of the `warp-tui` front-end. -use warp::tui_export::TerminalSurfaceInit; use warp::{TuiLoginModel, TuiLoginPhase}; +use warpui::SingletonEntity as _; use warpui_core::elements::tui::{TuiChildView, TuiElement}; use warpui_core::keymap::macros::*; use warpui_core::keymap::FixedBinding; use warpui_core::platform::TerminationMode; use warpui_core::{ - keymap, AppContext, Entity, EntityId, SingletonEntity, TuiView, TypedActionView, ViewContext, - ViewHandle, + keymap, AppContext, Entity, EntityId, TuiView, TypedActionView, ViewContext, ViewHandle, }; use crate::keybindings::TUI_BINDING_GROUP; -use crate::resume::TuiExitSummaryHandle; +use crate::sessions::TuiSessions; use crate::terminal_session_view::TuiTerminalSessionView; use crate::ui::{login_failed, login_placeholder, terminal_starting}; -/// Whether the authenticated terminal session has been created yet. Mirrors the -/// GUI root view's `AuthOnboardingState` split between the pre-session login gate -/// and the live terminal session. -enum RootTuiState { - /// Login gate: no terminal session exists yet. The placeholder shown is - /// chosen from the current [`TuiLoginPhase`]. - Auth, - /// The authenticated terminal session. - Terminal(ViewHandle), -} - /// Typed actions handled by [`RootTuiView`]. #[derive(Debug, Clone)] pub enum RootTuiAction { - /// Exit the app. Bound to ctrl-c in the root's keymap context; the - /// terminal session's deeper `Interrupt` binding wins while a session - /// exists, so this fires only on the pre-session placeholders (which say - /// "Press Ctrl-C to exit") — keeping the app exitable in every state. + /// Exits the app while no terminal session is focused. ExitApp, } -/// The app-level TUI shell. It gates the authenticated terminal session on login state. -pub struct RootTuiView { - state: RootTuiState, - exit_summary: TuiExitSummaryHandle, -} +/// The app-level TUI shell, projecting only the focused full session view. +pub struct RootTuiView; -/// Registers the root view's keybindings. Called once at TUI startup from -/// `keybindings::init`. +/// Registers the root view's keybindings. pub fn init(app: &mut AppContext) { app.register_fixed_bindings([FixedBinding::new( "ctrl-c", @@ -55,29 +36,19 @@ pub fn init(app: &mut AppContext) { } impl RootTuiView { - pub(crate) fn new(exit_summary: TuiExitSummaryHandle) -> Self { - Self { - state: RootTuiState::Auth, - exit_summary, - } + /// Creates the login-gated root view. + pub(crate) fn new() -> Self { + Self } - /// Creates the terminal child view once login has completed, or returns the - /// existing one if it was already created. Callers notify the root so it - /// re-renders from the login placeholder to the terminal session. - pub(crate) fn create_terminal_session( - &mut self, - surface_init: TerminalSurfaceInit, - ctx: &mut ViewContext, - ) -> ViewHandle { - if let RootTuiState::Terminal(terminal_session) = &self.state { - return terminal_session.clone(); + + fn focused_session_view(&self, ctx: &AppContext) -> Option> { + if !ctx.has_singleton_model::() { + return None; } - let exit_summary = self.exit_summary.clone(); - let terminal_session = ctx.add_typed_action_tui_view(|ctx| { - TuiTerminalSessionView::new(surface_init, exit_summary, ctx) - }); - self.state = RootTuiState::Terminal(terminal_session.clone()); - terminal_session + + TuiSessions::as_ref(ctx) + .focused_session() + .map(|session| session.view().clone()) } } @@ -90,21 +61,16 @@ impl TuiView for RootTuiView { "RootTuiView" } - fn child_view_ids(&self, _ctx: &AppContext) -> Vec { - // The TUI runtime uses this for child focus and event routing; only the - // live terminal session participates. - match &self.state { - RootTuiState::Terminal(terminal_session) => vec![terminal_session.id()], - RootTuiState::Auth => Vec::new(), - } + fn child_view_ids(&self, ctx: &AppContext) -> Vec { + self.focused_session_view(ctx) + .map(|view| vec![view.id()]) + .unwrap_or_default() } fn render(&self, ctx: &AppContext) -> Box { - match &self.state { - RootTuiState::Terminal(terminal_session) => { - TuiChildView::new(terminal_session).finish() - } - RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() { + match self.focused_session_view(ctx) { + Some(view) => TuiChildView::new(&view).finish(), + None => match TuiLoginModel::as_ref(ctx).phase() { TuiLoginPhase::LoggedIn => terminal_starting(), TuiLoginPhase::AwaitingLogin { verification_uri, @@ -116,7 +82,6 @@ impl TuiView for RootTuiView { } fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context { - // Propagate focus context into the input view so keystrokes reach it. let mut context = keymap::Context::default(); context.set.insert("RootTuiView"); context @@ -132,3 +97,7 @@ impl TypedActionView for RootTuiView { } } } + +#[cfg(test)] +#[path = "root_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs new file mode 100644 index 00000000000..ac60fd73b5f --- /dev/null +++ b/crates/warp_tui/src/root_view_tests.rs @@ -0,0 +1,67 @@ +use warp::tui_export::register_tui_session_view_test_singletons; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, UpdateModel}; +use warpui_core::{App, TuiView as _, WindowId}; + +use super::RootTuiView; +use crate::sessions::TuiSessions; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; + +fn add_root(app: &mut App) -> (WindowId, warpui_core::ViewHandle) { + app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }) +} + +#[test] +fn root_projects_only_the_focused_retained_session_view() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + add_test_semantic_selection(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let (window_id, root) = add_root(&mut app); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + root.update(&mut app, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + }); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + let second_view_id = second.id(); + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + }); + let focused_window_view = app.read(|ctx| ctx.focused_view_id(window_id)); + + let second_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(second, second_manager, false, ctx) + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert_eq!(ctx.focused_view_id(window_id), focused_window_view); + }); + + app.update_model(&sessions, |sessions, ctx| { + sessions.focus_session(second_id, ctx); + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![second_view_id]); + }); + app.update_model(&sessions, |sessions, ctx| { + sessions.focus_session(first_id, ctx); + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + }); + }); +} diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index f60ef944e9d..d18bb311b2f 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -2,7 +2,7 @@ //! //! [`run`] boots the real headless Warp app via [`warp::run_tui`]. Once shared //! initialization is done, the mount built here starts the TUI driver and -//! defers creating the transcript-capable terminal session until login. +//! defers creating the first terminal session until login. use std::collections::HashMap; use std::ffi::OsString; @@ -13,18 +13,19 @@ use clap::Parser; use pathfinder_geometry::vector::Vector2F; use warp::tui_export::{ Appearance, BannerState, IsSharedSessionCreator, LocalTtyTerminalManager, - ServerConversationToken, TerminalManagerTrait, TerminalSurfaceResult, + ServerConversationToken, TerminalSurfaceResult, }; use warp::{TuiLoginEvent, TuiLoginModel, TuiLoginPhase}; use warp_core::telemetry::TelemetryEvent as _; use warp_errors::report_error; -use warpui::SingletonEntity; +use warpui::SingletonEntity as _; use warpui_core::platform::{TerminationMode, WindowStyle}; -use warpui_core::runtime::{spawn_tui_driver, TuiDriverHandle}; -use warpui_core::{AddWindowOptions, AppContext, Entity, ModelHandle, ViewHandle}; +use warpui_core::runtime::spawn_tui_driver; +use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; +use crate::sessions::TuiSessions; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -51,20 +52,6 @@ fn parse_resume_token(token: String) -> Result { Ok(ServerConversationToken::new(token)) } -/// Holds the live TUI driver and, after login, the terminal manager. -struct TuiSession { - #[expect(dead_code, reason = "keeps the TUI driver alive for the TUI session")] - driver: TuiDriverHandle, - manager: Option>>, - resume_token: Option, -} - -impl Entity for TuiSession { - type Event = (); -} - -impl SingletonEntity for TuiSession {} - /// Boots the headless Warp app and mounts the transcript-capable TUI session. pub fn run() -> Result<()> { // If this process was re-exec'd as a Warp worker (e.g. the terminal @@ -102,7 +89,7 @@ pub fn run() -> Result<()> { result } -/// Creates the login-gated TUI root and starts the headless draw + input driver. +/// Creates the login-gated root and starts the headless draw and input driver. fn init( resume_token: Option, exit_summary: TuiExitSummaryHandle, @@ -126,37 +113,32 @@ fn init( appearance.set_theme(theme, ctx); }); - let banner = ctx.add_model(|_| BannerState::default()); let (window_id, root) = ctx.add_tui_window( AddWindowOptions { window_style: WindowStyle::NotStealFocus, ..Default::default() }, - |_| RootTuiView::new(exit_summary), + |_| RootTuiView::new(), ); match spawn_tui_driver(ctx, window_id, root.clone()) { Ok(driver) => { - let session = ctx.add_singleton_model(|_| TuiSession { - driver, - manager: None, - resume_token, + let sessions = ctx.add_singleton_model(|_| { + TuiSessions::new(driver, window_id, exit_summary, resume_token) + }); + root.update(ctx, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); }); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { - // Already authenticated at mount: create the session now. - create_terminal_session_after_login(&session, &root, &banner, ctx); + // Already authenticated at mount: create the first session now. + create_terminal_session_after_login(&sessions, ctx); } else { // Otherwise wait for login to complete and create it then. - let session_for_login = session.clone(); - let root_for_login = root.clone(); - let banner_for_login = banner.clone(); + let sessions_for_login = sessions.clone(); let login_model = TuiLoginModel::handle(ctx); ctx.subscribe_to_model(&login_model, move |_, event, ctx| match event { - TuiLoginEvent::LoggedIn => create_terminal_session_after_login( - &session_for_login, - &root_for_login, - &banner_for_login, - ctx, - ), + TuiLoginEvent::LoggedIn => { + create_terminal_session_after_login(&sessions_for_login, ctx) + } }); } } @@ -168,19 +150,15 @@ fn init( } } -/// Creates and retains the terminal manager after login. -fn create_terminal_session_after_login( - session: &ModelHandle, - root: &ViewHandle, - banner: &ModelHandle, - ctx: &mut AppContext, -) { - if session.read(ctx, |session, _| session.manager.is_some()) { +/// Creates the focused bootstrap session after login. +fn create_terminal_session_after_login(sessions: &ModelHandle, ctx: &mut AppContext) { + if sessions.read(ctx, |sessions, _| !sessions.is_empty()) { return; } - let root = root.clone(); - let resume_token = session.read(ctx, |session, _| session.resume_token.clone()); + let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); + let (window_id, exit_summary) = sessions.read(ctx, |sessions, _| sessions.surface_context()); + let banner = ctx.add_model(|_| BannerState::default()); let manager = LocalTtyTerminalManager::::create_tui_model( std::env::current_dir().ok(), HashMap::::from_iter(std::env::vars_os()), @@ -193,11 +171,8 @@ fn create_terminal_session_after_login( TRANSCRIPT_BLOCK_SPACING, ctx, move |surface_init, ctx| { - let surface = root.update(ctx, |root, ctx| { - let surface = root.create_terminal_session(surface_init, ctx); - // Re-render the root so it swaps the login placeholder for the session. - ctx.notify(); - surface + let surface = ctx.add_typed_action_tui_view(window_id, |ctx| { + TuiTerminalSessionView::new(surface_init, exit_summary, ctx) }); TerminalSurfaceResult { surface, @@ -218,10 +193,8 @@ fn create_terminal_session_after_login( }, ); - session.update(ctx, |session, ctx| { - session.manager = Some(manager.manager); - session.resume_token = None; - ctx.notify(); + sessions.update(ctx, |sessions, ctx| { + sessions.add_session(manager.surface, manager.manager, true, ctx); }); } diff --git a/crates/warp_tui/src/sessions.rs b/crates/warp_tui/src/sessions.rs new file mode 100644 index 00000000000..8d19816823e --- /dev/null +++ b/crates/warp_tui/src/sessions.rs @@ -0,0 +1,172 @@ +//! [`TuiSessions`]: the TUI's multi-session container. +//! +//! Every session is a full [`TuiTerminalSessionView`] backed by a retained +//! terminal manager. The container owns session lifetime and focus; the root +//! view renders and routes input only to the focused session. + +use warp::tui_export::{ServerConversationToken, TerminalManagerTrait}; +use warpui::SingletonEntity; +use warpui_core::runtime::TuiDriverHandle; +use warpui_core::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle, WindowId}; + +use crate::resume::TuiExitSummaryHandle; +use crate::terminal_session_view::TuiTerminalSessionView; + +/// Identifies a TUI terminal session. +/// +/// A session and its eagerly-created view have the same lifetime, so the +/// view's entity id is also the terminal surface id used by shared AI models. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct TuiSessionId(EntityId); + +impl TuiSessionId { + /// The raw entity id used at shared-model boundaries. + pub(crate) fn surface_id(self) -> EntityId { + self.0 + } +} + +/// A live TUI session: its full view and the manager retaining its PTY. +pub(crate) struct TuiSession { + id: TuiSessionId, + view: ViewHandle, + /// Retained for the session's lifetime to keep its PTY and event loop alive. + _manager: ModelHandle>, +} + +impl TuiSession { + /// The session's full terminal view. + pub(crate) fn view(&self) -> &ViewHandle { + &self.view + } +} + +/// Events emitted as the session set or focus changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiSessionsEvent { + /// A session was registered, possibly in the background. + SessionAdded(TuiSessionId), + /// The focused session changed to this id. + FocusChanged(TuiSessionId), +} + +/// Owns all live TUI sessions and the focused-session selection. +pub(crate) struct TuiSessions { + /// TUI-specific process driver. Its handle restores terminal mode on + /// drop, so the app-lifetime session singleton must retain it. + _driver: Option, + window_id: WindowId, + exit_summary: TuiExitSummaryHandle, + sessions: Vec, + focused_session_id: Option, + resume_token: Option, +} + +impl Entity for TuiSessions { + type Event = TuiSessionsEvent; +} + +impl SingletonEntity for TuiSessions {} + +impl TuiSessions { + /// Creates the app's session container. + pub(crate) fn new( + driver: TuiDriverHandle, + window_id: WindowId, + exit_summary: TuiExitSummaryHandle, + resume_token: Option, + ) -> Self { + Self { + _driver: Some(driver), + window_id, + exit_summary, + sessions: Vec::new(), + focused_session_id: None, + resume_token, + } + } + + /// Creates a driverless container for unit tests. + #[cfg(test)] + pub(crate) fn new_for_test(window_id: WindowId) -> Self { + Self { + _driver: None, + window_id, + exit_summary: TuiExitSummaryHandle::default(), + sessions: Vec::new(), + focused_session_id: None, + resume_token: None, + } + } + + /// Registers an eagerly-created session view and optionally focuses it. + pub(crate) fn add_session( + &mut self, + view: ViewHandle, + manager: ModelHandle>, + focus: bool, + ctx: &mut ModelContext, + ) -> TuiSessionId { + let id = TuiSessionId(view.id()); + debug_assert!( + self.session(id).is_none(), + "a session must not be registered twice" + ); + self.sessions.push(TuiSession { + id, + view, + _manager: manager, + }); + ctx.emit(TuiSessionsEvent::SessionAdded(id)); + if focus { + self.focus_session(id, ctx); + } + ctx.notify(); + id + } + + /// Returns the window and exit-summary handle used to create session views. + pub(crate) fn surface_context(&self) -> (WindowId, TuiExitSummaryHandle) { + (self.window_id, self.exit_summary.clone()) + } + + /// Focuses a registered session. Returns whether focus changed. + pub(crate) fn focus_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) -> bool { + if self.focused_session_id == Some(id) || self.session(id).is_none() { + return false; + } + self.focused_session_id = Some(id); + ctx.emit(TuiSessionsEvent::FocusChanged(id)); + ctx.notify(); + true + } + + /// The focused session's id. + pub(crate) fn focused_session_id(&self) -> Option { + self.focused_session_id + } + + /// The focused session. + pub(crate) fn focused_session(&self) -> Option<&TuiSession> { + self.focused_session_id.and_then(|id| self.session(id)) + } + + /// Looks up a registered session. + pub(crate) fn session(&self, id: TuiSessionId) -> Option<&TuiSession> { + self.sessions.iter().find(|session| session.id == id) + } + + /// Whether no session has been registered. + pub(crate) fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + /// Consumes the startup resume token. + pub(crate) fn take_resume_token(&mut self) -> Option { + self.resume_token.take() + } +} + +#[cfg(test)] +#[path = "sessions_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/sessions_tests.rs b/crates/warp_tui/src/sessions_tests.rs new file mode 100644 index 00000000000..dd44830d2f5 --- /dev/null +++ b/crates/warp_tui/src/sessions_tests.rs @@ -0,0 +1,92 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use warp::tui_export::register_tui_session_view_test_singletons; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ReadModel, SingletonEntity as _, UpdateModel}; +use warpui_core::App; + +use super::{TuiSessions, TuiSessionsEvent}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session, TestHostView}; + +type CapturedEvents = Rc>>; + +fn capture_events(app: &mut App) -> CapturedEvents { + let events: CapturedEvents = Rc::new(RefCell::new(Vec::new())); + let captured = events.clone(); + app.update(|ctx| { + let sessions = TuiSessions::handle(ctx); + ctx.subscribe_to_model(&sessions, move |_, event, _| { + captured.borrow_mut().push(*event); + }); + }); + events +} + +#[test] +fn add_and_focus_drive_events() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + add_test_semantic_selection(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let window_id = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ) + .0 + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + let events = capture_events(&mut app); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + let second_view_id = second.id(); + + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + assert_eq!(first_id.surface_id(), first_view_id); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![ + TuiSessionsEvent::SessionAdded(first_id), + TuiSessionsEvent::FocusChanged(first_id), + ], + ); + + let second_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(second, second_manager, false, ctx) + }); + assert_eq!(second_id.surface_id(), second_view_id); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::SessionAdded(second_id)], + ); + assert_eq!( + app.read_model(&sessions, |sessions, _| sessions.focused_session_id()), + Some(first_id), + ); + + app.update_model(&sessions, |sessions, ctx| { + assert!(sessions.focus_session(second_id, ctx)); + assert!(!sessions.focus_session(second_id, ctx)); + }); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::FocusChanged(second_id)], + ); + + app.update_model(&sessions, |sessions, ctx| { + assert!(sessions.focus_session(first_id, ctx)); + }); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::FocusChanged(first_id)], + ); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 9c9ffb199d8..4bcd2510e6b 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -66,6 +66,7 @@ use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::orchestration_block::TuiOrchestrationBlock; use crate::resume::TuiExitSummaryHandle; +use crate::sessions::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::terminal_content_element::TuiTerminalContentElement; @@ -317,6 +318,9 @@ impl TuiTerminalSessionView { } fn update_process_input_focus(&mut self, ctx: &mut ViewContext) { + if !self.is_focused_session(ctx) { + return; + } if self.process_owns_input() { if !ctx.is_self_focused() { ctx.focus_self(); @@ -985,7 +989,16 @@ impl TuiTerminalSessionView { // Focus the input view so the keymap responder chain is // [root, session, input]: input bindings win for keys they define, // and unbound keys (ctrl-c) fall through to the session/root bindings. - ctx.focus(&input_view); + // Background session views (e.g. orchestration children) must not + // steal window focus from the focused session at construction. + let is_focused_session = !ctx.has_singleton_model::() + || TuiSessions::as_ref(ctx) + .focused_session_id() + .is_none_or(|id| id.surface_id() == terminal_surface_id); + + if is_focused_session { + ctx.focus(&input_view); + } Self { transcript, @@ -1027,6 +1040,16 @@ impl TuiTerminalSessionView { self.transcript.as_ref(ctx).active_blocking_child(ctx) } + /// Whether this view projects the focused session. Background session + /// views must not claim window focus or write the exit summary. Absent + /// container state (unit tests) counts as focused. + fn is_focused_session(&self, ctx: &AppContext) -> bool { + !ctx.has_singleton_model::() + || TuiSessions::as_ref(ctx) + .focused_session_id() + .is_none_or(|id| id.surface_id() == self.terminal_surface_id) + } + /// Reconciles focus with the derived blocker: a newly active blocker is /// focused (handing off directly between consecutive blockers with no /// intermediate editable input), and focus returns to the input when the @@ -1038,8 +1061,10 @@ impl TuiTerminalSessionView { if blocker_view_id != self.active_blocker_view_id { // A foreground process owns the rendered pane and keyboard. Defer // both the focus handoff and its completion marker until the - // process releases input, so the transition remains retryable. - if !self.process_owns_input() { + // process releases input. Background-session blockers likewise + // remain pending rather than stealing focus from the foreground + // session, so either transition remains retryable. + if !self.process_owns_input() && self.is_focused_session(ctx) { match &blocker { Some(child) => ctx.focus(child), None => ctx.focus(&self.input_view), @@ -1277,6 +1302,11 @@ impl TuiTerminalSessionView { } fn refresh_exit_summary(&self, ctx: &AppContext) { + // The exit summary's resume hint tracks the focused session only; + // background children must not overwrite it with their own tokens. + if !self.is_focused_session(ctx) { + return; + } let token = self .conversation_selection .as_ref(ctx) diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index ac4d793d6ad..c6fc8683024 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -1,15 +1,35 @@ //! Shared fixtures for `warp_tui` unit tests. +use std::any::Any; use std::sync::Arc; use parking_lot::FairMutex; use warp::tui_export::{ ActiveSession, BlocklistAIActionModel, BlocklistAIHistoryModel, GetRelevantFilesController, - ModelEventDispatcher, Sessions, TerminalModel, + ModelEventDispatcher, Sessions, TerminalManagerTrait, TerminalModel, TerminalSurfaceInit, }; use warp_core::semantic_selection::SemanticSelection; -use warpui::{AddSingletonModel, App, EntityId, ModelHandle}; +use warpui::{AddSingletonModel, App, EntityId, ModelHandle, SingletonEntity as _}; use warpui_core::elements::tui::{TuiElement, TuiText}; -use warpui_core::{AppContext, Entity, TuiView, TypedActionView}; +use warpui_core::{AppContext, Entity, TuiView, TypedActionView, ViewHandle, WindowId}; + +use crate::resume::TuiExitSummaryHandle; +use crate::terminal_session_view::TuiTerminalSessionView; + +struct TestTerminalManager(Arc>); + +impl TerminalManagerTrait for TestTerminalManager { + fn model(&self) -> Arc> { + self.0.clone() + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} /// A trivial typed-action root view for tests that need a TUI window whose /// real subject is a non-root child view. @@ -48,6 +68,21 @@ pub(crate) fn add_test_action_model_and_events( ) -> ( ModelHandle, ModelHandle, +) { + let (action_model, dispatcher, _) = add_test_action_model_with_surface(app); + (action_model, dispatcher) +} + +/// [`add_test_action_model_and_events`] plus the terminal-surface id the +/// action model was built with, so tests can register an active conversation +/// for that surface in the history model (required by +/// `BlocklistAIActionModel::get_pending_action`). +pub(crate) fn add_test_action_model_with_surface( + app: &mut App, +) -> ( + ModelHandle, + ModelHandle, + EntityId, ) { add_test_semantic_selection(app); // Read as a singleton by the action model's executors. @@ -73,5 +108,45 @@ pub(crate) fn add_test_action_model_and_events( ctx, ) }); - (action_model, dispatcher) + (action_model, dispatcher, terminal_surface_id) +} + +/// Builds a full session view against mock terminal plumbing. +pub(crate) fn add_test_terminal_session( + app: &mut App, + window_id: WindowId, +) -> ( + ViewHandle, + ModelHandle>, +) { + app.update(|ctx| { + let surface_init = TerminalSurfaceInit::new_for_test(ctx); + let terminal_model = surface_init.model.clone(); + let view = ctx.add_typed_action_tui_view(window_id, |ctx| { + TuiTerminalSessionView::new(surface_init, TuiExitSummaryHandle::default(), ctx) + }); + let manager = ctx.add_model(|_| { + Box::new(TestTerminalManager(terminal_model)) as Box + }); + (view, manager) + }) +} + +/// Creates a live, active conversation for `terminal_surface_id` in the +/// history model, returning its id. Combined with +/// `BlocklistAIActionModel::queue_pending_action_for_test`, this drives an +/// action into `Blocked` status for confirmation-flow tests. +#[allow(dead_code)] +pub(crate) fn add_active_test_conversation( + app: &mut App, + terminal_surface_id: EntityId, +) -> warp::tui_export::AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = + history.start_new_conversation(terminal_surface_id, false, false, false, ctx); + history.set_active_conversation_id(conversation_id, terminal_surface_id, ctx); + conversation_id + }) + }) } diff --git a/specs/code-1822-tui-multi-session/TECH.md b/specs/code-1822-tui-multi-session/TECH.md new file mode 100644 index 00000000000..2543cea45bf --- /dev/null +++ b/specs/code-1822-tui-multi-session/TECH.md @@ -0,0 +1,71 @@ +# TECH: `TuiSessions` full-view multi-session container + +First PR in the TUI local-orchestration stack above the orchestration-card +branch. The follow-up adds `TuiOrchestrationModel` and materializes native +local children as background sessions. + +## Context + +The TUI currently retains one terminal manager in a singleton and one +`TuiTerminalSessionView` in `RootTuiView`. The shared AI layer already supports +multiple surfaces keyed by `terminal_surface_id`, but the TUI has no container +for multiple live terminal surfaces or a model-level notion of focus. + +Every TUI session needs a full view. `LocalTtyTerminalManager` requires a +terminal surface synchronously, and background orchestration children use the +same view-backed terminal machinery as the focused session. This follows the +GUI's hidden-pane model: background sessions retain complete views, while only +the focused view participates in rendering and input routing. + +## Proposed changes + +### New: `crates/warp_tui/src/sessions.rs` + +- Add `TuiSessionId(EntityId)`, using the eagerly-created view's entity id as + both session identity and shared-model `terminal_surface_id`. +- Add `TuiSessions`, a singleton retaining each session's + `TuiTerminalSessionView` and terminal manager (the manager kept only to tie + the PTY and event loop to the session's lifetime), plus the window and exit + summary context needed to construct additional session views. +- Track `focused_session_id` and emit `SessionAdded` and `FocusChanged` + events. All session creation paths register here so orchestration can + subscribe to every session, including future nested children. +- Session removal (with a `SessionRemoved` event and focus fallback) lands + with the orchestration PR that first needs it. + +### Changed: `crates/warp_tui/src/root_view.rs` + +- Replace the single authenticated child with projection of + `TuiSessions::focused_session()`. +- Subscribe to session events for redraws. +- Session creation does not flow through the root; it only projects sessions. +- Return only the focused view from `child_view_ids()`, keeping background + views out of rendering and the responder chain. + +### Changed: `crates/warp_tui/src/session.rs` + +- Replace the single-session singleton with `TuiSessions`. +- Create the full `TuiTerminalSessionView` synchronously inside the terminal + manager's surface callback, then register the view and its returned manager + with `TuiSessions` so the container owns the session lifetime. +- The login bootstrap registers the first session focused. + +### Changed: `crates/warp_tui/src/terminal_session_view.rs` + +- Guard constructor focus, blocker-driven focus changes, and exit-summary + updates so background views cannot steal focus or replace the focused + session's resume token. + +## Non-goals + +- Session navigation UI or keybindings. +- Session persistence; `TuiSessionId` is process-local. +- Remote or CLI-harness child materialization. + +## Testing and validation + +- Unit-test add/focus behavior and event emission on `TuiSessions`. +- Construct two full session views and verify the root projects only the + focused view, background registration does not steal focus, and focus + changes reuse retained views. +- Run `cargo nextest run -p warp_tui --no-fail-fast` and `./script/format`. \ No newline at end of file From 5cbddf92e170c6f5f6adacf922cb1194f9686587 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 11:29:09 -0400 Subject: [PATCH 26/40] address comments --- app/src/lib.rs | 2 + app/src/tui_export.rs | 162 +----------------- app/src/tui_test_support.rs | 114 ++++++++++++ crates/warp_tui/src/lib.rs | 2 +- crates/warp_tui/src/root_view.rs | 2 +- crates/warp_tui/src/root_view_tests.rs | 11 +- crates/warp_tui/src/session.rs | 2 +- .../src/{sessions.rs => session_registry.rs} | 10 +- ...ons_tests.rs => session_registry_tests.rs} | 20 ++- crates/warp_tui/src/terminal_session_view.rs | 94 +++++----- specs/code-1822-tui-multi-session/TECH.md | 11 +- 11 files changed, 202 insertions(+), 228 deletions(-) create mode 100644 app/src/tui_test_support.rs rename crates/warp_tui/src/{sessions.rs => session_registry.rs} (94%) rename crates/warp_tui/src/{sessions_tests.rs => session_registry_tests.rs} (81%) diff --git a/app/src/lib.rs b/app/src/lib.rs index c1fd2ab19ec..dc5341211b8 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -86,6 +86,8 @@ mod tracing; mod tui; #[cfg(feature = "tui")] pub mod tui_export; +#[cfg(all(feature = "tui", any(test, feature = "test-util")))] +mod tui_test_support; mod ui_components; mod undo_close; mod uri; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 9d5108c6b48..79f53709438 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,20 +2,12 @@ pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; -#[cfg(any(test, feature = "test-util"))] -use ai::api_keys::ApiKeyManager; -#[cfg(any(test, feature = "test-util"))] -use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; -#[cfg(any(test, feature = "test-util"))] -use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ AIConversation, AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, @@ -64,12 +56,6 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::orchestration_events::OrchestrationEventService; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, @@ -78,10 +64,6 @@ pub use crate::ai::blocklist::{ PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::{BlocklistAIPermissions, QueuedQueryModel}; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::cloud_agent_settings::CloudAgentSettings; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -89,16 +71,12 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, }; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -111,39 +89,21 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::auth_manager::AuthManager; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; -#[cfg(any(test, feature = "test-util"))] -use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; -#[cfg(any(test, feature = "test-util"))] -use crate::server::server_api::ServerApiProvider; -#[cfg(any(test, feature = "test-util"))] -use crate::server::sync_queue::SyncQueue; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; -#[cfg(any(test, feature = "test-util"))] -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ prepare_conversation_block_restoration, ConversationBlockRestorationPlan, @@ -203,13 +163,9 @@ pub use crate::tui::{ TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; #[cfg(any(test, feature = "test-util"))] -use crate::user_config::WarpConfig; +pub use crate::tui_test_support::register_tui_session_view_test_singletons; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; -#[cfg(any(test, feature = "test-util"))] -use crate::workspaces::user_workspaces::UserWorkspaces; -#[cfg(any(test, feature = "test-util"))] -use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -269,119 +225,3 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } - -/// Registers the minimal singleton set needed to construct, render, and -/// accept the TUI orchestration (`RunAgents`) card against real app models: -/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the -/// auth/server/cloud-object singletons the catalog models read, and the -/// catalog + permission models the card's snapshot builders and accept-path -/// permission checks use. Intended for `warp_tui` tests (via the `test-util` -/// feature) and this crate's own unit tests. Registration order matters: -/// each model subscribes to singletons registered before it. -#[cfg(any(test, feature = "test-util"))] -pub fn register_orchestration_test_singletons(app: &mut warpui::App) { - // Settings machinery required by CloudAgentSettings/AISettings reads. - app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); - app.update(init_and_register_user_preferences); - app.add_singleton_model(|_| SettingsManager::default()); - app.add_singleton_model(WarpConfig::mock); - app.update(|ctx| { - // No-op secure storage backs ApiKeyManager in tests. - warpui_extras::secure_storage::register_noop("test", ctx); - }); - app.update(AISettings::register_and_subscribe_to_events); - CloudAgentSettings::register(app); - // Secure-storage-backed; LLMPreferences subscribes to it. - app.add_singleton_model(ApiKeyManager::new); - - // Auth / server / cloud-object singletons the catalog models read. - app.add_singleton_model(|_| NetworkStatus::new()); - app.add_singleton_model(|_| ServerApiProvider::new_for_test()); - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - app.add_singleton_model(AuthManager::new_for_test); - app.add_singleton_model(|ctx| { - // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), - // so back the mock with the test ServerApi's clients instead. - let (team_client, workspace_client) = { - let provider = ServerApiProvider::as_ref(ctx); - (provider.get_team_client(), provider.get_workspace_client()) - }; - UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) - }); - app.add_singleton_model(SyncQueue::mock); - app.add_singleton_model(CloudModel::mock); - app.add_singleton_model(|_| crate::appearance::Appearance::mock()); - - // Catalog + permission singletons read by the card's construction, - // snapshot builders, and accept path. - app.add_singleton_model(|_| TemplatableMCPServerManager::default()); - app.add_singleton_model(LLMPreferences::new); - app.add_singleton_model(HarnessAvailabilityModel::new); - app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); - app.add_singleton_model(BlocklistAIPermissions::new); - app.add_singleton_model(|ctx| { - AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) - }); - // Plan publication during the accept path reads the document model. - app.add_singleton_model(|_| { - crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() - }); -} - -/// Registers the singleton set needed to construct a full TUI session view's -/// AI stack in tests, on top of -/// [`register_orchestration_test_singletons`]. Registration order matters: -/// each model subscribes to singletons registered before it. -#[cfg(any(test, feature = "test-util"))] -pub fn register_tui_session_test_singletons(app: &mut warpui::App) { - register_orchestration_test_singletons(app); - app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); - // QueuedQueryModel subscribes to history events; register after the - // history model is in place. - app.add_singleton_model(QueuedQueryModel::new); - app.add_singleton_model(|_| CLIAgentSessionsModel::new()); - app.add_singleton_model(OrchestrationEventService::new); - app.add_singleton_model(LocalAgentTaskSyncModel::new); - app.add_singleton_model(OrchestrationEventStreamer::new); - app.add_singleton_model(|_| ActiveAgentViewsModel::new()); - app.add_singleton_model(|_| GitRepoModels::new()); - app.add_singleton_model(|ctx| { - CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) - }); - app.add_singleton_model(AgentConversationsModel::new); -} - -/// [`register_tui_session_test_singletons`] plus the remaining singletons a -/// full `TuiTerminalSessionView` subscribes to. -#[cfg(any(test, feature = "test-util"))] -pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { - register_tui_session_test_singletons(app); - app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); - app.add_singleton_model(|ctx| { - crate::changelog_model::ChangelogModel::new(ServerApiProvider::as_ref(ctx).get()) - }); - app.add_singleton_model(|_| ai::project_context::model::ProjectContextModel::default()); - // The TUI auto-updater (which the session view subscribes to) reads its - // enablement setting at registration. - app.update(crate::settings::TuiAutoupdateSettings::register); - // Settings groups the editor-backed input view and transcript read. - app.update(crate::settings::CodeSettings::register); - app.update(crate::settings::FontSettings::register); - app.update(crate::settings::InputSettings::register); - app.update(crate::settings::InputModeSettings::register); - app.update(crate::settings::SelectionSettings::register); - app.update(crate::settings::ScrollSettings::register); - app.update(crate::settings::EmacsBindingsSettings::register); - app.update(crate::terminal::general_settings::GeneralSettings::register); - // Filesystem-watcher singletons the workflow/skill sources read. - app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default()); - app.add_singleton_model(watcher::HomeDirectoryWatcher::new_for_test); - app.add_singleton_model(repo_metadata::watcher::DirectoryWatcher::new); - #[cfg(feature = "local_fs")] - app.add_singleton_model(repo_metadata::RepoMetadataModel::new); - app.add_singleton_model( - crate::warp_managed_paths_watcher::WarpManagedPathsWatcher::new_for_testing, - ); - app.add_singleton_model(crate::workflows::local_workflows::LocalWorkflows::new); - app.add_singleton_model(crate::ai::skills::SkillManager::new); -} diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs new file mode 100644 index 00000000000..5819810fbd8 --- /dev/null +++ b/app/src/tui_test_support.rs @@ -0,0 +1,114 @@ +//! Test-only app initialization used by the external `warp_tui` crate. + +use ai::api_keys::ApiKeyManager; +use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; +use warpui::SingletonEntity as _; + +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; +use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +use crate::ai::blocklist::orchestration_events::OrchestrationEventService; +use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions, QueuedQueryModel}; +use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +use crate::ai::harness_availability::HarnessAvailabilityModel; +use crate::ai::llms::LLMPreferences; +use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; +use crate::auth::auth_manager::AuthManager; +use crate::auth::AuthStateProvider; +use crate::cloud_object::model::persistence::CloudModel; +use crate::code_review::git_repo_model::GitRepoModels; +use crate::network::NetworkStatus; +use crate::server::server_api::ServerApiProvider; +use crate::server::sync_queue::SyncQueue; +use crate::settings::manager::SettingsManager; +use crate::settings::{init_and_register_user_preferences, AISettings}; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::user_config::WarpConfig; +use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::LaunchMode; + +/// Registers the app models required to construct full TUI session views in tests. +/// +/// Registration order mirrors model subscription dependencies. +pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); + app.update(init_and_register_user_preferences); + app.add_singleton_model(|_| SettingsManager::default()); + app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + warpui_extras::secure_storage::register_noop("test", ctx); + }); + app.update(AISettings::register_and_subscribe_to_events); + CloudAgentSettings::register(app); + app.add_singleton_model(ApiKeyManager::new); + + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(|ctx| { + let (team_client, workspace_client) = { + let provider = ServerApiProvider::as_ref(ctx); + (provider.get_team_client(), provider.get_workspace_client()) + }; + UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) + }); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| crate::appearance::Appearance::mock()); + + app.add_singleton_model(|_| TemplatableMCPServerManager::default()); + app.add_singleton_model(LLMPreferences::new); + app.add_singleton_model(HarnessAvailabilityModel::new); + app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); + app.add_singleton_model(BlocklistAIPermissions::new); + app.add_singleton_model(|ctx| { + AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) + }); + app.add_singleton_model(|_| { + crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() + }); + + app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); + app.add_singleton_model(QueuedQueryModel::new); + app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + app.add_singleton_model(OrchestrationEventService::new); + app.add_singleton_model(LocalAgentTaskSyncModel::new); + app.add_singleton_model(OrchestrationEventStreamer::new); + app.add_singleton_model(|_| ActiveAgentViewsModel::new()); + app.add_singleton_model(|_| GitRepoModels::new()); + app.add_singleton_model(|ctx| { + CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) + }); + app.add_singleton_model(AgentConversationsModel::new); + + app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); + app.add_singleton_model(|ctx| { + crate::changelog_model::ChangelogModel::new(ServerApiProvider::as_ref(ctx).get()) + }); + app.add_singleton_model(|_| ai::project_context::model::ProjectContextModel::default()); + app.update(crate::settings::TuiAutoupdateSettings::register); + app.update(crate::settings::CodeSettings::register); + app.update(crate::settings::FontSettings::register); + app.update(crate::settings::InputSettings::register); + app.update(crate::settings::InputModeSettings::register); + app.update(crate::settings::SelectionSettings::register); + app.update(crate::settings::ScrollSettings::register); + app.update(crate::settings::EmacsBindingsSettings::register); + app.update(crate::terminal::general_settings::GeneralSettings::register); + + app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default()); + app.add_singleton_model(watcher::HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(repo_metadata::watcher::DirectoryWatcher::new); + #[cfg(feature = "local_fs")] + app.add_singleton_model(repo_metadata::RepoMetadataModel::new); + app.add_singleton_model( + crate::warp_managed_paths_watcher::WarpManagedPathsWatcher::new_for_testing, + ); + app.add_singleton_model(crate::workflows::local_workflows::LocalWorkflows::new); + app.add_singleton_model(crate::ai::skills::SkillManager::new); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index b63b5263b17..0b17adf64db 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -35,7 +35,7 @@ mod option_selector; mod orchestrated_agent_identity_styling; mod orchestration_block; mod resume; -mod sessions; +mod session_registry; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index deade2556ad..7bc9a87f9f8 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -11,7 +11,7 @@ use warpui_core::{ }; use crate::keybindings::TUI_BINDING_GROUP; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::terminal_session_view::TuiTerminalSessionView; use crate::ui::{login_failed, login_placeholder, terminal_starting}; diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index ac60fd73b5f..ebd6979ad9a 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -4,7 +4,7 @@ use warpui::{AddWindowOptions, UpdateModel}; use warpui_core::{App, TuiView as _, WindowId}; use super::RootTuiView; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; fn add_root(app: &mut App) -> (WindowId, warpui_core::ViewHandle) { @@ -32,16 +32,17 @@ fn root_projects_only_the_focused_retained_session_view() { }); let (first, first_manager) = add_test_terminal_session(&mut app, window_id); - let (second, second_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); - let second_view_id = second.id(); let first_id = app.update_model(&sessions, |sessions, ctx| { sessions.add_session(first, first_manager, true, ctx) }); app.read(|ctx| { assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); }); let focused_window_view = app.read(|ctx| ctx.focused_view_id(window_id)); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let second_view_id = second.id(); let second_id = app.update_model(&sessions, |sessions, ctx| { sessions.add_session(second, second_manager, false, ctx) @@ -56,12 +57,16 @@ fn root_projects_only_the_focused_retained_session_view() { }); app.read(|ctx| { assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![second_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &second_view_id)); + assert_ne!(ctx.focused_view_id(window_id), focused_window_view); }); app.update_model(&sessions, |sessions, ctx| { sessions.focus_session(first_id, ctx); }); app.read(|ctx| { assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); + assert_eq!(ctx.focused_view_id(window_id), focused_window_view); }); }); } diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index d18bb311b2f..0375ed165b7 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -25,7 +25,7 @@ use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ diff --git a/crates/warp_tui/src/sessions.rs b/crates/warp_tui/src/session_registry.rs similarity index 94% rename from crates/warp_tui/src/sessions.rs rename to crates/warp_tui/src/session_registry.rs index 8d19816823e..2066255f84b 100644 --- a/crates/warp_tui/src/sessions.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -1,4 +1,4 @@ -//! [`TuiSessions`]: the TUI's multi-session container. +//! [`TuiSessions`]: registry and foreground selection for live TUI sessions. //! //! Every session is a full [`TuiTerminalSessionView`] backed by a retained //! terminal manager. The container owns session lifetime and focus; the root @@ -136,6 +136,12 @@ impl TuiSessions { return false; } self.focused_session_id = Some(id); + let view = self + .session(id) + .expect("focused session was validated above") + .view + .clone(); + view.update(ctx, |view, ctx| view.activate(ctx)); ctx.emit(TuiSessionsEvent::FocusChanged(id)); ctx.notify(); true @@ -168,5 +174,5 @@ impl TuiSessions { } #[cfg(test)] -#[path = "sessions_tests.rs"] +#[path = "session_registry_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/sessions_tests.rs b/crates/warp_tui/src/session_registry_tests.rs similarity index 81% rename from crates/warp_tui/src/sessions_tests.rs rename to crates/warp_tui/src/session_registry_tests.rs index dd44830d2f5..dc74596418e 100644 --- a/crates/warp_tui/src/sessions_tests.rs +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -43,14 +43,13 @@ fn add_and_focus_drive_events() { let events = capture_events(&mut app); let (first, first_manager) = add_test_terminal_session(&mut app, window_id); - let (second, second_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); - let second_view_id = second.id(); let first_id = app.update_model(&sessions, |sessions, ctx| { sessions.add_session(first, first_manager, true, ctx) }); assert_eq!(first_id.surface_id(), first_view_id); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); assert_eq!( std::mem::take(&mut *events.borrow_mut()), vec![ @@ -58,6 +57,13 @@ fn add_and_focus_drive_events() { TuiSessionsEvent::FocusChanged(first_id), ], ); + let first_focused_view_id = app.read(|ctx| ctx.focused_view_id(window_id)); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let second_view_id = second.id(); + assert_eq!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); let second_id = app.update_model(&sessions, |sessions, ctx| { sessions.add_session(second, second_manager, false, ctx) @@ -76,6 +82,11 @@ fn add_and_focus_drive_events() { assert!(sessions.focus_session(second_id, ctx)); assert!(!sessions.focus_session(second_id, ctx)); }); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &second_view_id) })); + assert_ne!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); assert_eq!( std::mem::take(&mut *events.borrow_mut()), vec![TuiSessionsEvent::FocusChanged(second_id)], @@ -84,6 +95,11 @@ fn add_and_focus_drive_events() { app.update_model(&sessions, |sessions, ctx| { assert!(sessions.focus_session(first_id, ctx)); }); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); + assert_eq!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); assert_eq!( std::mem::take(&mut *events.borrow_mut()), vec![TuiSessionsEvent::FocusChanged(first_id)], diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 4bcd2510e6b..babb54b2da3 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -66,7 +66,7 @@ use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::orchestration_block::TuiOrchestrationBlock; use crate::resume::TuiExitSummaryHandle; -use crate::sessions::TuiSessions; +use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::terminal_content_element::TuiTerminalContentElement; @@ -318,19 +318,28 @@ impl TuiTerminalSessionView { } fn update_process_input_focus(&mut self, ctx: &mut ViewContext) { - if !self.is_focused_session(ctx) { - return; - } + self.focus_current_owner_if_active(ctx); + } + + fn focus_current_owner(&self, ctx: &mut ViewContext) { if self.process_owns_input() { - if !ctx.is_self_focused() { - ctx.focus_self(); - } - } else if ctx.is_self_focused() { - if let Some(blocker) = self.active_blocking_child(ctx) { - ctx.focus(&blocker); - } else { - ctx.focus(&self.input_view); - } + ctx.focus_self(); + } else if let Some(blocker) = self.active_blocking_child(ctx) { + ctx.focus(&blocker); + } else { + ctx.focus(&self.input_view); + } + } + + fn focus_current_owner_if_active(&self, ctx: &mut ViewContext) { + if self.is_focused_session(ctx) { + self.focus_current_owner(ctx); + } + } + + fn focus_input_if_active(&self, ctx: &mut ViewContext) { + if self.is_focused_session(ctx) { + ctx.focus(&self.input_view); } } fn resume_after_user_controlled_command( @@ -375,7 +384,7 @@ impl TuiTerminalSessionView { transcript.detach_cli_subagent(initial_requested_command_action_id, view.id(), ctx); }); } - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); } fn handle_cli_subagent_event(&mut self, event: &CLISubagentEvent, ctx: &mut ViewContext) { match event { @@ -986,20 +995,6 @@ impl TuiTerminalSessionView { ); ctx.spawn_stream_local(terminal_resize_rx, Self::handle_terminal_resize, |_, _| {}); - // Focus the input view so the keymap responder chain is - // [root, session, input]: input bindings win for keys they define, - // and unbound keys (ctrl-c) fall through to the session/root bindings. - // Background session views (e.g. orchestration children) must not - // steal window focus from the focused session at construction. - let is_focused_session = !ctx.has_singleton_model::() - || TuiSessions::as_ref(ctx) - .focused_session_id() - .is_none_or(|id| id.surface_id() == terminal_surface_id); - - if is_focused_session { - ctx.focus(&input_view); - } - Self { transcript, input_view, @@ -1040,14 +1035,17 @@ impl TuiTerminalSessionView { self.transcript.as_ref(ctx).active_blocking_child(ctx) } - /// Whether this view projects the focused session. Background session - /// views must not claim window focus or write the exit summary. Absent - /// container state (unit tests) counts as focused. + /// Activates this session after the registry has made it authoritative. + pub(crate) fn activate(&mut self, ctx: &mut ViewContext) { + self.focus_current_owner(ctx); + self.write_exit_summary(ctx); + } + + /// Whether this view projects the focused session. fn is_focused_session(&self, ctx: &AppContext) -> bool { - !ctx.has_singleton_model::() - || TuiSessions::as_ref(ctx) - .focused_session_id() - .is_none_or(|id| id.surface_id() == self.terminal_surface_id) + TuiSessions::as_ref(ctx) + .focused_session_id() + .is_some_and(|id| id.surface_id() == self.terminal_surface_id) } /// Reconciles focus with the derived blocker: a newly active blocker is @@ -1059,18 +1057,8 @@ impl TuiTerminalSessionView { let blocker = self.active_blocking_child(ctx); let blocker_view_id = blocker.as_ref().map(ViewHandle::id); if blocker_view_id != self.active_blocker_view_id { - // A foreground process owns the rendered pane and keyboard. Defer - // both the focus handoff and its completion marker until the - // process releases input. Background-session blockers likewise - // remain pending rather than stealing focus from the foreground - // session, so either transition remains retryable. - if !self.process_owns_input() && self.is_focused_session(ctx) { - match &blocker { - Some(child) => ctx.focus(child), - None => ctx.focus(&self.input_view), - } - self.active_blocker_view_id = blocker_view_id; - } + self.active_blocker_view_id = blocker_view_id; + self.focus_current_owner_if_active(ctx); } ctx.notify(); } @@ -1236,7 +1224,7 @@ impl TuiTerminalSessionView { self.conversation_restore_state = ConversationRestoreState::Idle; self.refresh_exit_summary(ctx); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); ctx.notify(); } @@ -1267,7 +1255,7 @@ impl TuiTerminalSessionView { future.abort(); } self.next_restore_request_id = self.next_restore_request_id.wrapping_add(1); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); ctx.notify(); true } @@ -1295,18 +1283,20 @@ impl TuiTerminalSessionView { TuiConversationRestoreOrigin::ConversationList => { self.conversation_restore_state = ConversationRestoreState::Idle; self.show_transient_hint(message, ctx); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); } } ctx.notify(); } fn refresh_exit_summary(&self, ctx: &AppContext) { - // The exit summary's resume hint tracks the focused session only; - // background children must not overwrite it with their own tokens. if !self.is_focused_session(ctx) { return; } + self.write_exit_summary(ctx); + } + + fn write_exit_summary(&self, ctx: &AppContext) { let token = self .conversation_selection .as_ref(ctx) diff --git a/specs/code-1822-tui-multi-session/TECH.md b/specs/code-1822-tui-multi-session/TECH.md index 2543cea45bf..7eb4f11c83e 100644 --- a/specs/code-1822-tui-multi-session/TECH.md +++ b/specs/code-1822-tui-multi-session/TECH.md @@ -19,7 +19,7 @@ the focused view participates in rendering and input routing. ## Proposed changes -### New: `crates/warp_tui/src/sessions.rs` +### New: `crates/warp_tui/src/session_registry.rs` - Add `TuiSessionId(EntityId)`, using the eagerly-created view's entity id as both session identity and shared-model `terminal_surface_id`. @@ -51,10 +51,11 @@ the focused view participates in rendering and input routing. - The login bootstrap registers the first session focused. ### Changed: `crates/warp_tui/src/terminal_session_view.rs` - -- Guard constructor focus, blocker-driven focus changes, and exit-summary - updates so background views cannot steal focus or replace the focused - session's resume token. +- Keep construction focus-neutral. When `TuiSessions` activates a session, the + view focuses its current input owner and refreshes the exit summary. +- Route later blocker, process, CLI-subagent, and conversation-restoration + focus requests through focused-session guards so background views cannot + steal focus or replace the focused session's resume token. ## Non-goals From eb8c75e5290480d8fbf3d35ae5445382bfd1d300 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 18:09:31 -0400 Subject: [PATCH 27/40] add back explicit state enum --- crates/warp_tui/src/root_view.rs | 39 ++++++++++++++++++++------ crates/warp_tui/src/root_view_tests.rs | 7 +++++ crates/warp_tui/src/session.rs | 18 ++++++++---- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index 7bc9a87f9f8..35f06c77fdf 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -22,8 +22,16 @@ pub enum RootTuiAction { ExitApp, } +/// Whether the root is presenting authentication or the live session container. +enum RootTuiState { + Auth, + Terminal, +} + /// The app-level TUI shell, projecting only the focused full session view. -pub struct RootTuiView; +pub struct RootTuiView { + state: RootTuiState, +} /// Registers the root view's keybindings. pub fn init(app: &mut AppContext) { @@ -38,7 +46,15 @@ pub fn init(app: &mut AppContext) { impl RootTuiView { /// Creates the login-gated root view. pub(crate) fn new() -> Self { - Self + Self { + state: RootTuiState::Auth, + } + } + + /// Transitions from the authentication gate to the live session container. + pub(crate) fn show_terminal(&mut self, ctx: &mut ViewContext) { + self.state = RootTuiState::Terminal; + ctx.notify(); } fn focused_session_view(&self, ctx: &AppContext) -> Option> { @@ -62,15 +78,18 @@ impl TuiView for RootTuiView { } fn child_view_ids(&self, ctx: &AppContext) -> Vec { - self.focused_session_view(ctx) - .map(|view| vec![view.id()]) - .unwrap_or_default() + match self.state { + RootTuiState::Auth => Vec::new(), + RootTuiState::Terminal => self + .focused_session_view(ctx) + .map(|view| vec![view.id()]) + .unwrap_or_default(), + } } fn render(&self, ctx: &AppContext) -> Box { - match self.focused_session_view(ctx) { - Some(view) => TuiChildView::new(&view).finish(), - None => match TuiLoginModel::as_ref(ctx).phase() { + match self.state { + RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() { TuiLoginPhase::LoggedIn => terminal_starting(), TuiLoginPhase::AwaitingLogin { verification_uri, @@ -78,6 +97,10 @@ impl TuiView for RootTuiView { } => login_placeholder(verification_uri.as_deref(), user_code.as_deref()), TuiLoginPhase::Failed { message } => login_failed(message.as_str()), }, + RootTuiState::Terminal => self + .focused_session_view(ctx) + .map(|view| TuiChildView::new(&view).finish()) + .unwrap_or_else(terminal_starting), } } diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index ebd6979ad9a..60ccb677aac 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -30,12 +30,19 @@ fn root_projects_only_the_focused_retained_session_view() { root.update(&mut app, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); }); + app.read(|ctx| { + assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); + }); let (first, first_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); let first_id = app.update_model(&sessions, |sessions, ctx| { sessions.add_session(first, first_manager, true, ctx) }); + app.read(|ctx| { + assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); + }); + root.update(&mut app, |root, ctx| root.show_terminal(ctx)); app.read(|ctx| { assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 0375ed165b7..c347ad1a934 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -130,15 +130,18 @@ fn init( }); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { // Already authenticated at mount: create the first session now. - create_terminal_session_after_login(&sessions, ctx); + create_terminal_session_after_login(&sessions, &root, ctx); } else { // Otherwise wait for login to complete and create it then. let sessions_for_login = sessions.clone(); + let root_for_login = root.clone(); let login_model = TuiLoginModel::handle(ctx); ctx.subscribe_to_model(&login_model, move |_, event, ctx| match event { - TuiLoginEvent::LoggedIn => { - create_terminal_session_after_login(&sessions_for_login, ctx) - } + TuiLoginEvent::LoggedIn => create_terminal_session_after_login( + &sessions_for_login, + &root_for_login, + ctx, + ), }); } } @@ -151,7 +154,11 @@ fn init( } /// Creates the focused bootstrap session after login. -fn create_terminal_session_after_login(sessions: &ModelHandle, ctx: &mut AppContext) { +fn create_terminal_session_after_login( + sessions: &ModelHandle, + root: &ViewHandle, + ctx: &mut AppContext, +) { if sessions.read(ctx, |sessions, _| !sessions.is_empty()) { return; } @@ -196,6 +203,7 @@ fn create_terminal_session_after_login(sessions: &ModelHandle, ctx: sessions.update(ctx, |sessions, ctx| { sessions.add_session(manager.surface, manager.manager, true, ctx); }); + root.update(ctx, |root, ctx| root.show_terminal(ctx)); } #[cfg(test)] From 50fb114dcb535b3edb903d06c0f2dbae24f10fcf Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 18:37:00 -0400 Subject: [PATCH 28/40] Add TuiOrchestrationModel + local native child agents in background sessions The orchestration model subscribes to every session's StartAgentExecutor via TuiSessions registration, materializes native (Oz) local children into background sessions (create_agent_task -> shared create_local_terminal_session helper -> child conversation linkage -> prompt dispatch), and resolves CLI-harness/remote requests as clean per-child failures instead of spawn timeouts. Exports the StartAgent executor surface through tui_export and adds minimal transcript lines for inter-agent messages/lifecycle events (TODO(code-1822) for richer rendering). Co-Authored-By: Oz --- app/src/ai/blocklist/action_model.rs | 2 +- app/src/ai/blocklist/action_model/execute.rs | 3 +- app/src/ai/blocklist/mod.rs | 10 +- app/src/global_resource_handles.rs | 2 +- app/src/tui_export.rs | 18 +- app/src/tui_test_support.rs | 4 + crates/warp_tui/src/agent_block.rs | 25 +- crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/orchestration_model.rs | 323 ++++++++++++++++++ .../warp_tui/src/orchestration_model_tests.rs | 178 ++++++++++ crates/warp_tui/src/session.rs | 48 ++- crates/warp_tui/src/session_registry.rs | 20 +- crates/warp_tui/src/terminal_session_view.rs | 9 + specs/code-1822-tui-local-children/TECH.md | 147 ++++++++ 14 files changed, 758 insertions(+), 32 deletions(-) create mode 100644 crates/warp_tui/src/orchestration_model.rs create mode 100644 crates/warp_tui/src/orchestration_model_tests.rs create mode 100644 specs/code-1822-tui-local-children/TECH.md diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 3a9f1685ec3..2b8fc711699 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -34,7 +34,7 @@ pub use execute::{ ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, - StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; use futures::future::{join_all, BoxFuture}; use itertools::Itertools; diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index b01f2333bb7..c77dff385da 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -62,7 +62,8 @@ pub use send_message::SendMessageToAgentExecutor; use serde::{Deserialize, Serialize}; pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent}; pub use start_agent::{ - StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, + StartAgentRequestId, }; use start_recording::StartRecordingExecutor; use stop_recording::StopRecordingExecutor; diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index fe29f4b09b2..51065df732d 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -49,8 +49,7 @@ pub use action_model::RequestFileEditsExecutor; #[cfg_attr(target_family = "wasm", allow(unused_imports))] pub(crate) use action_model::{ apply_edits, read_local_file_context, FileReadResult, ReadFileContextResult, - RequestFileEditsFormatKind, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - StartAgentRequestId, + RequestFileEditsFormatKind, }; pub use action_model::{ BlocklistAIActionEvent, BlocklistAIActionModel, ShellCommandExecutor, ShellCommandExecutorEvent, @@ -58,6 +57,13 @@ pub use action_model::{ // Consumed by `tui_export` for the `warp_tui` frontend. #[cfg_attr(not(feature = "tui"), allow(unused_imports))] pub use action_model::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot}; +// Consumed by `tui_export` for the `warp_tui` frontend's child-agent +// materializer, in addition to the GUI pane-group dispatch. +#[cfg_attr(target_family = "wasm", allow(unused_imports))] +pub use action_model::{ + StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, + StartAgentRequestId, +}; #[cfg(any(test, feature = "integration_tests"))] pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; diff --git a/app/src/global_resource_handles.rs b/app/src/global_resource_handles.rs index bedbdef1a3d..9ed6e43d341 100644 --- a/app/src/global_resource_handles.rs +++ b/app/src/global_resource_handles.rs @@ -58,7 +58,7 @@ pub struct GlobalResourceHandles { } impl GlobalResourceHandles { - #[cfg(any(test, feature = "integration_tests"))] + #[cfg(any(test, feature = "integration_tests", feature = "test-util"))] pub fn mock(app: &mut warpui::App) -> Self { let referral_theme_status = app.add_model(ReferralThemeStatus::new); let user_default_shell_unsupported_banner_model_handle = diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 79f53709438..7205ecf13ce 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -21,10 +21,10 @@ pub use crate::ai::agent::{ AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoId, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, AskUserQuestionResult, CancellationReason, - FileGlobV2Result, GrepResult, MessageId, RequestCommandOutputResult, RunAgentsAgentOutcomeKind, - RunAgentsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, - ShellCommandDelay, StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, - TodoOperation, UserQueryMode, + FileGlobV2Result, GrepResult, MessageId, RenderableAIError, RequestCommandOutputResult, + RunAgentsAgentOutcomeKind, RunAgentsResult, SearchCodebaseFailureReason, SearchCodebaseResult, + ServerOutputId, Shared, ShellCommandDelay, StartAgentExecutionMode, + SuggestNewConversationResult, SummarizationType, TodoOperation, UserQueryMode, }; pub use crate::ai::agent_conversations_model::{ query_conversation_entries, AgentConversationEntry, AgentConversationEntryId, @@ -32,6 +32,7 @@ pub use crate::ai::agent_conversations_model::{ AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, HarnessFilter, OwnerFilter, }; +pub use crate::ai::ambient_agents::AmbientAgentTaskId; pub use crate::ai::blocklist::agent_view::{ AgentViewController, AgentViewDisplayMode, AgentViewEntryOrigin, EnterAgentViewError, EphemeralMessageModel, @@ -56,13 +57,17 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +pub use crate::ai::blocklist::orchestration_event_streamer::{ + register_agent_event_consumer, unregister_agent_event_consumer, +}; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, - RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, + RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, + StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, @@ -71,6 +76,7 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; +pub use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, @@ -102,6 +108,8 @@ pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +pub use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot}; +pub use crate::server::server_api::ServerApiProvider; pub use crate::settings::AISettingsChangedEvent; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs index 5819810fbd8..212441acdf5 100644 --- a/app/src/tui_test_support.rs +++ b/app/src/tui_test_support.rs @@ -85,6 +85,10 @@ pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) }); app.add_singleton_model(AgentConversationsModel::new); + let global_resources = crate::GlobalResourceHandles::mock(app); + app.add_singleton_model(|_| { + crate::GlobalResourceHandlesProvider::new(global_resources.clone()) + }); app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); app.add_singleton_model(|ctx| { diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 845022fddbc..84b7b95f7de 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -921,7 +921,10 @@ impl TuiAIBlock { ); } AIAgentOutputMessageType::Action(action) => { - sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + // WaitForEvents renders nothing, matching the GUI. + if !matches!(action.action, AIAgentActionType::WaitForEvents { .. }) { + sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + } } AIAgentOutputMessageType::Reasoning { text, @@ -973,6 +976,22 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, + // TODO: add full status rendering based on MOCs. + AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { + for received in messages { + sections.push(TuiAIBlockSection::PlainText(format!( + "Received message from agent {}: {}", + received.sender_agent_id, received.subject + ))); + } + } + AIAgentOutputMessageType::EventsFromAgents { event_ids } => { + let count = event_ids.len(); + let plural = if count == 1 { "" } else { "s" }; + sections.push(TuiAIBlockSection::PlainText(format!( + "Received {count} agent lifecycle event{plural}" + ))); + } // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) @@ -981,9 +1000,7 @@ impl TuiAIBlock { | AIAgentOutputMessageType::CommentsAddressed { .. } | AIAgentOutputMessageType::DebugOutput { .. } | AIAgentOutputMessageType::ArtifactCreated(_) - | AIAgentOutputMessageType::SkillInvoked(_) - | AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } - | AIAgentOutputMessageType::EventsFromAgents { .. } => {} + | AIAgentOutputMessageType::SkillInvoked(_) => {} } } } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 0b17adf64db..1d0b0a0889a 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -34,6 +34,7 @@ mod model_menu; mod option_selector; mod orchestrated_agent_identity_styling; mod orchestration_block; +mod orchestration_model; mod resume; mod session_registry; mod skills_menu; diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs new file mode 100644 index 00000000000..e35e57ee439 --- /dev/null +++ b/crates/warp_tui/src/orchestration_model.rs @@ -0,0 +1,323 @@ +//! [`TuiOrchestrationModel`]: the TUI's child-agent coordinator. +//! +//! The shared `StartAgentExecutor` (one per session surface) emits +//! `CreateAgent` and waits for a frontend to materialize the child. In the +//! GUI that materializer is `TerminalView` → `PaneGroup`'s hidden child +//! panes; in the TUI it is this singleton. It subscribes to every session +//! registered with [`TuiSessions`] (so children can orchestrate +//! grandchildren), spawns native Oz children into background sessions, and +//! tracks the session dimension of the orchestration tree — conversation +//! lineage itself stays in `BlocklistAIHistoryModel`. +//! +//! Native (Oz) local children run in background TUI sessions. Local +//! CLI-harness and remote child requests resolve with an explicit failure. + +use std::collections::{HashMap, HashSet}; + +use warp::tui_export::{ + register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, + AIExecutionProfilesModel, AgentConfigSnapshot, AmbientAgentTaskId, BlocklistAIHistoryModel, + ConversationStatus, Harness, LLMId, LLMPreferences, RenderableAIError, ServerApiProvider, + StartAgentExecutionMode, StartAgentExecutorEvent, StartAgentRequest, +}; +use warpui::SingletonEntity; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ReadModel as _}; + +use crate::session::create_local_terminal_session; +use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; + +/// The TUI's child-agent coordinator singleton. See the module docs. +pub(crate) struct TuiOrchestrationModel { + /// Session hosting each live child conversation. The session dimension + /// only — conversation lineage is read from `BlocklistAIHistoryModel` + /// (`children_by_parent` / `parent_conversation_id`), never mirrored. + child_session_by_conversation: HashMap, + /// Sessions that have dispatched at least one child agent. + parent_sessions: HashSet, +} + +impl Entity for TuiOrchestrationModel { + type Event = (); +} + +impl SingletonEntity for TuiOrchestrationModel {} + +impl TuiOrchestrationModel { + /// Registers the singleton and subscribes it to [`TuiSessions`] so every + /// session's `StartAgentExecutor` gets wired as sessions register. Must + /// run before any session is created. + pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { + let sessions = TuiSessions::handle(ctx); + ctx.add_singleton_model(|ctx| { + ctx.subscribe_to_model(&sessions, Self::handle_sessions_event); + Self { + child_session_by_conversation: HashMap::new(), + parent_sessions: HashSet::new(), + } + }) + } + + /// Wires a newly registered session's `StartAgentExecutor` to this model. + fn handle_sessions_event( + &mut self, + sessions: ModelHandle, + event: &TuiSessionsEvent, + ctx: &mut ModelContext, + ) { + let TuiSessionsEvent::SessionAdded(session_id) = event else { + return; + }; + let session_id = *session_id; + let Some(session_view) = ctx.read_model(&sessions, |sessions, _| { + sessions + .session(session_id) + .map(|session| session.view().clone()) + }) else { + return; + }; + let action_model = session_view.as_ref(ctx).ai_action_model().clone(); + let executor = ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)); + ctx.subscribe_to_model(&executor, move |me, _, event, ctx| { + me.handle_executor_event(session_id, event, ctx); + }); + } + + fn handle_executor_event( + &mut self, + parent_session_id: TuiSessionId, + event: &StartAgentExecutorEvent, + ctx: &mut ModelContext, + ) { + match event { + StartAgentExecutorEvent::CreateAgent(request) => { + self.dispatch_create_agent(parent_session_id, (**request).clone(), ctx); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + self.cleanup_failed_child(conversation_id, ctx); + } + } + } + + /// Routes a `CreateAgent` request the same two ways as the GUI's + /// per-mode dispatch, with unsupported modes resolving as clean per-child + /// failures. + fn dispatch_create_agent( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + ctx: &mut ModelContext, + ) { + // Dispatching a child makes the parent an orchestrator: register it + // as a streamer consumer so its SSE stream (child lifecycle + inbox + // messages) opens. The GUI gets this from the agent view's + // `ActiveAgentViewsModel` bridge, which the TUI does not have. + register_agent_event_consumer( + request.parent_conversation_id, + parent_session_id.surface_id(), + ctx, + ); + match request.execution_mode.clone() { + StartAgentExecutionMode::Local { + harness_type: None, + model_id, + } => self.launch_native_child(parent_session_id, request, model_id, ctx), + StartAgentExecutionMode::Local { + harness_type: Some(harness_type), + .. + } => { + // TODO(code-1822): support local CLI-harness children by + // reusing the frontend-neutral + // `prepare_local_harness_child_launch` command builder. + fail_child_request( + &request, + format!( + "Local {harness_type} child agents aren't supported in the Warp TUI yet." + ), + ctx, + ); + } + StartAgentExecutionMode::Remote { .. } => { + // TODO(code-1822): remote children need a TUI materializer; + // the GUI's spawn path is coupled to ambient-agent panes. + fail_child_request( + &request, + "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), + ctx, + ); + } + } + } + + /// Native (Oz) local child: eagerly creates the server task row (which + /// activates messaging/lifecycle for the child), then materializes the + /// background session, mirroring the GUI's + /// `launch_local_no_harness_child`. + fn launch_native_child( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + model_id: Option, + ctx: &mut ModelContext, + ) { + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let agent_name = Some(request.name.trim().to_owned()).filter(|name| !name.is_empty()); + let prompt = request.prompt.clone(); + let parent_run_id = request.parent_run_id.clone(); + ctx.spawn( + async move { + ai_client + .create_agent_task( + prompt, + None, + parent_run_id, + Some(AgentConfigSnapshot { + name: agent_name, + ..Default::default() + }), + ) + .await + }, + move |me, result, ctx| match result { + Ok(task_id) => me.materialize_native_child( + parent_session_id, + &request, + model_id.as_deref(), + task_id, + ctx, + ), + Err(error) => fail_child_request( + &request, + format!("Failed to create local child task: {error}"), + ctx, + ), + }, + ); + } + + /// Creates the background session, links the child conversation to the + /// parent, echoes it back to the executor, and dispatches the prompt. + fn materialize_native_child( + &mut self, + parent_session_id: TuiSessionId, + request: &StartAgentRequest, + model_id: Option<&str>, + task_id: AmbientAgentTaskId, + ctx: &mut ModelContext, + ) { + let sessions = TuiSessions::handle(ctx); + let (session_id, session_view) = create_local_terminal_session(&sessions, false, ctx); + let child_surface_id = session_id.surface_id(); + + // Inherit the parent's execution profile and base model, then apply + // the run-wide model override — the TUI counterpart of the GUI's + // `propagate_parent_agent_settings` + `apply_child_model_id_override`. + let parent_surface_id = parent_session_id.surface_id(); + let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) + .active_profile(Some(parent_surface_id), ctx) + .id(); + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { + profiles.set_active_profile(child_surface_id, parent_profile_id, ctx); + }); + let parent_base_model_id = LLMPreferences::as_ref(ctx) + .get_active_base_model(ctx, Some(parent_surface_id)) + .id + .clone(); + LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { + prefs.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); + }); + if let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) { + let llm_id: LLMId = model_id.into(); + LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { + prefs.update_preferred_agent_mode_llm(&llm_id, child_surface_id, ctx); + }); + } + + let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + child_surface_id, + request.name.clone(), + request.parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + // Stamp the task id before completing the request so the + // executor and the local task-status sync see it immediately. + if let Some(conversation) = history.conversation_mut(&conversation_id) { + conversation.set_task_id(task_id); + } + history.set_active_conversation_id(conversation_id, child_surface_id, ctx); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + conversation_id + }); + + // Register the child as a streamer consumer so its own inbox stream + // (parent→child messages, wake events) opens. + register_agent_event_consumer(conversation_id, child_surface_id, ctx); + + let ai_controller = session_view.as_ref(ctx).ai_controller().clone(); + let prompt = request.prompt.clone(); + ai_controller.update(ctx, |controller, ctx| { + controller.set_ambient_agent_task_id(Some(task_id), ctx); + controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); + }); + + self.child_session_by_conversation + .insert(conversation_id, session_id); + self.parent_sessions.insert(parent_session_id); + ctx.notify(); + } + + /// Tears down the background session of a child that failed at the + /// launch stage (the executor's `CleanupFailedChildLaunch`). + fn cleanup_failed_child( + &mut self, + conversation_id: &AIConversationId, + ctx: &mut ModelContext, + ) { + let Some(session_id) = self.child_session_by_conversation.remove(conversation_id) else { + return; + }; + unregister_agent_event_consumer(*conversation_id, session_id.surface_id(), ctx); + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.remove_session(session_id, ctx); + }); + ctx.notify(); + } +} + +/// Resolves a child request as failed without materializing a session: +/// creates the child conversation on a synthetic surface, marks it errored, +/// then echoes it to the executor — which completes the pending slot with +/// the error message instead of hanging into the spawn timeout. +fn fail_child_request( + request: &StartAgentRequest, + message: String, + ctx: &mut ModelContext, +) { + log::warn!( + "Failing TUI child agent request '{}': {message}", + request.name + ); + let surface_id = EntityId::new(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + surface_id, + request.name.clone(), + request.parent_conversation_id, + None, + ctx, + ); + history.update_conversation_status_with_error( + surface_id, + conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other(message, false)), + ctx, + ); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + }); +} + +#[cfg(test)] +#[path = "orchestration_model_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs new file mode 100644 index 00000000000..b2ec7348cc2 --- /dev/null +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -0,0 +1,178 @@ +use warp::tui_export::{ + register_tui_session_view_test_singletons, StartAgentExecutionMode, StartAgentExecutor, + StartAgentOutcome, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; +use warpui_core::{App, WindowId}; + +use super::TuiOrchestrationModel; +use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_semantic_selection, add_test_terminal_session, +}; + +struct OrchestrationFixture { + sessions: ModelHandle, + window_id: WindowId, +} + +/// Boots the container + root + orchestration model wiring (no live PTYs). +fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { + register_tui_session_view_test_singletons(app); + add_test_semantic_selection(app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let (window_id, root) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + root.update(app, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + }); + app.update(TuiOrchestrationModel::register); + OrchestrationFixture { + sessions, + window_id, + } +} + +/// Registers a session (with a live active conversation) and returns its id +/// plus its surface's `StartAgentExecutor`. +fn add_dispatching_session( + app: &mut App, + fixture: &OrchestrationFixture, + focus: bool, +) -> (TuiSessionId, ModelHandle) { + let (session, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update_model(&fixture.sessions, |sessions, ctx| { + sessions.add_session(session.clone(), manager, focus, ctx) + }); + add_active_test_conversation(app, session_id.surface_id()); + let executor = app.read(|ctx| { + let action_model = session.as_ref(ctx).ai_action_model().clone(); + ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)) + }); + (session_id, executor) +} + +/// Dispatches a StartAgent request through the session's executor and +/// returns the resolved outcome (the orchestration model resolves +/// unsupported modes synchronously within the same effect flush). +fn dispatch_and_recv( + app: &mut App, + fixture: &OrchestrationFixture, + session_id: TuiSessionId, + executor: &ModelHandle, + execution_mode: StartAgentExecutionMode, +) -> StartAgentOutcome { + let parent_conversation_id = app.read(|ctx| { + warp::tui_export::BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(session_id.surface_id()) + .expect("fixture registered an active conversation") + .id() + }); + let _ = fixture; + let receiver = app.update_model(executor, |executor, ctx| { + executor.dispatch( + "researcher".to_string(), + "research the codebase".to_string(), + execution_mode, + None, + parent_conversation_id, + Some("parent-run-1".to_string()), + ctx, + ) + }); + receiver + .try_recv() + .expect("unsupported-mode dispatches resolve before the update returns") +} + +fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { + match outcome { + StartAgentOutcome::Error(message) => { + assert!(message.contains(needle), "unexpected error: {message}"); + } + StartAgentOutcome::Started { agent_id } => { + panic!("expected an error outcome, got Started({agent_id})"); + } + } +} + +#[test] +fn local_harness_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + + let outcome = dispatch_and_recv( + &mut app, + &fixture, + session_id, + &executor, + StartAgentExecutionMode::Local { + harness_type: Some("claude".to_string()), + model_id: None, + }, + ); + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + }); +} + +#[test] +fn remote_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + + let outcome = dispatch_and_recv( + &mut app, + &fixture, + session_id, + &executor, + StartAgentExecutionMode::Remote { + environment_id: "env-1".to_string(), + skill_references: Vec::new(), + model_id: "auto".to_string(), + computer_use_enabled: false, + worker_host: "warp".to_string(), + harness_type: "oz".to_string(), + title: "Researcher".to_string(), + auth_secret_name: None, + }, + ); + assert_error_containing(outcome, "Cloud child agents aren't supported"); + }); +} + +#[test] +fn sessions_registered_after_init_are_wired_for_orchestration() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + // First session exists before the dispatching one, mirroring a child + // session dispatching grandchildren later in an app's lifetime. + let _ = add_dispatching_session(&mut app, &fixture, true); + let (late_session_id, late_executor) = add_dispatching_session(&mut app, &fixture, false); + + let outcome = dispatch_and_recv( + &mut app, + &fixture, + late_session_id, + &late_executor, + StartAgentExecutionMode::Local { + harness_type: Some("codex".to_string()), + model_id: None, + }, + ); + // A resolved outcome proves the late session's executor is wired to + // the orchestration model (an unwired executor would never resolve). + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + }); +} diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index c347ad1a934..9ebc446bf5d 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -23,9 +23,10 @@ use warpui_core::platform::{TerminationMode, WindowStyle}; use warpui_core::runtime::spawn_tui_driver; use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; +use crate::orchestration_model::TuiOrchestrationModel; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; -use crate::session_registry::TuiSessions; +use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -128,6 +129,7 @@ fn init( root.update(ctx, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); }); + TuiOrchestrationModel::register(ctx); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { // Already authenticated at mount: create the first session now. create_terminal_session_after_login(&sessions, &root, ctx); @@ -153,7 +155,7 @@ fn init( } } -/// Creates the focused bootstrap session after login. +/// Creates the focused bootstrap session and restores the requested conversation. fn create_terminal_session_after_login( sessions: &ModelHandle, root: &ViewHandle, @@ -164,7 +166,28 @@ fn create_terminal_session_after_login( } let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); + let (_, surface) = create_local_terminal_session(sessions, true, ctx); + if let Some(token) = resume_token { + surface.update(ctx, |view, ctx| { + view.restore_conversation( + TuiConversationRestoreTarget::Server(token), + TuiConversationRestoreOrigin::Startup, + ctx, + ); + }); + } + root.update(ctx, |root, ctx| root.show_terminal(ctx)); +} + +/// Creates and registers a full local terminal session. +pub(crate) fn create_local_terminal_session( + sessions: &ModelHandle, + focus: bool, + ctx: &mut AppContext, +) -> (TuiSessionId, ViewHandle) { let (window_id, exit_summary) = sessions.read(ctx, |sessions, _| sessions.surface_context()); + // The manager uses this internal model for unsupported-shell state; the + // TUI does not render a separate banner surface. let banner = ctx.add_model(|_| BannerState::default()); let manager = LocalTtyTerminalManager::::create_tui_model( std::env::current_dir().ok(), @@ -184,26 +207,17 @@ fn create_terminal_session_after_login( TerminalSurfaceResult { surface, post_wire: move |_manager: &mut LocalTtyTerminalManager, - surface: &ViewHandle, - ctx: &mut AppContext| { - if let Some(token) = resume_token { - surface.update(ctx, |view, ctx| { - view.restore_conversation( - TuiConversationRestoreTarget::Server(token), - TuiConversationRestoreOrigin::Startup, - ctx, - ); - }); - } - }, + _surface: &ViewHandle, + _ctx: &mut AppContext| {}, } }, ); - sessions.update(ctx, |sessions, ctx| { - sessions.add_session(manager.surface, manager.manager, true, ctx); + let surface = manager.surface.clone(); + let session_id = sessions.update(ctx, |sessions, ctx| { + sessions.add_session(manager.surface, manager.manager, focus, ctx) }); - root.update(ctx, |root, ctx| root.show_terminal(ctx)); + (session_id, surface) } #[cfg(test)] diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 2066255f84b..ab6d3e73b99 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -46,6 +46,8 @@ impl TuiSession { pub(crate) enum TuiSessionsEvent { /// A session was registered, possibly in the background. SessionAdded(TuiSessionId), + /// A session was removed from the container. + SessionRemoved(TuiSessionId), /// The focused session changed to this id. FocusChanged(TuiSessionId), } @@ -129,7 +131,23 @@ impl TuiSessions { pub(crate) fn surface_context(&self) -> (WindowId, TuiExitSummaryHandle) { (self.window_id, self.exit_summary.clone()) } - + /// Removes a session. When the focused session is removed, focus falls + /// back to the most recently added remaining session, if any. + pub(crate) fn remove_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) { + let before = self.sessions.len(); + self.sessions.retain(|session| session.id != id); + if self.sessions.len() == before { + return; + } + ctx.emit(TuiSessionsEvent::SessionRemoved(id)); + if self.focused_session_id == Some(id) { + self.focused_session_id = None; + if let Some(fallback) = self.sessions.last().map(|session| session.id) { + self.focus_session(fallback, ctx); + } + } + ctx.notify(); + } /// Focuses a registered session. Returns whether focus changed. pub(crate) fn focus_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) -> bool { if self.focused_session_id == Some(id) || self.session(id).is_none() { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index babb54b2da3..84be3f529f6 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1030,6 +1030,15 @@ impl TuiTerminalSessionView { } } + /// The action model driving this session's agent tool execution. + pub(crate) fn ai_action_model(&self) -> &ModelHandle { + &self.ai_action_model + } + + /// The controller used to submit prompts into this session. + pub(crate) fn ai_controller(&self) -> &ModelHandle { + &self.ai_controller + } /// The active front-of-queue blocking interaction, if any. fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md new file mode 100644 index 00000000000..41fb0787dc3 --- /dev/null +++ b/specs/code-1822-tui-local-children/TECH.md @@ -0,0 +1,147 @@ +# TECH: `TuiOrchestrationModel` + background local child agents + +Second PR of the two-PR stack, building on the full-view `TuiSessions` +container. Accepting a local `run_agents` request in the TUI spawns native +child agents in background sessions and makes the run observable in the +parent transcript. + +## Context + +The shared orchestration engine is frontend-neutral and already partially wired into the TUI: + +- The TUI's `TuiRunAgentsCardView` drives the shared `RunAgentsExecutor` accept path + ([crates/warp_tui/src/run_agents_card_view.rs:200-236 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/run_agents_card_view.rs#L200-L236)). +- `RunAgentsExecutor` fans out per child to `StartAgentExecutor::dispatch`, which emits + `StartAgentExecutorEvent::CreateAgent(Box)` and awaits materialization + ([app/src/ai/blocklist/action_model/execute/start_agent.rs:499,532-566 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L532-L566)). + Nothing in `crates/warp_tui` subscribes to that event today, and `StartAgentExecutor` is not yet + exported via `app/src/tui_export.rs`. +- In the GUI, materialization is `TerminalView::handle_start_agent_executor_event` + ([app/src/terminal/view.rs:7630-7650 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/terminal/view.rs#L7630-L7650)) + → `PaneGroup::create_hidden_child_agent_conversation` + ([app/src/pane_group/child_agent/mod.rs:130-179 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/child_agent/mod.rs#L130-L179)) — + pane-tree machinery the TUI cannot reuse. The frontend-neutral pieces it calls are reusable: + `BlocklistAIHistoryModel::start_new_child_conversation` + ([history_model.rs:508-544 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/history_model.rs#L508-L544)), + the `children_by_parent` lineage index, `ai_client.create_agent_task`, and + `StartAgentExecutor`'s self-completion off `BlocklistAIHistoryEvent`s + ([start_agent.rs:144-310 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L144-L310)). +- Messaging/lifecycle needs almost no TUI work: `OrchestrationEventStreamer`, + `OrchestrationEventService`, `LocalAgentTaskSyncModel`, and `MessageHydrator` are + frontend-neutral singletons already registered by the shared bootstrap the TUI binary runs + ([app/src/lib.rs:2051-2057 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/lib.rs#L2051-L2057)). + Transport is server-mediated (SSE + RPC) even between local agents in one process. The one + gap (found in live verification): the streamer only opens a conversation's SSE stream once a + *consumer* registers for it (`register_agent_event_consumer`), which the GUI drives from the + agent view's `ActiveAgentViewsModel` bridge — a surface the TUI lacks. Without it, children + sent messages but the parent never received them. `TuiOrchestrationModel` therefore registers + the parent as a consumer on dispatch and each child on materialization (and unregisters on + failed-launch cleanup). +- The TUI transcript currently drops orchestration traffic on the floor: + `MessagesReceivedFromAgents`/`EventsFromAgents` are explicit no-ops + ([crates/warp_tui/src/agent_block.rs:670-671 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/agent_block.rs#L670-L671)). + +Scope decisions (from the architecture discussion): native Oz children only; CLI-harness children +(Claude/Codex/OpenCode) are a follow-up, and remote children (whose GUI spawn path is coupled to +ambient-agent panes) also resolve as explicit per-child failures for now. Children stay invisible +— no navigation, no status bar; `TuiSessions` focus never moves off session 0 in this PR. +Following the GUI's hidden-pane prior art, each child session gets a full (backgrounded) +`TuiTerminalSessionView` retained by `TuiSessions`; the view doubles as the +terminal manager's PTY surface. + +## Proposed changes + +### Changed: `app/src/tui_export.rs` + +- Re-export `StartAgentExecutor`, `StartAgentExecutorEvent`, and `StartAgentRequest` (plus any + associated outcome types needed to fail a child), mirroring the existing + `RunAgentsExecutor` exports. + +### New: `crates/warp_tui/src/orchestration_model.rs` — `TuiOrchestrationModel` + +A `SingletonEntity` owning all TUI orchestration coordination: + +- Subscribes to `TuiSessions::SessionAdded` and, for each registered session, subscribes to that + session's `StartAgentExecutor`. Because all session creation flows through `TuiSessions` + (PR 2 invariant), every session — including children, enabling future nesting — is wired. +- On `StartAgentExecutorEvent::CreateAgent`, dispatches on the request mirroring the GUI's + per-mode dispatch: + - **Native (no harness)**: `ai_client.create_agent_task` (server task row → run_id, which is + what activates messaging/lifecycle for the child) → create a background session via the + shared `create_local_terminal_session` helper (PTY manager + full backgrounded view, + registered unfocused with `TuiSessions`) → inherit the parent's execution + profile/base model and apply the run-wide model override → + `BlocklistAIHistoryModel::start_new_child_conversation` + `set_task_id` → + `record_new_conversation_request_complete` (echoes the child `AIConversationId` so + `StartAgentExecutor` resolves its pending slot) → send the child's prompt via the child + session's `BlocklistAIController::send_agent_query_in_conversation`. + - **CLI-harness (Claude/Codex/OpenCode) and Remote**: resolve the pending slot with a clear + per-child failure outcome — a clean `failed` entry in the `launched` result rather than a + spawn-timeout hang. The failure path creates the child conversation on a synthetic surface, + marks it `Error`, and echoes it to the executor (which then also emits + `CleanupFailedChildLaunch`; the model tears down any mapped child session on that event). + TODO(code-1822): implement CLI-harness children by reusing the frontend-neutral + `prepare_local_harness_child_launch` + ([app/src/pane_group/pane/local_harness_launch.rs:158 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/pane/local_harness_launch.rs#L158)), + and remote children with a TUI-native spawn path. +- `crates/warp_tui/src/session.rs` extracts `create_local_terminal_session`, the single + session-materialization helper shared by the login bootstrap (focused) and child creation + (background). It obtains the window and exit-summary context from + `TuiSessions`, so `TuiOrchestrationModel` holds no view-layer state. +- Tracking state is thin and session-dimensional only: + - `child_session_by_conversation: HashMap` + - `parent_sessions: HashSet` + Conversation lineage is always read from `BlocklistAIHistoryModel` (`children_by_parent`, + `parent_conversation_id`) — never mirrored here. This model adds only the conversation↔session + mapping that the shared layer doesn't know about, and is the future home/data source for session + navigation and child-status UI. +- `TuiTerminalSessionView` remains orchestration-ignorant; the coordinator + uses narrow accessors for its action model and controller. + +### Changed: `crates/warp_tui/src/agent_block.rs` — minimal orchestration transcript rendering + +- Replace the no-op arms for `MessagesReceivedFromAgents` and `EventsFromAgents` with simple + rendered lines: sender + subject for messages; sender + lifecycle transition for events. +- `TODO: add full status rendering based on MOCs.` marks the intentionally + minimal message/lifecycle lines. +- Suppress the `WaitForEvents` tool-call row ("Waiting for agent events…") entirely: the GUI + renders nothing for this action (its output match falls through to a no-op), so the TUI skips + emitting a transcript section for it rather than using the generic fallback label. + +### Non-goals + +- Remote and CLI-harness local children (explicit per-child failures, above), session + navigation/reveal, child cleanup UX on completion (children idle like GUI hidden panes; + lifecycle events surface state). + +## Testing and validation + +- Unit tests on `TuiOrchestrationModel` (per `rust-unit-tests`/`tui-testing` conventions): + - `CreateAgent` with a CLI harness or Remote mode resolves the executor's pending slot with the + per-child failure message and materializes no session. + - Sessions added after model init get their executors subscribed (the nesting invariant), + proven by a late-registered session's dispatch resolving. + - The native path's session materialization spawns a real PTY, so it is validated end-to-end + (below) rather than unit-tested against a mocked server client. +- Render-to-lines test: transcript renders message/lifecycle lines for + `MessagesReceivedFromAgents`/`EventsFromAgents` outputs. +- End-to-end manual validation per `tui-verify-change` (the key checkpoint that run_id + registration + SSE lifecycle work for TUI-spawned children): in `./script/run-tui`, prompt an + orchestration (`run_agents` with 2 local children) → accept → card shows launched; child + lifecycle (`in_progress`/`succeeded`) and completion messages appear in the parent transcript; + parent's tool result contains per-child `launched` entries with agent run ids. +- `./script/presubmit` before submit. + +## Parallelization + +Mostly none — the orchestration model, tui_export additions, and materializer are one coupled unit. +The transcript-rendering change (`agent_block.rs`) is independent and could be split to a parallel +local agent in a separate worktree, but it is ~50 LOC; not worth the coordination overhead. A +single agent implements this PR sequentially. + +## Follow-ups + +- CLI-harness local children (see TODO above). +- Richer transcript rendering for agent messages/events (see TODO above). +- Session navigation + child-status surface (next milestone; builds on `TuiSessions` focus and + `TuiOrchestrationModel` tracking). From 52e75f1a64288a48e9389ac38eb512bdcc8b322c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 13:38:02 -0400 Subject: [PATCH 29/40] abstract out child agent launch fns Co-Authored-By: Oz --- app/src/ai/blocklist/child_agent_launch.rs | 93 +++++ app/src/ai/blocklist/history_model.rs | 4 + app/src/ai/blocklist/history_model_tests.rs | 26 ++ app/src/ai/blocklist/mod.rs | 10 +- app/src/ai/llms.rs | 38 +- app/src/ai/llms_tests.rs | 23 ++ app/src/pane_group/child_agent/mod.rs | 48 +-- app/src/pane_group/pane/terminal_pane.rs | 212 +++++------ app/src/tui_export.rs | 26 +- crates/warp_tui/src/agent_block.rs | 4 +- crates/warp_tui/src/agent_block_tests.rs | 71 +++- crates/warp_tui/src/orchestration_model.rs | 333 +++++++++--------- .../warp_tui/src/orchestration_model_tests.rs | 49 ++- crates/warp_tui/src/session.rs | 6 +- crates/warp_tui/src/session_registry.rs | 4 + crates/warp_tui/src/terminal_session_view.rs | 63 +++- specs/code-1822-tui-local-children/TECH.md | 251 ++++++------- 17 files changed, 739 insertions(+), 522 deletions(-) create mode 100644 app/src/ai/blocklist/child_agent_launch.rs diff --git a/app/src/ai/blocklist/child_agent_launch.rs b/app/src/ai/blocklist/child_agent_launch.rs new file mode 100644 index 00000000000..998ca708d54 --- /dev/null +++ b/app/src/ai/blocklist/child_agent_launch.rs @@ -0,0 +1,93 @@ +//! Frontend-neutral preparation and settings propagation for local Oz children. +#[cfg(not(target_family = "wasm"))] +use std::future::Future; + +use warpui::{AppContext, EntityId, SingletonEntity as _}; +#[cfg(not(target_family = "wasm"))] +use { + crate::ai::ambient_agents::task::normalize_orchestrator_agent_name, + crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}, + crate::server::server_api::ServerApiProvider, +}; + +use crate::ai::llms::{LLMId, LLMPreferences}; +use crate::AIExecutionProfilesModel; + +/// Server-side state prepared before a frontend creates the child's surface. +#[cfg(not(target_family = "wasm"))] +pub struct PreparedLocalOzChildLaunch { + pub task_id: AmbientAgentTaskId, + pub conversation_name: String, +} + +/// Creates the server task row shared by the GUI hidden-pane and TUI +/// background-session launch paths. +#[cfg(not(target_family = "wasm"))] +pub fn prepare_local_oz_child_launch( + name: &str, + prompt: &str, + parent_run_id: Option<&str>, + ctx: &AppContext, +) -> impl Future> + 'static { + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let agent_name = normalize_orchestrator_agent_name(name); + let conversation_name = agent_name.clone().unwrap_or_default(); + let prompt = prompt.to_owned(); + let parent_run_id = parent_run_id.map(str::to_owned); + async move { + let task_id = ai_client + .create_agent_task( + prompt, + None, + parent_run_id, + Some(AgentConfigSnapshot { + name: agent_name, + ..Default::default() + }), + ) + .await?; + Ok(PreparedLocalOzChildLaunch { + task_id, + conversation_name, + }) + } +} + +/// Copies the parent's execution profile and effective base model to a child +/// surface before its first request is sent. +pub fn inherit_child_agent_settings( + parent_surface_id: EntityId, + child_surface_id: EntityId, + ctx: &mut AppContext, +) { + let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) + .active_profile(Some(parent_surface_id), ctx) + .id(); + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { + profiles.set_active_profile(child_surface_id, parent_profile_id, ctx); + }); + + let parent_base_model_id = LLMPreferences::as_ref(ctx) + .get_active_base_model(ctx, Some(parent_surface_id)) + .id + .clone(); + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); + }); +} + +/// Applies a non-empty run-wide model override after parent settings have +/// been inherited. +pub fn apply_child_agent_model_override( + child_surface_id: EntityId, + model_id: Option<&str>, + ctx: &mut AppContext, +) { + let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) else { + return; + }; + let model_id = LLMId::from(model_id); + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.set_agent_mode_llm_override(child_surface_id, model_id, ctx); + }); +} diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index b87cfbb95a9..c3254bdd863 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -2180,6 +2180,10 @@ impl BlocklistAIHistoryModel { self.all_conversations_metadata.remove(&conversation_id); self.conversations_by_id.remove(&conversation_id); + self.children_by_parent.retain(|_, child_ids| { + child_ids.retain(|child_id| *child_id != conversation_id); + !child_ids.is_empty() + }); if let Some(terminal_surface_id) = terminal_surface_id { if self diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 9157686d2b2..ed57f5f61de 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -1926,6 +1926,32 @@ fn test_set_parent_multiple_children() { }); } +#[test] +fn test_remove_child_conversation_cleans_parent_index() { + App::test((), |mut app| async move { + let terminal_view_id = EntityId::new(); + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let parent_id = history_model.update(&mut app, |model, ctx| { + model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let child_id = history_model.update(&mut app, |model, ctx| { + let child_id = model.start_new_conversation(terminal_view_id, false, false, false, ctx); + model.set_parent_for_conversation(child_id, parent_id); + child_id + }); + + history_model.update(&mut app, |model, ctx| { + model.remove_conversation(child_id, terminal_view_id, ctx); + }); + + history_model.read(&app, |model, _| { + assert!(model.conversation(&child_id).is_none()); + assert!(model.child_conversation_ids_of(&parent_id).is_empty()); + }); + }); +} + #[test] fn test_child_conversation_ids_of_unknown_parent() { App::test((), |app| async move { diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 51065df732d..b2ecf440c7f 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -2,6 +2,7 @@ mod action_model; pub mod agent_view; pub mod block; +mod child_agent_launch; pub mod code_block; mod context_model; mod controller; @@ -59,7 +60,10 @@ pub use action_model::{ pub use action_model::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot}; // Consumed by `tui_export` for the `warp_tui` frontend's child-agent // materializer, in addition to the GUI pane-group dispatch. -#[cfg_attr(target_family = "wasm", allow(unused_imports))] +#[cfg_attr( + any(target_family = "wasm", not(feature = "tui")), + allow(unused_imports) +)] pub use action_model::{ StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, @@ -68,6 +72,10 @@ pub use action_model::{ pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; pub use block::{keyboard_navigable_buttons, toggleable_items}; +pub use child_agent_launch::{apply_child_agent_model_override, inherit_child_agent_settings}; +#[cfg(not(target_family = "wasm"))] +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use child_agent_launch::{prepare_local_oz_child_launch, PreparedLocalOzChildLaunch}; #[cfg(not(feature = "tui"))] pub(crate) use context_model::block_context_from_terminal_model; #[cfg(feature = "tui")] diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 56f1880a8b9..db173b281fe 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -854,14 +854,6 @@ impl LLMPreferences { app: &AppContext, terminal_view_id: Option, ) -> &LLMInfo { - // In the TUI, the file-backed `agents.model` setting is the source of - // truth for the base model: it overrides both per-surface overrides - // and the cloud-synced execution profile, keeping the TUI's TOML file - // the single place the model is configured. - if settings_mode == settings::SettingsMode::Tui { - return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app); - } - if let Some(terminal_view_id) = terminal_view_id { let raw_override = self.base_llm_for_terminal_view.get(&terminal_view_id); if let Some(llm_id) = raw_override { @@ -873,6 +865,12 @@ impl LLMPreferences { } } + // In the TUI, the file-backed `agents.model` setting is the default + // for every surface. Explicit per-surface overrides still take + // precedence for orchestrated children launched with a model override. + if settings_mode == settings::SettingsMode::Tui { + return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app); + } let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); profile @@ -1497,8 +1495,8 @@ impl LLMPreferences { .is_some() } else { self.base_llm_for_terminal_view - .insert(terminal_view_id, preferred_llm_id.clone()); - true + .insert(terminal_view_id, preferred_llm_id.clone()) + != Some(preferred_llm_id.clone()) }; if changed { @@ -1507,6 +1505,26 @@ impl LLMPreferences { } } + /// Sets an explicit runtime override without normalizing it against the + /// execution profile. Orchestrated child runs use this because a requested + /// model equal to the profile default must still override the TUI's + /// file-backed model setting. + pub(crate) fn set_agent_mode_llm_override( + &mut self, + terminal_view_id: EntityId, + model_id: LLMId, + ctx: &mut ModelContext, + ) { + if self + .base_llm_for_terminal_view + .insert(terminal_view_id, model_id.clone()) + != Some(model_id) + { + self.trigger_snapshot_save(ctx); + ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); + } + } + /// Copies the raw per-pane Agent Mode override from `source_terminal_view_id` /// onto `new_terminal_view_id`, removing any existing override when the /// source has none. Combined with copying the source's execution profile, diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 5b46fae9c18..c2227f6914d 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -932,6 +932,29 @@ fn tui_agent_model_known_id_resolves_to_that_model() { }); } +#[test] +fn tui_surface_override_precedes_file_backed_default() { + App::test((), |app| async move { + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(UserWorkspaces::default_mock); + app.read(|app_ctx| { + let surface_id = EntityId::new(); + let mut preferences = preferences_for_tui_tests(); + preferences + .base_llm_for_terminal_view + .insert(surface_id, LLMId::from("auto")); + + let info = preferences.get_preferred_base_model_for_settings_mode( + settings::SettingsMode::Tui, + app_ctx, + Some(surface_id), + ); + + assert_eq!(info.id.as_str(), "auto"); + }); + }); +} + #[test] fn tui_agent_model_unknown_id_falls_back_to_the_default_model() { tui_agent_model_test(|preferences, app| { diff --git a/app/src/pane_group/child_agent/mod.rs b/app/src/pane_group/child_agent/mod.rs index ea22d1864c2..ac050925928 100644 --- a/app/src/pane_group/child_agent/mod.rs +++ b/app/src/pane_group/child_agent/mod.rs @@ -14,12 +14,12 @@ use crate::ai::agent::RenderableAIError; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::attachment_utils::attachments_download_dir; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequestId}; -use crate::ai::llms::LLMPreferences; +use crate::ai::blocklist::{ + inherit_child_agent_settings, BlocklistAIHistoryModel, StartAgentRequestId, +}; use crate::pane_group::{PaneGroup, PaneId}; use crate::terminal::shared_session::IsSharedSessionCreator; use crate::terminal::TerminalView; -use crate::AIExecutionProfilesModel; pub(crate) struct HiddenChildAgentConversation { pub terminal_view: ViewHandle, @@ -75,40 +75,6 @@ pub(crate) fn apply_hidden_child_agent_task_context( }); } -fn propagate_parent_agent_settings( - group: &PaneGroup, - parent_pane_id: PaneId, - child_terminal_view_id: EntityId, - ctx: &mut ViewContext, -) { - let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) else { - log::warn!( - "Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile" - ); - return; - }; - - let parent_view_id = parent_terminal_view.id(); - let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) - .active_profile(Some(parent_view_id), ctx) - .id(); - AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { - profiles.set_active_profile(child_terminal_view_id, parent_profile_id, ctx); - }); - - let parent_base_model_id = LLMPreferences::as_ref(ctx) - .get_active_base_model(ctx, Some(parent_view_id)) - .id - .clone(); - LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { - llm_prefs.update_preferred_agent_mode_llm( - &parent_base_model_id, - child_terminal_view_id, - ctx, - ); - }); -} - fn start_new_child_conversation( terminal_view_id: EntityId, name: String, @@ -154,7 +120,13 @@ pub(crate) fn create_hidden_child_agent_conversation( }; let terminal_view_id = new_terminal_view.id(); - propagate_parent_agent_settings(group, parent_pane_id, terminal_view_id, ctx); + if let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) { + inherit_child_agent_settings(parent_terminal_view.id(), terminal_view_id, ctx); + } else { + log::warn!( + "Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile" + ); + } if let Some(task_context) = task_context.as_ref() { apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx); } diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 0a37b6aebda..8e82d9ffc7c 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -29,9 +29,13 @@ use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, Harness use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}; use crate::ai::blocklist::agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +#[cfg(not(target_family = "wasm"))] +use crate::ai::blocklist::prepare_local_oz_child_launch; #[cfg(feature = "local_fs")] use crate::ai::blocklist::BlocklistAIHistoryEvent; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; +use crate::ai::blocklist::{ + apply_child_agent_model_override, BlocklistAIHistoryModel, StartAgentRequest, +}; use crate::ai::conversation_utils; use crate::ai::llms::LLMPreferences; use crate::ai::skills::SkillManager; @@ -122,23 +126,6 @@ fn serialize_proto_to_base64(message: &M) -> String { BASE64_STANDARD.encode(message.encode_to_vec()) } -/// Overrides the child's preferred agent-mode LLM. `None` is a no-op -/// (inherits the parent's LLM via `propagate_parent_agent_settings`). -#[cfg(not(target_family = "wasm"))] -fn apply_child_model_id_override( - child_terminal_view_id: EntityId, - model_id: Option<&str>, - ctx: &mut ViewContext, -) { - let Some(model_id) = model_id.map(str::trim).filter(|m| !m.is_empty()) else { - return; - }; - let llm_id: ai::LLMId = model_id.into(); - LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { - llm_prefs.update_preferred_agent_mode_llm(&llm_id, child_terminal_view_id, ctx); - }); -} - /// Returns the host terminal's `SharedSessionSource`, or `None` if it is /// not currently a shared-session creator. Reads the underlying /// `TerminalModel` directly via the host's `TerminalView`. @@ -1638,12 +1625,8 @@ fn launch_local_no_harness_child( model_id: Option, ctx: &mut ViewContext, ) { - let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client(); let request_id = request.id; - let agent_name = normalize_orchestrator_agent_name(&request.name); - let request_name = agent_name.clone().unwrap_or_default(); let parent_conversation_id = request.parent_conversation_id; - let parent_run_id = request.parent_run_id.clone(); let prompt = request.prompt.clone(); // Snapshot the host terminal's shared-session source before the spawn @@ -1653,118 +1636,107 @@ fn launch_local_no_harness_child( .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| host_terminal_shared_session_source_type(&view, ctx)); - let prompt_for_create = prompt.clone(); - let agent_name_for_create = agent_name.clone(); - let _ = ctx.spawn( - async move { - ai_client - .create_agent_task( - prompt_for_create, - None, - parent_run_id, - Some(AgentConfigSnapshot { - name: agent_name_for_create, - ..Default::default() + let launch = prepare_local_oz_child_launch( + &request.name, + &request.prompt, + request.parent_run_id.as_deref(), + ctx, + ); + let _ = ctx.spawn(launch, move |group, result, ctx| match result { + Ok(prepared) => { + let child_task_id = prepared.task_id; + let is_shared_session_creator = + inherit_share_for_local_child(host_source.as_ref(), child_task_id); + + if let Some(HiddenChildAgentConversation { + terminal_view: new_terminal_view, + terminal_view_id, + conversation_id, + .. + }) = create_hidden_child_agent_conversation( + group, + HiddenChildAgentConversationRequest { + parent_pane_id, + name: prepared.conversation_name.clone(), + parent_conversation_id, + orchestration_harness: Some(Harness::Oz), + env_vars: HashMap::new(), + task_context: Some(HiddenChildAgentTaskContext { + task_id: child_task_id, + working_dir: None, }), - ) - .await - }, - move |group, result, ctx| match result { - Ok(child_task_id) => { - let is_shared_session_creator = - inherit_share_for_local_child(host_source.as_ref(), child_task_id); - - if let Some(HiddenChildAgentConversation { - terminal_view: new_terminal_view, - terminal_view_id, - conversation_id, - .. - }) = create_hidden_child_agent_conversation( - group, - HiddenChildAgentConversationRequest { - parent_pane_id, - name: request_name.clone(), - parent_conversation_id, - orchestration_harness: Some(Harness::Oz), - env_vars: HashMap::new(), - task_context: Some(HiddenChildAgentTaskContext { - task_id: child_task_id, - working_dir: None, - }), - is_shared_session_creator, - }, - ctx, - ) { - apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); - - // Stamp the task id on the child conversation directly - // so the share-reporter in - // `local_tty/terminal_manager.rs` can resolve it from - // the selected conversation when the share handshake - // succeeds. Mirrors the pattern used by - // `OrchestrationViewerModel::apply_children_fetch`. - BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { - if let Some(conversation) = model.conversation_mut(&conversation_id) { - conversation.set_task_id(child_task_id); - } - model.record_new_conversation_request_complete( - request_id, - conversation_id, - ctx, - ); - }); - - new_terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view - .ai_controller() - .update(ctx, |controller, ctx| { - controller.send_agent_query_in_conversation( - prompt.clone(), - conversation_id, - ctx, - ); - }); + is_shared_session_creator, + }, + ctx, + ) { + apply_child_agent_model_override(terminal_view_id, model_id.as_deref(), ctx); + + // Stamp the task id on the child conversation directly + // so the share-reporter in + // `local_tty/terminal_manager.rs` can resolve it from + // the selected conversation when the share handshake + // succeeds. Mirrors the pattern used by + // `OrchestrationViewerModel::apply_children_fetch`. + BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { + if let Some(conversation) = model.conversation_mut(&conversation_id) { + conversation.set_task_id(child_task_id); + } + model.record_new_conversation_request_complete( + request_id, + conversation_id, + ctx, + ); + }); - terminal_view.enter_agent_view( - None, - Some(conversation_id), - AgentViewEntryOrigin::ChildAgent, - ctx, - ); - }); - } else { - let _ = create_error_child_agent_conversation( - group, - ErrorChildAgentConversationRequest { - parent_pane_id, - name: request_name, - parent_conversation_id, - request_id: Some(request_id), - orchestration_harness: Some(Harness::Oz), - error_message: - "Failed to create a hidden pane for the local child agent." - .to_string(), - }, + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view + .ai_controller() + .update(ctx, |controller, ctx| { + controller.send_agent_query_in_conversation( + prompt.clone(), + conversation_id, + ctx, + ); + }); + + terminal_view.enter_agent_view( + None, + Some(conversation_id), + AgentViewEntryOrigin::ChildAgent, ctx, ); - } - } - Err(error) => { + }); + } else { let _ = create_error_child_agent_conversation( group, ErrorChildAgentConversationRequest { parent_pane_id, - name: request_name, + name: prepared.conversation_name, parent_conversation_id, request_id: Some(request_id), orchestration_harness: Some(Harness::Oz), - error_message: format!("Failed to create local child task: {error}"), + error_message: "Failed to create a hidden pane for the local child agent." + .to_string(), }, ctx, ); } - }, - ); + } + Err(error) => { + let _ = create_error_child_agent_conversation( + group, + ErrorChildAgentConversationRequest { + parent_pane_id, + name: normalize_orchestrator_agent_name(&request.name).unwrap_or_default(), + parent_conversation_id, + request_id: Some(request_id), + orchestration_harness: Some(Harness::Oz), + error_message: format!("Failed to create local child task: {error}"), + }, + ctx, + ); + } + }); } /// Asynchronously prepares a local harness launch, then creates the @@ -1844,7 +1816,7 @@ fn launch_local_harness_child( }, ctx, ) { - apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); + apply_child_agent_model_override(terminal_view_id, model_id.as_deref(), ctx); BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { model.record_new_conversation_request_complete( diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 7205ecf13ce..38bdd5e363f 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -21,10 +21,11 @@ pub use crate::ai::agent::{ AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoId, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, AskUserQuestionResult, CancellationReason, - FileGlobV2Result, GrepResult, MessageId, RenderableAIError, RequestCommandOutputResult, - RunAgentsAgentOutcomeKind, RunAgentsResult, SearchCodebaseFailureReason, SearchCodebaseResult, - ServerOutputId, Shared, ShellCommandDelay, StartAgentExecutionMode, - SuggestNewConversationResult, SummarizationType, TodoOperation, UserQueryMode, + FileGlobV2Result, GrepResult, MessageId, ReceivedMessageDisplay, RenderableAIError, + RequestCommandOutputResult, RunAgentsAgentOutcomeKind, RunAgentsResult, + SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, ShellCommandDelay, + StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, TodoOperation, + UserQueryMode, }; pub use crate::ai::agent_conversations_model::{ query_conversation_entries, AgentConversationEntry, AgentConversationEntryId, @@ -62,12 +63,14 @@ pub use crate::ai::blocklist::orchestration_event_streamer::{ }; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ - block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, - BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, - InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, - RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, - StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, + apply_child_agent_model_override, block_context_from_terminal_model, + inherit_child_agent_settings, prepare_local_oz_child_launch, AIActionStatus, + BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, + InputTypeAutoDetectionSource, PolicyConfigUpdate, PreparedLocalOzChildLaunch, + RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, + ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, + StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, @@ -76,7 +79,6 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; -pub use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, @@ -108,8 +110,6 @@ pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; -pub use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot}; -pub use crate::server::server_api::ServerApiProvider; pub use crate::settings::AISettingsChangedEvent; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 84b7b95f7de..91f92a24d18 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -976,7 +976,8 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, - // TODO: add full status rendering based on MOCs. + + // TODO: add full status rendering for sub-agents. AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { for received in messages { sections.push(TuiAIBlockSection::PlainText(format!( @@ -992,6 +993,7 @@ impl TuiAIBlock { "Received {count} agent lifecycle event{plural}" ))); } + // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 44445b00687..a190ebad068 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -11,8 +11,9 @@ use warp::tui_export::{ AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, Appearance, LLMId, - MessageId, OutputStatusUpdateCallback, RequestCommandOutputResult, ServerOutputId, Shared, - SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, UserQueryMode, + MessageId, OutputStatusUpdateCallback, ReceivedMessageDisplay, RequestCommandOutputResult, + ServerOutputId, Shared, SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, + UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; @@ -288,6 +289,72 @@ fn agent_block_renders_multiple_tool_calls_in_order() { }); } +#[test] +fn orchestration_outputs_render_without_wait_for_events_tool_row() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let wait_action = AIAgentAction { + id: AIAgentActionId::from("wait-action".to_string()), + action: AIAgentActionType::WaitForEvents { + tool_call_id: "wait-call".to_string(), + idle_timeout_seconds: 600, + }, + task_id: TaskId::new("wait-task".to_string()), + requires_result: false, + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("m1", wait_action), + AIAgentOutputMessage { + id: MessageId::new("m2".to_string()), + message: AIAgentOutputMessageType::MessagesReceivedFromAgents { + messages: vec![ReceivedMessageDisplay { + message_id: "message-1".to_string(), + sender_agent_id: "researcher".to_string(), + addresses: vec!["lead".to_string()], + subject: "Investigation complete".to_string(), + message_body: "Found the issue".to_string(), + }], + }, + citations: Vec::new(), + }, + AIAgentOutputMessage { + id: MessageId::new("m3".to_string()), + message: AIAgentOutputMessageType::EventsFromAgents { + event_ids: vec!["event-1".to_string(), "event-2".to_string()], + }, + citations: Vec::new(), + }, + ]), + }, + ); + + app.read(|app_ctx| { + let block = block.as_ref(app_ctx); + assert_eq!( + block.sections(app_ctx), + vec![ + TuiAIBlockSection::PlainText( + "Received message from agent researcher: Investigation complete" + .to_string(), + ), + TuiAIBlockSection::PlainText("Received 2 agent lifecycle events".to_string(),), + ], + ); + assert_eq!( + render_block_lines(block, 80, app_ctx), + vec![ + "Received message from agent researcher: Investigation complete", + "Received 2 agent lifecycle events", + ], + ); + }); + }); +} + #[test] fn tool_call_row_glyph_and_colors_reflect_state() { App::test((), |app| async move { diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index e35e57ee439..7d4ececf0ca 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -13,18 +13,20 @@ //! CLI-harness and remote child requests resolve with an explicit failure. use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use warp::tui_export::{ + apply_child_agent_model_override, inherit_child_agent_settings, prepare_local_oz_child_launch, register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, - AIExecutionProfilesModel, AgentConfigSnapshot, AmbientAgentTaskId, BlocklistAIHistoryModel, - ConversationStatus, Harness, LLMId, LLMPreferences, RenderableAIError, ServerApiProvider, - StartAgentExecutionMode, StartAgentExecutorEvent, StartAgentRequest, + BlocklistAIHistoryModel, ConversationStatus, Harness, PreparedLocalOzChildLaunch, + RenderableAIError, StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; -use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ReadModel as _}; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle}; use crate::session::create_local_terminal_session; use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; +use crate::terminal_session_view::TuiTerminalSessionEvent; /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { @@ -32,8 +34,8 @@ pub(crate) struct TuiOrchestrationModel { /// only — conversation lineage is read from `BlocklistAIHistoryModel` /// (`children_by_parent` / `parent_conversation_id`), never mirrored. child_session_by_conversation: HashMap, - /// Sessions that have dispatched at least one child agent. - parent_sessions: HashSet, + /// Conversations whose event streams are consumed by each live session. + event_consumers_by_session: HashMap>, } impl Entity for TuiOrchestrationModel { @@ -48,53 +50,64 @@ impl TuiOrchestrationModel { /// run before any session is created. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { let sessions = TuiSessions::handle(ctx); - ctx.add_singleton_model(|ctx| { - ctx.subscribe_to_model(&sessions, Self::handle_sessions_event); - Self { - child_session_by_conversation: HashMap::new(), - parent_sessions: HashSet::new(), + let model = ctx.add_singleton_model(|_| Self { + child_session_by_conversation: HashMap::new(), + event_consumers_by_session: HashMap::new(), + }); + let model_for_sessions = model.clone(); + ctx.subscribe_to_model(&sessions, move |sessions, event, ctx| match event { + TuiSessionsEvent::SessionAdded(session_id) => { + let session_id = *session_id; + let Some(session_view) = sessions + .as_ref(ctx) + .session(session_id) + .map(|session| session.view().clone()) + else { + return; + }; + let model = model_for_sessions.clone(); + ctx.subscribe_to_view(&session_view, move |_, event, ctx| { + model.update(ctx, |model, ctx| { + model.handle_session_event(session_id, event, ctx); + }); + }); } - }) - } - - /// Wires a newly registered session's `StartAgentExecutor` to this model. - fn handle_sessions_event( - &mut self, - sessions: ModelHandle, - event: &TuiSessionsEvent, - ctx: &mut ModelContext, - ) { - let TuiSessionsEvent::SessionAdded(session_id) = event else { - return; - }; - let session_id = *session_id; - let Some(session_view) = ctx.read_model(&sessions, |sessions, _| { - sessions - .session(session_id) - .map(|session| session.view().clone()) - }) else { - return; - }; - let action_model = session_view.as_ref(ctx).ai_action_model().clone(); - let executor = ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)); - ctx.subscribe_to_model(&executor, move |me, _, event, ctx| { - me.handle_executor_event(session_id, event, ctx); + TuiSessionsEvent::SessionRemoved(session_id) => { + model_for_sessions.update(ctx, |model, ctx| { + model.handle_session_removed(*session_id, ctx); + }); + } + TuiSessionsEvent::FocusChanged(_) => {} }); + model } - fn handle_executor_event( + fn handle_session_event( &mut self, parent_session_id: TuiSessionId, - event: &StartAgentExecutorEvent, + event: &TuiTerminalSessionEvent, ctx: &mut ModelContext, ) { match event { - StartAgentExecutorEvent::CreateAgent(request) => { - self.dispatch_create_agent(parent_session_id, (**request).clone(), ctx); + TuiTerminalSessionEvent::StartAgentConversation { + request, + working_directory, + } => { + self.dispatch_create_agent( + parent_session_id, + (**request).clone(), + working_directory.clone(), + ctx, + ); } - StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + TuiTerminalSessionEvent::CleanupFailedChildLaunch { conversation_id } => { self.cleanup_failed_child(conversation_id, ctx); } + TuiTerminalSessionEvent::ExecuteCommand(_) + | TuiTerminalSessionEvent::InterruptPty + | TuiTerminalSessionEvent::WriteAgentInput { .. } + | TuiTerminalSessionEvent::WriteUserInput(_) + | TuiTerminalSessionEvent::Resize(_) => {} } } @@ -105,30 +118,28 @@ impl TuiOrchestrationModel { &mut self, parent_session_id: TuiSessionId, request: StartAgentRequest, + working_directory: Option, ctx: &mut ModelContext, ) { - // Dispatching a child makes the parent an orchestrator: register it - // as a streamer consumer so its SSE stream (child lifecycle + inbox - // messages) opens. The GUI gets this from the agent view's - // `ActiveAgentViewsModel` bridge, which the TUI does not have. - register_agent_event_consumer( - request.parent_conversation_id, - parent_session_id.surface_id(), - ctx, - ); match request.execution_mode.clone() { StartAgentExecutionMode::Local { harness_type: None, model_id, - } => self.launch_native_child(parent_session_id, request, model_id, ctx), + } => self.begin_local_oz_child_launch( + parent_session_id, + request, + model_id, + working_directory, + ctx, + ), StartAgentExecutionMode::Local { harness_type: Some(harness_type), .. } => { - // TODO(code-1822): support local CLI-harness children by - // reusing the frontend-neutral - // `prepare_local_harness_child_launch` command builder. - fail_child_request( + // Local non-oz children are not supported outside of dogfood in the GUI, + // and would be odd in the TUI. For now, we don't offer this option in the + // orchestration card, so this should never be reached. + self.fail_child_request( &request, format!( "Local {harness_type} child agents aren't supported in the Warp TUI yet." @@ -139,7 +150,7 @@ impl TuiOrchestrationModel { StartAgentExecutionMode::Remote { .. } => { // TODO(code-1822): remote children need a TUI materializer; // the GUI's spawn path is coupled to ambient-agent panes. - fail_child_request( + self.fail_child_request( &request, "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), ctx, @@ -148,94 +159,64 @@ impl TuiOrchestrationModel { } } - /// Native (Oz) local child: eagerly creates the server task row (which - /// activates messaging/lifecycle for the child), then materializes the - /// background session, mirroring the GUI's - /// `launch_local_no_harness_child`. - fn launch_native_child( + /// Starts server-side task creation. The completion callback creates the + /// TUI session only after the task has a stable run id. + fn begin_local_oz_child_launch( &mut self, parent_session_id: TuiSessionId, request: StartAgentRequest, model_id: Option, + working_directory: Option, ctx: &mut ModelContext, ) { - let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); - let agent_name = Some(request.name.trim().to_owned()).filter(|name| !name.is_empty()); - let prompt = request.prompt.clone(); - let parent_run_id = request.parent_run_id.clone(); - ctx.spawn( - async move { - ai_client - .create_agent_task( - prompt, - None, - parent_run_id, - Some(AgentConfigSnapshot { - name: agent_name, - ..Default::default() - }), - ) - .await - }, - move |me, result, ctx| match result { - Ok(task_id) => me.materialize_native_child( - parent_session_id, - &request, - model_id.as_deref(), - task_id, - ctx, - ), - Err(error) => fail_child_request( - &request, - format!("Failed to create local child task: {error}"), - ctx, - ), - }, + let launch = prepare_local_oz_child_launch( + &request.name, + &request.prompt, + request.parent_run_id.as_deref(), + ctx, ); + ctx.spawn(launch, move |me, result, ctx| match result { + Ok(prepared) => me.create_local_oz_child_session( + parent_session_id, + &request, + model_id.as_deref(), + working_directory, + prepared, + ctx, + ), + Err(error) => me.fail_child_request( + &request, + format!("Failed to create local child task: {error}"), + ctx, + ), + }); } - /// Creates the background session, links the child conversation to the - /// parent, echoes it back to the executor, and dispatches the prompt. - fn materialize_native_child( + /// Creates the background terminal session and child conversation for a + /// prepared task, then sends the child's first prompt. + fn create_local_oz_child_session( &mut self, parent_session_id: TuiSessionId, request: &StartAgentRequest, model_id: Option<&str>, - task_id: AmbientAgentTaskId, + working_directory: Option, + prepared: PreparedLocalOzChildLaunch, ctx: &mut ModelContext, ) { let sessions = TuiSessions::handle(ctx); - let (session_id, session_view) = create_local_terminal_session(&sessions, false, ctx); + let (session_id, session_view) = + create_local_terminal_session(&sessions, false, working_directory, ctx); let child_surface_id = session_id.surface_id(); + let task_id = prepared.task_id; - // Inherit the parent's execution profile and base model, then apply - // the run-wide model override — the TUI counterpart of the GUI's - // `propagate_parent_agent_settings` + `apply_child_model_id_override`. let parent_surface_id = parent_session_id.surface_id(); - let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) - .active_profile(Some(parent_surface_id), ctx) - .id(); - AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { - profiles.set_active_profile(child_surface_id, parent_profile_id, ctx); - }); - let parent_base_model_id = LLMPreferences::as_ref(ctx) - .get_active_base_model(ctx, Some(parent_surface_id)) - .id - .clone(); - LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { - prefs.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); - }); - if let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) { - let llm_id: LLMId = model_id.into(); - LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { - prefs.update_preferred_agent_mode_llm(&llm_id, child_surface_id, ctx); - }); - } + inherit_child_agent_settings(parent_surface_id, child_surface_id, ctx); + apply_child_agent_model_override(child_surface_id, model_id, ctx); let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { let conversation_id = history.start_new_child_conversation( child_surface_id, - request.name.clone(), + prepared.conversation_name, request.parent_conversation_id, Some(Harness::Oz), ctx, @@ -250,20 +231,16 @@ impl TuiOrchestrationModel { conversation_id }); - // Register the child as a streamer consumer so its own inbox stream - // (parent→child messages, wake events) opens. - register_agent_event_consumer(conversation_id, child_surface_id, ctx); + self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); + self.register_event_consumer(session_id, conversation_id, ctx); - let ai_controller = session_view.as_ref(ctx).ai_controller().clone(); let prompt = request.prompt.clone(); - ai_controller.update(ctx, |controller, ctx| { - controller.set_ambient_agent_task_id(Some(task_id), ctx); - controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); + session_view.update(ctx, |view, ctx| { + view.start_orchestrated_child(task_id, prompt, conversation_id, ctx); }); self.child_session_by_conversation .insert(conversation_id, session_id); - self.parent_sessions.insert(parent_session_id); ctx.notify(); } @@ -274,48 +251,72 @@ impl TuiOrchestrationModel { conversation_id: &AIConversationId, ctx: &mut ModelContext, ) { - let Some(session_id) = self.child_session_by_conversation.remove(conversation_id) else { - return; - }; - unregister_agent_event_consumer(*conversation_id, session_id.surface_id(), ctx); - TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { - sessions.remove_session(session_id, ctx); + let terminal_surface_id = BlocklistAIHistoryModel::as_ref(ctx) + .terminal_surface_id_for_conversation(conversation_id); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.delete_conversation(*conversation_id, terminal_surface_id, ctx); }); + if let Some(session_id) = self.child_session_by_conversation.remove(conversation_id) { + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.remove_session(session_id, ctx); + }); + } ctx.notify(); } -} -/// Resolves a child request as failed without materializing a session: -/// creates the child conversation on a synthetic surface, marks it errored, -/// then echoes it to the executor — which completes the pending slot with -/// the error message instead of hanging into the spawn timeout. -fn fail_child_request( - request: &StartAgentRequest, - message: String, - ctx: &mut ModelContext, -) { - log::warn!( - "Failing TUI child agent request '{}': {message}", - request.name - ); - let surface_id = EntityId::new(); - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - let conversation_id = history.start_new_child_conversation( - surface_id, - request.name.clone(), - request.parent_conversation_id, - None, - ctx, - ); - history.update_conversation_status_with_error( - surface_id, - conversation_id, - ConversationStatus::Error, - Some(RenderableAIError::other(message, false)), - ctx, + /// Resolves a child request as failed without creating a TUI session. + fn fail_child_request( + &mut self, + request: &StartAgentRequest, + message: String, + ctx: &mut ModelContext, + ) { + log::warn!( + "Failing TUI child agent request '{}': {message}", + request.name ); - history.record_new_conversation_request_complete(request.id, conversation_id, ctx); - }); + let surface_id = EntityId::new(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + surface_id, + request.name.trim().to_owned(), + request.parent_conversation_id, + None, + ctx, + ); + history.update_conversation_status_with_error( + surface_id, + conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other(message, false)), + ctx, + ); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + }); + } + + fn register_event_consumer( + &mut self, + session_id: TuiSessionId, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + register_agent_event_consumer(conversation_id, session_id.surface_id(), ctx); + self.event_consumers_by_session + .entry(session_id) + .or_default() + .insert(conversation_id); + } + + fn handle_session_removed(&mut self, session_id: TuiSessionId, ctx: &mut ModelContext) { + if let Some(conversation_ids) = self.event_consumers_by_session.remove(&session_id) { + for conversation_id in conversation_ids { + unregister_agent_event_consumer(conversation_id, session_id.surface_id(), ctx); + } + } + self.child_session_by_conversation + .retain(|_, child_session_id| *child_session_id != session_id); + } } #[cfg(test)] diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index b2ec7348cc2..b33eb067628 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,6 +1,6 @@ use warp::tui_export::{ - register_tui_session_view_test_singletons, StartAgentExecutionMode, StartAgentExecutor, - StartAgentOutcome, + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + StartAgentExecutionMode, StartAgentExecutor, StartAgentOutcome, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; @@ -55,10 +55,7 @@ fn add_dispatching_session( sessions.add_session(session.clone(), manager, focus, ctx) }); add_active_test_conversation(app, session_id.surface_id()); - let executor = app.read(|ctx| { - let action_model = session.as_ref(ctx).ai_action_model().clone(); - ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)) - }); + let executor = app.read(|ctx| session.as_ref(ctx).start_agent_executor_for_test(ctx)); (session_id, executor) } @@ -71,7 +68,7 @@ fn dispatch_and_recv( session_id: TuiSessionId, executor: &ModelHandle, execution_mode: StartAgentExecutionMode, -) -> StartAgentOutcome { +) -> (AIConversationId, StartAgentOutcome) { let parent_conversation_id = app.read(|ctx| { warp::tui_export::BlocklistAIHistoryModel::as_ref(ctx) .active_conversation(session_id.surface_id()) @@ -90,9 +87,12 @@ fn dispatch_and_recv( ctx, ) }); - receiver - .try_recv() - .expect("unsupported-mode dispatches resolve before the update returns") + ( + parent_conversation_id, + receiver + .try_recv() + .expect("unsupported-mode dispatches resolve before the update returns"), + ) } fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { @@ -106,13 +106,33 @@ fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { } } +fn assert_failed_launch_cleaned_up( + app: &App, + fixture: &OrchestrationFixture, + parent_conversation_id: AIConversationId, + expected_session_count: usize, +) { + app.read(|ctx| { + let history = BlocklistAIHistoryModel::as_ref(ctx); + assert!(history + .child_conversation_ids_of(&parent_conversation_id) + .is_empty()); + assert!(TuiOrchestrationModel::as_ref(ctx) + .event_consumers_by_session + .is_empty()); + }); + assert_eq!( + app.read_model(&fixture.sessions, |sessions, _| sessions.len()), + expected_session_count, + ); +} #[test] fn local_harness_children_fail_cleanly() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); - let outcome = dispatch_and_recv( + let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, &fixture, session_id, @@ -123,6 +143,7 @@ fn local_harness_children_fail_cleanly() { }, ); assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); }); } @@ -132,7 +153,7 @@ fn remote_children_fail_cleanly() { let fixture = orchestration_fixture(&mut app); let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); - let outcome = dispatch_and_recv( + let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, &fixture, session_id, @@ -149,6 +170,7 @@ fn remote_children_fail_cleanly() { }, ); assert_error_containing(outcome, "Cloud child agents aren't supported"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); }); } @@ -161,7 +183,7 @@ fn sessions_registered_after_init_are_wired_for_orchestration() { let _ = add_dispatching_session(&mut app, &fixture, true); let (late_session_id, late_executor) = add_dispatching_session(&mut app, &fixture, false); - let outcome = dispatch_and_recv( + let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, &fixture, late_session_id, @@ -174,5 +196,6 @@ fn sessions_registered_after_init_are_wired_for_orchestration() { // A resolved outcome proves the late session's executor is wired to // the orchestration model (an unwired executor would never resolve). assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 2); }); } diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 9ebc446bf5d..9082e454888 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -166,7 +166,8 @@ fn create_terminal_session_after_login( } let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); - let (_, surface) = create_local_terminal_session(sessions, true, ctx); + let (_, surface) = + create_local_terminal_session(sessions, true, std::env::current_dir().ok(), ctx); if let Some(token) = resume_token { surface.update(ctx, |view, ctx| { view.restore_conversation( @@ -183,6 +184,7 @@ fn create_terminal_session_after_login( pub(crate) fn create_local_terminal_session( sessions: &ModelHandle, focus: bool, + startup_directory: Option, ctx: &mut AppContext, ) -> (TuiSessionId, ViewHandle) { let (window_id, exit_summary) = sessions.read(ctx, |sessions, _| sessions.surface_context()); @@ -190,7 +192,7 @@ pub(crate) fn create_local_terminal_session( // TUI does not render a separate banner surface. let banner = ctx.add_model(|_| BannerState::default()); let manager = LocalTtyTerminalManager::::create_tui_model( - std::env::current_dir().ok(), + startup_directory, HashMap::::from_iter(std::env::vars_os()), IsSharedSessionCreator::No, None, diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index ab6d3e73b99..90c815a4061 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -184,6 +184,10 @@ impl TuiSessions { pub(crate) fn is_empty(&self) -> bool { self.sessions.is_empty() } + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.sessions.len() + } /// Consumes the startup resume token. pub(crate) fn take_resume_token(&mut self) -> Option { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 84be3f529f6..5a7e2a07eaf 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,6 +1,7 @@ //! Authenticated terminal-session TUI surface. use std::borrow::Cow; use std::collections::HashMap; +use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -11,6 +12,8 @@ use instant::Instant; use parking_lot::FairMutex; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::settings::{AISettings, AISettingsChangedEvent}; +#[cfg(test)] +use warp::tui_export::StartAgentExecutor; use warp::tui_export::{ block_context_from_terminal_model, build_slash_command_mixer, detect_possible_git_repo, export_conversation_markdown, prepare_conversation_block_restoration, @@ -28,11 +31,12 @@ use warp::tui_export::{ GitStatusMetadata, LLMId, LLMPreferences, LLMPreferencesEvent, ModelEvent, ParsedSlashCommandInput, PtyIntent, PtyIntentEvent, RepoDetectionSessionType, RepoDetectionSource, ServerConversationToken, ShellCommandExecutorEvent, SizeInfo, SizeUpdate, - SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, StaticCommand, - TerminalModel, TerminalSurface, TerminalSurfaceInit, TranscriptScope, TuiMcpAction, - TuiMcpManager, TuiSlashCommand, TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, - TuiZeroStateDataSource, UserTakeOverReason, COMMAND_REGISTRY, - LOCAL_SKILLS_REMOTE_EXECUTION_ERROR_MESSAGE, WAKEUP_THROTTLE_PERIOD, + SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, + StartAgentExecutorEvent, StartAgentRequest, StaticCommand, TerminalModel, TerminalSurface, + TerminalSurfaceInit, TranscriptScope, TuiMcpAction, TuiMcpManager, TuiSlashCommand, + TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, TuiZeroStateDataSource, + UserTakeOverReason, COMMAND_REGISTRY, LOCAL_SKILLS_REMOTE_EXECUTION_ERROR_MESSAGE, + WAKEUP_THROTTLE_PERIOD, }; use warp_core::features::FeatureFlag; use warp_core::settings::Setting; @@ -107,6 +111,13 @@ pub(crate) enum TuiTerminalSessionEvent { }, WriteUserInput(Cow<'static, [u8]>), Resize(SizeUpdate), + StartAgentConversation { + request: Box, + working_directory: Option, + }, + CleanupFailedChildLaunch { + conversation_id: AIConversationId, + }, } impl PtyIntentEvent for TuiTerminalSessionEvent { @@ -120,6 +131,7 @@ impl PtyIntentEvent for TuiTerminalSessionEvent { }), Self::WriteUserInput(bytes) => Some(PtyIntent::WriteBytes(bytes.clone())), Self::Resize(size_update) => Some(PtyIntent::Resize(*size_update)), + Self::StartAgentConversation { .. } | Self::CleanupFailedChildLaunch { .. } => None, } } } @@ -373,6 +385,13 @@ impl TuiTerminalSessionView { }); } + #[cfg(test)] + pub(crate) fn start_agent_executor_for_test( + &self, + ctx: &AppContext, + ) -> ModelHandle { + self.ai_action_model.as_ref(ctx).start_agent_executor(ctx) + } fn detach_cli_subagent_view( &mut self, block_id: &BlockId, @@ -601,6 +620,20 @@ impl TuiTerminalSessionView { ctx, ) }); + let start_agent_executor = action_model.as_ref(ctx).start_agent_executor(ctx); + ctx.subscribe_to_model(&start_agent_executor, |view, _, event, ctx| match event { + StartAgentExecutorEvent::CreateAgent(request) => { + ctx.emit(TuiTerminalSessionEvent::StartAgentConversation { + request: request.clone(), + working_directory: view.current_working_directory(ctx).map(PathBuf::from), + }); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + ctx.emit(TuiTerminalSessionEvent::CleanupFailedChildLaunch { + conversation_id: *conversation_id, + }); + } + }); let ai_controller = ctx.add_model(|ctx| { BlocklistAIController::new( ai_input_model.clone(), @@ -1030,15 +1063,21 @@ impl TuiTerminalSessionView { } } - /// The action model driving this session's agent tool execution. - pub(crate) fn ai_action_model(&self) -> &ModelHandle { - &self.ai_action_model + /// Starts the first request for a child conversation hosted by this + /// background session. + pub(crate) fn start_orchestrated_child( + &mut self, + task_id: warp::tui_export::AmbientAgentTaskId, + prompt: String, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + self.ai_controller.update(ctx, |controller, ctx| { + controller.set_ambient_agent_task_id(Some(task_id), ctx); + controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); + }); } - /// The controller used to submit prompts into this session. - pub(crate) fn ai_controller(&self) -> &ModelHandle { - &self.ai_controller - } /// The active front-of-queue blocking interaction, if any. fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 41fb0787dc3..dc5254c739f 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -1,147 +1,110 @@ # TECH: `TuiOrchestrationModel` + background local child agents - -Second PR of the two-PR stack, building on the full-view `TuiSessions` -container. Accepting a local `run_agents` request in the TUI spawns native -child agents in background sessions and makes the run observable in the -parent transcript. - -## Context - -The shared orchestration engine is frontend-neutral and already partially wired into the TUI: - -- The TUI's `TuiRunAgentsCardView` drives the shared `RunAgentsExecutor` accept path - ([crates/warp_tui/src/run_agents_card_view.rs:200-236 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/run_agents_card_view.rs#L200-L236)). -- `RunAgentsExecutor` fans out per child to `StartAgentExecutor::dispatch`, which emits - `StartAgentExecutorEvent::CreateAgent(Box)` and awaits materialization - ([app/src/ai/blocklist/action_model/execute/start_agent.rs:499,532-566 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L532-L566)). - Nothing in `crates/warp_tui` subscribes to that event today, and `StartAgentExecutor` is not yet - exported via `app/src/tui_export.rs`. -- In the GUI, materialization is `TerminalView::handle_start_agent_executor_event` - ([app/src/terminal/view.rs:7630-7650 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/terminal/view.rs#L7630-L7650)) - → `PaneGroup::create_hidden_child_agent_conversation` - ([app/src/pane_group/child_agent/mod.rs:130-179 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/child_agent/mod.rs#L130-L179)) — - pane-tree machinery the TUI cannot reuse. The frontend-neutral pieces it calls are reusable: - `BlocklistAIHistoryModel::start_new_child_conversation` - ([history_model.rs:508-544 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/history_model.rs#L508-L544)), - the `children_by_parent` lineage index, `ai_client.create_agent_task`, and - `StartAgentExecutor`'s self-completion off `BlocklistAIHistoryEvent`s - ([start_agent.rs:144-310 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L144-L310)). -- Messaging/lifecycle needs almost no TUI work: `OrchestrationEventStreamer`, - `OrchestrationEventService`, `LocalAgentTaskSyncModel`, and `MessageHydrator` are - frontend-neutral singletons already registered by the shared bootstrap the TUI binary runs - ([app/src/lib.rs:2051-2057 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/lib.rs#L2051-L2057)). - Transport is server-mediated (SSE + RPC) even between local agents in one process. The one - gap (found in live verification): the streamer only opens a conversation's SSE stream once a - *consumer* registers for it (`register_agent_event_consumer`), which the GUI drives from the - agent view's `ActiveAgentViewsModel` bridge — a surface the TUI lacks. Without it, children - sent messages but the parent never received them. `TuiOrchestrationModel` therefore registers - the parent as a consumer on dispatch and each child on materialization (and unregisters on - failed-launch cleanup). -- The TUI transcript currently drops orchestration traffic on the floor: - `MessagesReceivedFromAgents`/`EventsFromAgents` are explicit no-ops - ([crates/warp_tui/src/agent_block.rs:670-671 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/agent_block.rs#L670-L671)). - -Scope decisions (from the architecture discussion): native Oz children only; CLI-harness children -(Claude/Codex/OpenCode) are a follow-up, and remote children (whose GUI spawn path is coupled to -ambient-agent panes) also resolve as explicit per-child failures for now. Children stay invisible -— no navigation, no status bar; `TuiSessions` focus never moves off session 0 in this PR. -Following the GUI's hidden-pane prior art, each child session gets a full (backgrounded) -`TuiTerminalSessionView` retained by `TuiSessions`; the view doubles as the -terminal manager's PTY surface. - -## Proposed changes - -### Changed: `app/src/tui_export.rs` - -- Re-export `StartAgentExecutor`, `StartAgentExecutorEvent`, and `StartAgentRequest` (plus any - associated outcome types needed to fail a child), mirroring the existing - `RunAgentsExecutor` exports. - -### New: `crates/warp_tui/src/orchestration_model.rs` — `TuiOrchestrationModel` - -A `SingletonEntity` owning all TUI orchestration coordination: - -- Subscribes to `TuiSessions::SessionAdded` and, for each registered session, subscribes to that - session's `StartAgentExecutor`. Because all session creation flows through `TuiSessions` - (PR 2 invariant), every session — including children, enabling future nesting — is wired. -- On `StartAgentExecutorEvent::CreateAgent`, dispatches on the request mirroring the GUI's - per-mode dispatch: - - **Native (no harness)**: `ai_client.create_agent_task` (server task row → run_id, which is - what activates messaging/lifecycle for the child) → create a background session via the - shared `create_local_terminal_session` helper (PTY manager + full backgrounded view, - registered unfocused with `TuiSessions`) → inherit the parent's execution - profile/base model and apply the run-wide model override → - `BlocklistAIHistoryModel::start_new_child_conversation` + `set_task_id` → - `record_new_conversation_request_complete` (echoes the child `AIConversationId` so - `StartAgentExecutor` resolves its pending slot) → send the child's prompt via the child - session's `BlocklistAIController::send_agent_query_in_conversation`. - - **CLI-harness (Claude/Codex/OpenCode) and Remote**: resolve the pending slot with a clear - per-child failure outcome — a clean `failed` entry in the `launched` result rather than a - spawn-timeout hang. The failure path creates the child conversation on a synthetic surface, - marks it `Error`, and echoes it to the executor (which then also emits - `CleanupFailedChildLaunch`; the model tears down any mapped child session on that event). - TODO(code-1822): implement CLI-harness children by reusing the frontend-neutral - `prepare_local_harness_child_launch` - ([app/src/pane_group/pane/local_harness_launch.rs:158 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/pane/local_harness_launch.rs#L158)), - and remote children with a TUI-native spawn path. -- `crates/warp_tui/src/session.rs` extracts `create_local_terminal_session`, the single - session-materialization helper shared by the login bootstrap (focused) and child creation - (background). It obtains the window and exit-summary context from - `TuiSessions`, so `TuiOrchestrationModel` holds no view-layer state. -- Tracking state is thin and session-dimensional only: - - `child_session_by_conversation: HashMap` - - `parent_sessions: HashSet` - Conversation lineage is always read from `BlocklistAIHistoryModel` (`children_by_parent`, - `parent_conversation_id`) — never mirrored here. This model adds only the conversation↔session - mapping that the shared layer doesn't know about, and is the future home/data source for session - navigation and child-status UI. -- `TuiTerminalSessionView` remains orchestration-ignorant; the coordinator - uses narrow accessors for its action model and controller. - -### Changed: `crates/warp_tui/src/agent_block.rs` — minimal orchestration transcript rendering - -- Replace the no-op arms for `MessagesReceivedFromAgents` and `EventsFromAgents` with simple - rendered lines: sender + subject for messages; sender + lifecycle transition for events. -- `TODO: add full status rendering based on MOCs.` marks the intentionally - minimal message/lifecycle lines. -- Suppress the `WaitForEvents` tool-call row ("Waiting for agent events…") entirely: the GUI - renders nothing for this action (its output match falls through to a no-op), so the TUI skips - emitting a transcript section for it rather than using the generic fallback label. - -### Non-goals - -- Remote and CLI-harness local children (explicit per-child failures, above), session - navigation/reveal, child cleanup UX on completion (children idle like GUI hidden panes; - lifecycle events surface state). - +This change builds on the full-view `TuiSessions` container. Accepting a local +`run_agents` request in the TUI creates native Oz children in background terminal +sessions while the parent remains focused and receives orchestration traffic. +## Architecture +### Shared local Oz launch contract +The GUI and TUI share the frontend-neutral parts of native child launch through +`app/src/ai/blocklist/child_agent_launch.rs (16-93)`: +- `prepare_local_oz_child_launch` normalizes the child name and creates the server task row with + the prompt and parent run id. The returned `PreparedLocalOzChildLaunch` contains the task id and + normalized conversation name needed by the frontend-specific materializer. +- `inherit_child_agent_settings` copies the parent's execution profile and effective base model to + the child surface. +- `apply_child_agent_model_override` installs a non-empty run-wide model override after inheritance. +The GUI hidden-pane path uses the same helpers in +`app/src/pane_group/pane/terminal_pane.rs (1528-1705)`. The TUI therefore does +not export or directly compose `AIClient`, `AgentConfigSnapshot`, +`ServerApiProvider`, or `AIExecutionProfilesModel`. +### Session event bridge +Each `TuiTerminalSessionView` owns its `StartAgentExecutor` subscription and +converts executor events into semantic session events +(`crates/warp_tui/src/terminal_session_view.rs (111-136, 599-614)`): +- `CreateAgent` emits `StartAgentConversation` with the request and a snapshot of the parent's + current working directory. +- `CleanupFailedChildLaunch` emits the corresponding cleanup event. +`TuiOrchestrationModel::register` runs before the first session is created. It +subscribes to `TuiSessions` and, for every `SessionAdded`, subscribes to that +session view through `AppContext`; `SessionRemoved` tears down session-owned +streamer consumers (`crates/warp_tui/src/orchestration_model.rs (47-112)`). +Because every session, including a background child session, is registered in +`TuiSessions`, children are also wired to launch descendants. +### Native child launch +`TuiOrchestrationModel` separates task creation from TUI surface creation +(`crates/warp_tui/src/orchestration_model.rs (114-238)`): +1. `begin_local_oz_child_launch` starts shared server-task preparation. +2. `create_local_oz_child_session` creates an unfocused terminal session using the parent's + captured working directory. +3. The child inherits the parent's execution profile and effective base model, then receives the + requested run-wide model override. +4. `BlocklistAIHistoryModel::start_new_child_conversation` establishes lineage on the child + surface. The task id is stamped before `record_new_conversation_request_complete` resolves the + pending `StartAgentExecutor` slot. +5. The coordinator registers event consumers for the parent and child conversations. +6. `TuiTerminalSessionView::start_orchestrated_child` attaches the task id to the child controller + and sends the first prompt (`crates/warp_tui/src/terminal_session_view.rs (1034-1049)`). +`create_local_terminal_session` is the single session factory for both the +focused bootstrap session and background children. Its explicit startup-directory +parameter preserves the parent's current directory for child shells +(`crates/warp_tui/src/session.rs (152-217)`). +### Model selection +TUI `agents.model` remains the default model for ordinary TUI surfaces. +Explicit per-surface overrides are resolved first so a child `model_id` always +wins, including when it equals the execution profile default +(`app/src/ai/llms.rs (844-878, 1504-1526)`). +### Streamer and session ownership +The coordinator stores only frontend-specific runtime ownership +(`crates/warp_tui/src/orchestration_model.rs (31-39)`): +- `child_session_by_conversation` maps a child conversation to its background session. +- `event_consumers_by_session` records which conversation streams each live session consumes. +Conversation lineage remains canonical in `BlocklistAIHistoryModel`. Removing a +conversation also removes its id from `children_by_parent` +(`app/src/ai/blocklist/history_model.rs (2112-2182)`). +### Unsupported modes and failed launch cleanup +Local CLI-harness and remote requests resolve as explicit per-child failures +instead of waiting for the spawn timeout +(`crates/warp_tui/src/orchestration_model.rs (114-159, 239-296)`). +The failure path creates an errored child conversation on a synthetic surface +and echoes its id to `StartAgentExecutor`. The resulting cleanup event: +- deletes the child conversation and persisted state, +- removes it from the parent-child topology, +- removes any mapped background session, and +- unregisters consumers when the session is removed. +This leaves no dead child conversation, session, or streamer registration. +### Transcript rendering +`crates/warp_tui/src/agent_block.rs (775-888)`: +- suppresses the `WaitForEvents` tool-call row, matching the GUI, +- renders sender and subject for each `MessagesReceivedFromAgents` entry, and +- renders the number of received lifecycle events for `EventsFromAgents`. +Lifecycle output currently contains event ids rather than event details, so the +TUI intentionally renders a count rather than a sender/status transition. +## Exports +`app/src/tui_export.rs (52-75)` exposes the shared child-launch functions and +prepared result plus the `StartAgentExecutor` request/event/outcome types needed +by the TUI surface bridge. Server-client and execution-profile implementation +types remain behind the shared launch API. +## Non-goals +- Local CLI-harness children (Claude, Codex, OpenCode, Gemini). +- Remote/cloud child materialization. +- Navigation to or revealing background child sessions. +- Removing completed child sessions; successful children remain retained like GUI hidden panes. +- Rich message bodies and per-event lifecycle status rendering. ## Testing and validation - -- Unit tests on `TuiOrchestrationModel` (per `rust-unit-tests`/`tui-testing` conventions): - - `CreateAgent` with a CLI harness or Remote mode resolves the executor's pending slot with the - per-child failure message and materializes no session. - - Sessions added after model init get their executors subscribed (the nesting invariant), - proven by a late-registered session's dispatch resolving. - - The native path's session materialization spawns a real PTY, so it is validated end-to-end - (below) rather than unit-tested against a mocked server client. -- Render-to-lines test: transcript renders message/lifecycle lines for - `MessagesReceivedFromAgents`/`EventsFromAgents` outputs. -- End-to-end manual validation per `tui-verify-change` (the key checkpoint that run_id - registration + SSE lifecycle work for TUI-spawned children): in `./script/run-tui`, prompt an - orchestration (`run_agents` with 2 local children) → accept → card shows launched; child - lifecycle (`in_progress`/`succeeded`) and completion messages appear in the parent transcript; - parent's tool result contains per-child `launched` entries with agent run ids. -- `./script/presubmit` before submit. - -## Parallelization - -Mostly none — the orchestration model, tui_export additions, and materializer are one coupled unit. -The transcript-rendering change (`agent_block.rs`) is independent and could be split to a parallel -local agent in a separate worktree, but it is ~50 LOC; not worth the coordination overhead. A -single agent implements this PR sequentially. - +- `crates/warp_tui/src/orchestration_model_tests.rs (121-199)` verifies that local-harness and + remote requests resolve with explicit failures while leaving no child topology, extra session, + or event-consumer state. It also proves that sessions added after coordinator initialization are + wired for orchestration. +- `crates/warp_tui/src/agent_block_tests.rs (290-362)` renders orchestration messages and lifecycle + counts while asserting that `WaitForEvents` contributes no tool row. +- `app/src/ai/llms_tests.rs (936-959)` verifies that an explicit surface override precedes the TUI + file-backed default. +- `app/src/ai/blocklist/history_model_tests.rs (1872-1897)` verifies that removing a child + conversation cleans the parent index. +- `cargo check -p warp_tui` passes. +- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` passes. +- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` passes. ## Follow-ups - -- CLI-harness local children (see TODO above). -- Richer transcript rendering for agent messages/events (see TODO above). -- Session navigation + child-status surface (next milestone; builds on `TuiSessions` focus and - `TuiOrchestrationModel` tracking). +- Add local CLI-harness children by reusing the existing local-harness preparation path. +- Add a TUI-native remote child materializer. +- Add richer received-message and lifecycle-event rendering. +- Add child-session navigation and status UI on top of the retained `TuiSessions` entries. From 8179fcafe7a096ef3668cb85a39bcd3e84c5dd87 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 14:01:58 -0400 Subject: [PATCH 30/40] remove unnecessary test helper Co-Authored-By: Oz --- .../warp_tui/src/orchestration_model_tests.rs | 64 ++++++++++++------- crates/warp_tui/src/terminal_session_view.rs | 9 --- specs/code-1822-tui-local-children/TECH.md | 6 +- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index b33eb067628..40755012919 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,6 +1,6 @@ use warp::tui_export::{ register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, - StartAgentExecutionMode, StartAgentExecutor, StartAgentOutcome, + StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; @@ -43,20 +43,46 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { } } -/// Registers a session (with a live active conversation) and returns its id -/// plus its surface's `StartAgentExecutor`. +/// Registers a session with a live active conversation. fn add_dispatching_session( app: &mut App, fixture: &OrchestrationFixture, focus: bool, -) -> (TuiSessionId, ModelHandle) { +) -> TuiSessionId { let (session, manager) = add_test_terminal_session(app, fixture.window_id); let session_id = app.update_model(&fixture.sessions, |sessions, ctx| { - sessions.add_session(session.clone(), manager, focus, ctx) + sessions.add_session(session, manager, focus, ctx) }); add_active_test_conversation(app, session_id.surface_id()); - let executor = app.read(|ctx| session.as_ref(ctx).start_agent_executor_for_test(ctx)); - (session_id, executor) + session_id +} + +/// Creates a standalone executor and relays its frontend materialization +/// events into the coordinator. +fn add_relayed_executor( + app: &mut App, + parent_session_id: TuiSessionId, +) -> ModelHandle { + let executor = app.add_model(StartAgentExecutor::new); + app.update(|ctx| { + let orchestration = TuiOrchestrationModel::handle(ctx); + ctx.subscribe_to_model(&executor, move |_, event, ctx| { + orchestration.update(ctx, |orchestration, ctx| match event { + StartAgentExecutorEvent::CreateAgent(request) => { + orchestration.dispatch_create_agent( + parent_session_id, + (**request).clone(), + None, + ctx, + ); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + orchestration.cleanup_failed_child(conversation_id, ctx); + } + }); + }); + }); + executor } /// Dispatches a StartAgent request through the session's executor and @@ -64,7 +90,6 @@ fn add_dispatching_session( /// unsupported modes synchronously within the same effect flush). fn dispatch_and_recv( app: &mut App, - fixture: &OrchestrationFixture, session_id: TuiSessionId, executor: &ModelHandle, execution_mode: StartAgentExecutionMode, @@ -75,7 +100,6 @@ fn dispatch_and_recv( .expect("fixture registered an active conversation") .id() }); - let _ = fixture; let receiver = app.update_model(executor, |executor, ctx| { executor.dispatch( "researcher".to_string(), @@ -130,11 +154,11 @@ fn assert_failed_launch_cleaned_up( fn local_harness_children_fail_cleanly() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + let session_id = add_dispatching_session(&mut app, &fixture, true); + let executor = add_relayed_executor(&mut app, session_id); let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, - &fixture, session_id, &executor, StartAgentExecutionMode::Local { @@ -151,11 +175,11 @@ fn local_harness_children_fail_cleanly() { fn remote_children_fail_cleanly() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + let session_id = add_dispatching_session(&mut app, &fixture, true); + let executor = add_relayed_executor(&mut app, session_id); let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, - &fixture, session_id, &executor, StartAgentExecutionMode::Remote { @@ -175,26 +199,22 @@ fn remote_children_fail_cleanly() { } #[test] -fn sessions_registered_after_init_are_wired_for_orchestration() { +fn failed_launch_cleanup_preserves_other_sessions() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - // First session exists before the dispatching one, mirroring a child - // session dispatching grandchildren later in an app's lifetime. let _ = add_dispatching_session(&mut app, &fixture, true); - let (late_session_id, late_executor) = add_dispatching_session(&mut app, &fixture, false); + let background_session_id = add_dispatching_session(&mut app, &fixture, false); + let executor = add_relayed_executor(&mut app, background_session_id); let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, - &fixture, - late_session_id, - &late_executor, + background_session_id, + &executor, StartAgentExecutionMode::Local { harness_type: Some("codex".to_string()), model_id: None, }, ); - // A resolved outcome proves the late session's executor is wired to - // the orchestration model (an unwired executor would never resolve). assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 2); }); diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 5a7e2a07eaf..669ebeef79d 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -12,8 +12,6 @@ use instant::Instant; use parking_lot::FairMutex; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::settings::{AISettings, AISettingsChangedEvent}; -#[cfg(test)] -use warp::tui_export::StartAgentExecutor; use warp::tui_export::{ block_context_from_terminal_model, build_slash_command_mixer, detect_possible_git_repo, export_conversation_markdown, prepare_conversation_block_restoration, @@ -385,13 +383,6 @@ impl TuiTerminalSessionView { }); } - #[cfg(test)] - pub(crate) fn start_agent_executor_for_test( - &self, - ctx: &AppContext, - ) -> ModelHandle { - self.ai_action_model.as_ref(ctx).start_agent_executor(ctx) - } fn detach_cli_subagent_view( &mut self, block_id: &BlockId, diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index dc5254c739f..d58e564dc78 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -90,10 +90,10 @@ types remain behind the shared launch API. - Removing completed child sessions; successful children remain retained like GUI hidden panes. - Rich message bodies and per-event lifecycle status rendering. ## Testing and validation -- `crates/warp_tui/src/orchestration_model_tests.rs (121-199)` verifies that local-harness and +- `crates/warp_tui/src/orchestration_model_tests.rs (154-221)` verifies that local-harness and remote requests resolve with explicit failures while leaving no child topology, extra session, - or event-consumer state. It also proves that sessions added after coordinator initialization are - wired for orchestration. + or event-consumer state. It also verifies that failed-launch cleanup preserves unrelated + retained sessions. - `crates/warp_tui/src/agent_block_tests.rs (290-362)` renders orchestration messages and lifecycle counts while asserting that `WaitForEvents` contributes no tool row. - `app/src/ai/llms_tests.rs (936-959)` verifies that an explicit surface override precedes the TUI From 67438d890d49ad5cfb4ec9ddd951c5ab89fa94e6 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 14:25:06 -0400 Subject: [PATCH 31/40] rich message component --- app/src/ai/execution_profiles/profiles.rs | 2 +- crates/warp_tui/src/agent_block.rs | 32 +-- crates/warp_tui/src/agent_block_sections.rs | 48 +--- crates/warp_tui/src/agent_block_tests.rs | 117 ++++++++-- crates/warp_tui/src/agent_message.rs | 159 +++++++++++++ crates/warp_tui/src/agent_message_tests.rs | 220 ++++++++++++++++++ crates/warp_tui/src/lib.rs | 2 + crates/warp_tui/src/status.rs | 62 +++++ crates/warp_tui/src/status_tests.rs | 50 ++++ crates/warp_tui/src/tool_call_labels.rs | 110 +++------ crates/warp_tui/src/tool_call_labels_tests.rs | 23 +- .../tui_block_list_viewport_source_tests.rs | 1 + crates/warp_tui/src/tui_file_edits_view.rs | 12 +- crates/warp_tui/src/tui_shell_command_view.rs | 13 +- 14 files changed, 680 insertions(+), 171 deletions(-) create mode 100644 crates/warp_tui/src/agent_message.rs create mode 100644 crates/warp_tui/src/agent_message_tests.rs create mode 100644 crates/warp_tui/src/status.rs create mode 100644 crates/warp_tui/src/status_tests.rs diff --git a/app/src/ai/execution_profiles/profiles.rs b/app/src/ai/execution_profiles/profiles.rs index 41100f3f1a5..c2207053970 100644 --- a/app/src/ai/execution_profiles/profiles.rs +++ b/app/src/ai/execution_profiles/profiles.rs @@ -128,7 +128,7 @@ pub struct AIExecutionProfilesModel { impl AIExecutionProfilesModel { #[allow(unused_variables)] - pub fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext) -> Self { + pub(crate) fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext) -> Self { cfg_if::cfg_if! { if #[cfg(feature = "agent_mode_evals")] { let default_profile_state = DefaultProfileState::Unsynced { diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 91f92a24d18..945014d596b 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -19,7 +19,7 @@ use warp::tui_export::{ AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIBlockModel, AIConversationId, BlockId, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel, CancellationReason, MessageId, ModelEvent, ModelEventDispatcher, - SummarizationType, TerminalModel, TodoOperation, TodoStatus, + ReceivedMessageDisplay, SummarizationType, TerminalModel, TodoOperation, TodoStatus, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -39,6 +39,7 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; +use crate::agent_message::render_agent_message; use crate::orchestration_block::{TuiOrchestrationBlock, TuiOrchestrationBlockEvent}; use crate::transcript_view::BLOCK_TOP_PADDING_ROWS; use crate::tui_builder::TuiUiBuilder; @@ -97,6 +98,8 @@ enum TuiAIBlockSection { CompletedTodos { completed: Vec, }, + /// A message delivered by another agent in the orchestration. + AgentMessage(ReceivedMessageDisplay), } /// Per-message UI state for collapsible sections (thinking blocks, @@ -864,6 +867,9 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(message) => { + render_agent_message(&self.collapsible_states, message, app) + } }) } fn rich_text_sections(message_id: &MessageId, text: &AIAgentText) -> Vec { @@ -976,24 +982,14 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, - - // TODO: add full status rendering for sub-agents. AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { for received in messages { - sections.push(TuiAIBlockSection::PlainText(format!( - "Received message from agent {}: {}", - received.sender_agent_id, received.subject - ))); + sections.push(TuiAIBlockSection::AgentMessage(received.clone())); } } - AIAgentOutputMessageType::EventsFromAgents { event_ids } => { - let count = event_ids.len(); - let plural = if count == 1 { "" } else { "s" }; - sections.push(TuiAIBlockSection::PlainText(format!( - "Received {count} agent lifecycle event{plural}" - ))); - } - + // Event IDs contain no display detail. The sender's live + // conversation status is shown on rich message rows. + AIAgentOutputMessageType::EventsFromAgents { .. } => {} // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) @@ -1167,6 +1163,9 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(message) => { + render_agent_message(&self.collapsible_states, message, app) + } }; // One row of bottom padding separates sections; the last section @@ -1230,7 +1229,8 @@ fn section_logical_text(section: &TuiAIBlockSection) -> Option { | TuiAIBlockSection::Thinking { .. } | TuiAIBlockSection::Summarization { .. } | TuiAIBlockSection::TodoList { .. } - | TuiAIBlockSection::CompletedTodos { .. } => None, + | TuiAIBlockSection::CompletedTodos { .. } + | TuiAIBlockSection::AgentMessage(_) => None, } } diff --git a/crates/warp_tui/src/agent_block_sections.rs b/crates/warp_tui/src/agent_block_sections.rs index 708668e603d..e90a344da3f 100644 --- a/crates/warp_tui/src/agent_block_sections.rs +++ b/crates/warp_tui/src/agent_block_sections.rs @@ -18,10 +18,7 @@ use warpui_core::elements::CrossAxisAlignment; use warpui_core::AppContext; use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; -use crate::tool_call_labels::{ - tool_call_display_state, tool_call_glyph, tool_call_label, ResolvedCommandBlock, - ToolCallDisplayState, -}; +use crate::tool_call_labels::{tool_call_display_state, tool_call_label, ResolvedCommandBlock}; use crate::tui_builder::TuiUiBuilder; const INPUT_PREFIX: &str = "> "; @@ -73,41 +70,6 @@ pub(crate) fn render_input_section(text: &str, app: &AppContext) -> Box TuiStyle { - match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Pending => { - builder.dim_text_style() - } - ToolCallDisplayState::AwaitingApproval | ToolCallDisplayState::Running => { - builder.attention_glyph_style() - } - ToolCallDisplayState::Succeeded => builder.success_glyph_style(), - ToolCallDisplayState::Failed => builder.error_text_style(), - ToolCallDisplayState::Cancelled => builder.muted_text_style(), - } -} - -/// Shared label style for all rich and fallback TUI tool-call rows. -pub(crate) fn tool_call_label_style( - state: ToolCallDisplayState, - builder: &TuiUiBuilder, -) -> TuiStyle { - match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Pending => { - builder.dim_text_style() - } - ToolCallDisplayState::AwaitingApproval - | ToolCallDisplayState::Running - | ToolCallDisplayState::Succeeded - | ToolCallDisplayState::Failed - | ToolCallDisplayState::Cancelled => builder.primary_text_style(), - } -} - /// Renders the fallback plain-text status row for an agent tool call, used /// for every tool call without a richer registered child view (the GUI's /// view-based action rendering has no TUI equivalent for these yet): a @@ -116,7 +78,7 @@ pub(crate) fn tool_call_label_style( /// hanging indent under itself. State lives in the glyph, so labels keep the /// normal foreground except in-flight rows, which stay dim until execution /// starts. `output_streaming` marks tool calls whose arguments are still -/// streaming in (see `ToolCallDisplayState::Constructing`); `block` carries +/// streaming in (see `TuiStatusState::Constructing`); `block` carries /// the terminal block's ground truth for shell-command tool calls (see /// `ResolvedCommandBlock`). pub(crate) fn render_fallback_tool_call_section( @@ -128,12 +90,12 @@ pub(crate) fn render_fallback_tool_call_section( ) -> Box { let builder = TuiUiBuilder::from_app(app); let state = tool_call_display_state(status, output_streaming, block.map(|block| block.state)); - let glyph_style = tool_call_glyph_style(state, &builder); - let label_style = tool_call_label_style(state, &builder); + let glyph_style = state.glyph_style(&builder); + let label_style = state.label_style(&builder); let label = tool_call_label(action, status, output_streaming, block); TuiFlex::row() .child( - TuiText::new(format!("{} ", tool_call_glyph(state))) + TuiText::new(format!("{} ", state.glyph())) .with_style(glyph_style) .finish(), ) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index a190ebad068..fd1001d4686 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -36,6 +36,7 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; +use crate::agent_message::agent_message_section_id; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -293,6 +294,13 @@ fn agent_block_renders_multiple_tool_calls_in_order() { fn orchestration_outputs_render_without_wait_for_events_tool_row() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); + let received = ReceivedMessageDisplay { + message_id: "message-1".to_string(), + sender_agent_id: "researcher".to_string(), + addresses: vec!["lead".to_string()], + subject: "Investigation complete".to_string(), + message_body: "Found the issue".to_string(), + }; let wait_action = AIAgentAction { id: AIAgentActionId::from("wait-action".to_string()), action: AIAgentActionType::WaitForEvents { @@ -311,13 +319,7 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { AIAgentOutputMessage { id: MessageId::new("m2".to_string()), message: AIAgentOutputMessageType::MessagesReceivedFromAgents { - messages: vec![ReceivedMessageDisplay { - message_id: "message-1".to_string(), - sender_agent_id: "researcher".to_string(), - addresses: vec!["lead".to_string()], - subject: "Investigation complete".to_string(), - message_body: "Found the issue".to_string(), - }], + messages: vec![received.clone()], }, citations: Vec::new(), }, @@ -336,20 +338,7 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { let block = block.as_ref(app_ctx); assert_eq!( block.sections(app_ctx), - vec![ - TuiAIBlockSection::PlainText( - "Received message from agent researcher: Investigation complete" - .to_string(), - ), - TuiAIBlockSection::PlainText("Received 2 agent lifecycle events".to_string(),), - ], - ); - assert_eq!( - render_block_lines(block, 80, app_ctx), - vec![ - "Received message from agent researcher: Investigation complete", - "Received 2 agent lifecycle events", - ], + vec![TuiAIBlockSection::AgentMessage(received)], ); }); }); @@ -524,6 +513,81 @@ fn agent_block_ignores_unsupported_message_variants() { }); } +#[test] +fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { + App::test((), |mut app| async move { + let first = received_message("run-1", "first", "Starting work"); + let second = received_message("run-2", "second", "Reviewing changes"); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + AIAgentOutputMessage::messages_received_from_agents( + MessageId::new("messages-1".to_owned()), + vec![first.clone(), second.clone()], + ), + AIAgentOutputMessage::events_from_agents( + MessageId::new("events-1".to_owned()), + vec!["event-1".to_owned(), "event-2".to_owned()], + ), + ]), + }, + ); + app.read(|app_ctx| { + assert_eq!( + block.as_ref(app_ctx).sections(app_ctx), + vec![ + TuiAIBlockSection::AgentMessage(first), + TuiAIBlockSection::AgentMessage(second), + ] + ); + }); + }); +} + +#[test] +fn agent_message_defaults_collapsed_and_expands_through_block_state() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let received = received_message("run-1", "progress", "Starting work"); + let message_id = agent_message_section_id(&received); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + AIAgentOutputMessage::messages_received_from_agents( + MessageId::new("messages-1".to_owned()), + vec![received], + ), + ]), + }, + ); + app.read(|ctx| { + let lines = render_block_lines(block.as_ref(ctx), 40, ctx); + assert!(lines[0].ends_with(" ▸")); + assert!(lines.iter().all(|line| !line.contains("Starting work"))); + }); + + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + block.window_id(ctx), + block.id(), + &TuiAIBlockAction::SetSectionCollapsed { + message_id, + collapsed: false, + }, + ); + }); + app.read(|ctx| { + let lines = render_block_lines(block.as_ref(ctx), 40, ctx); + assert!(lines[0].ends_with(" ▾")); + assert_eq!(lines[1], " Starting work"); + }); + }); +} + #[test] fn agent_block_preserves_and_renders_code_sections_in_order() { App::test((), |mut app| async move { @@ -1469,6 +1533,17 @@ fn debug_output_message(id: &str, text: &str) -> AIAgentOutputMessage { } } +/// Builds one incoming orchestration message for extraction tests. +fn received_message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { + ReceivedMessageDisplay { + message_id: format!("message-{sender}"), + sender_agent_id: sender.to_owned(), + addresses: vec!["parent-run".to_owned()], + subject: subject.to_owned(), + message_body: body.to_owned(), + } +} + /// Builds a todo item for task-list tests. fn todo(id: &str, title: &str) -> AIAgentTodo { AIAgentTodo::new(id.to_owned().into(), title.to_owned(), String::new()) diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs new file mode 100644 index 00000000000..254d3d971e8 --- /dev/null +++ b/crates/warp_tui/src/agent_message.rs @@ -0,0 +1,159 @@ +//! Rich TUI rendering for messages received from orchestration participants. + +use warp::tui_export::{ + BlocklistAIHistoryModel, ConversationStatus, MessageId, ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{tui_collapsible, Modifier, TuiContainer, TuiElement, TuiText}; +use warpui_core::AppContext; + +use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; +use crate::orchestrated_agent_identity_styling::{ + assign_agent_identity_indices, stable_hash, AgentIdentity, +}; +use crate::status::TuiStatusState; +use crate::tui_builder::TuiUiBuilder; + +/// Render-ready identity and lifecycle presentation for a message sender. +struct AgentMessagePresentation { + name: String, + status: TuiStatusState, + identity: AgentIdentity, +} +/// Maps a shared conversation lifecycle into the common TUI status contract. +fn agent_status(status: &ConversationStatus) -> TuiStatusState { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents => TuiStatusState::Running, + ConversationStatus::Success => TuiStatusState::Succeeded, + ConversationStatus::Error => TuiStatusState::Failed, + ConversationStatus::Cancelled => TuiStatusState::Cancelled, + ConversationStatus::Blocked { .. } => TuiStatusState::Blocked, + } +} + +/// Resolves a sender's name and sibling-stable identity. +fn message_presentation( + sender_agent_id: &str, + builder: &TuiUiBuilder, + app: &AppContext, +) -> AgentMessagePresentation { + let history = BlocklistAIHistoryModel::as_ref(app); + let sender = history + .conversation_id_for_agent_id(sender_agent_id) + .and_then(|conversation_id| history.conversation(&conversation_id)) + .or_else(|| { + history + .all_live_conversations() + .into_iter() + .map(|(_, conversation)| conversation) + .find(|conversation| { + conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) + }) + }); + let name = sender + .and_then(|conversation| conversation.agent_name()) + .unwrap_or("Unknown agent") + .to_owned(); + let status = sender + .map(|conversation| agent_status(conversation.status())) + .unwrap_or(TuiStatusState::Running); + let palette = builder.agent_identity_palette(); + let sibling_identity_index = sender.and_then(|conversation| { + let parent_id = conversation.parent_conversation_id()?; + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == conversation.id())?; + let indices = assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + palette.len(), + ); + indices.get(sender_index).copied() + }); + let fallback_identity_index = (!palette.is_empty()).then(|| { + usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() + }); + let identity = sibling_identity_index + .or(fallback_identity_index) + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or(AgentIdentity { + glyph: "⟡", + style: builder.accent_text_style(), + }); + AgentMessagePresentation { + name, + status, + identity, + } +} + +/// The persistent collapse-state key for one received message. +pub(crate) fn agent_message_section_id(message: &ReceivedMessageDisplay) -> MessageId { + MessageId::new(format!("received-agent-message:{}", message.message_id)) +} + +/// Renders a received child message as a collapsed-by-default disclosure. +pub(crate) fn render_agent_message( + states: &CollapsibleSectionStates, + message: &ReceivedMessageDisplay, + app: &AppContext, +) -> Box { + let builder = TuiUiBuilder::from_app(app); + let presentation = message_presentation(&message.sender_agent_id, &builder, app); + let header_spans = [ + ( + format!("{} ", presentation.status.glyph()), + presentation.status.glyph_style(&builder), + ), + ( + format!("{} ", presentation.identity.glyph), + presentation.identity.style, + ), + ( + presentation.name, + presentation.identity.style.add_modifier(Modifier::BOLD), + ), + // The helper adds another separating space with its chevron. + (" ".to_owned(), builder.primary_text_style()), + ]; + let preview = if message.message_body.trim().is_empty() { + message.subject.as_str() + } else { + message.message_body.as_str() + } + .to_owned(); + let preview_style = builder.muted_text_style(); + let message_id = agent_message_section_id(message); + let collapsed = states.is_collapsed(&message_id, true); + let toggle_message_id = message_id.clone(); + tui_collapsible( + collapsed, + header_spans, + builder.primary_text_style(), + states.hover_state(&message_id), + move || { + TuiContainer::new( + TuiText::new(preview.clone()) + .with_style(preview_style) + .finish(), + ) + .with_padding_left(4) + .finish() + }, + move |event_ctx, _app| { + event_ctx.dispatch_typed_action(TuiAIBlockAction::SetSectionCollapsed { + message_id: toggle_message_id.clone(), + collapsed: !collapsed, + }); + }, + ) +} + +#[cfg(test)] +#[path = "agent_message_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs new file mode 100644 index 00000000000..b5fc5683ab9 --- /dev/null +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -0,0 +1,220 @@ +use warp::tui_export::{ + AIConversationId, Appearance, BlocklistAIHistoryModel, ConversationStatus, + ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, EntityId}; + +use super::{agent_message_section_id, agent_status, render_agent_message}; +use crate::agent_block::CollapsibleSectionStates; +use crate::status::TuiStatusState; +use crate::tui_builder::TuiUiBuilder; +const INFRA_RUN_ID: &str = "00000000-0000-0000-0000-000000000001"; +const UI_RUN_ID: &str = "00000000-0000-0000-0000-000000000002"; + +/// Registers the appearance and history models needed by the renderer. +fn register_models(app: &mut App) { + app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); +} + +/// Creates one parent conversation for child-message tests. +fn add_parent(app: &mut App) -> AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.start_new_conversation(EntityId::new(), false, false, false, ctx) + }) + }) +} + +/// Creates a named child with a sender run ID and lifecycle status. +fn add_child( + app: &mut App, + parent_id: AIConversationId, + name: &str, + run_id: &str, + status: ConversationStatus, +) { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let surface_id = EntityId::new(); + let conversation_id = + history.start_new_conversation(surface_id, false, false, false, ctx); + let conversation = history + .conversation_mut(&conversation_id) + .expect("child conversation was just created"); + conversation.set_agent_name(name.to_owned()); + conversation.set_run_id(run_id.to_owned()); + history.set_parent_for_conversation(conversation_id, parent_id); + history.update_conversation_status(surface_id, conversation_id, status, ctx); + }); + }); +} + +/// Builds one received message payload. +fn message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { + ReceivedMessageDisplay { + message_id: format!("message-{sender}"), + sender_agent_id: sender.to_owned(), + addresses: vec!["parent-run".to_owned()], + subject: subject.to_owned(), + message_body: body.to_owned(), + } +} + +#[test] +fn conversation_statuses_map_to_shared_tui_statuses() { + assert_eq!( + agent_status(&ConversationStatus::InProgress), + TuiStatusState::Running + ); + assert_eq!( + agent_status(&ConversationStatus::TransientError), + TuiStatusState::Running + ); + assert_eq!( + agent_status(&ConversationStatus::WaitingForEvents), + TuiStatusState::Running + ); + assert_eq!( + agent_status(&ConversationStatus::Blocked { + blocked_action: "approval".to_owned(), + }), + TuiStatusState::Blocked + ); + assert_eq!( + agent_status(&ConversationStatus::Success), + TuiStatusState::Succeeded + ); + assert_eq!( + agent_status(&ConversationStatus::Error), + TuiStatusState::Failed + ); + assert_eq!( + agent_status(&ConversationStatus::Cancelled), + TuiStatusState::Cancelled + ); +} + +#[test] +fn running_child_message_matches_the_design_layout_and_styles() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + add_child( + &mut app, + parent_id, + "infrastructure-bot", + INFRA_RUN_ID, + ConversationStatus::InProgress, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let message = message(INFRA_RUN_ID, "progress", "Starting to build infrastructure"); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render_agent_message(&states, &message, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + let lines = frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect::>(); + assert!(lines[0].starts_with("● ")); + assert!(lines[0].contains(" infrastructure-bot ▸")); + assert!(lines.iter().all(|line| !line.contains("Starting to build"))); + + let builder = TuiUiBuilder::from_app(ctx); + assert_eq!( + frame.buffer[(0, 0)].fg, + TuiStatusState::Running + .glyph_style(&builder) + .fg + .expect("running status has a foreground") + ); + assert_eq!(frame.buffer[(2, 0)].fg, frame.buffer[(4, 0)].fg); + assert!(frame.buffer[(4, 0)].modifier.contains(Modifier::BOLD)); + + states.set_collapsed(agent_message_section_id(&message), false); + let expanded = presenter.present_element( + render_agent_message(&states, &message, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + assert!(expanded.buffer.to_lines()[0].contains(" ▾")); + assert_eq!( + expanded.buffer.to_lines()[1].trim_end(), + " Starting to build infrastructure" + ); + assert_eq!( + expanded.buffer[(4, 1)].fg, + builder + .muted_text_style() + .fg + .expect("muted text has a foreground") + ); + }); + }); +} + +#[test] +fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + add_child( + &mut app, + parent_id, + "ui-implementer", + UI_RUN_ID, + ConversationStatus::Success, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let received = message( + UI_RUN_ID, + "progress", + "Starting to implement a responsive interface", + ); + states.set_collapsed(agent_message_section_id(&received), false); + let mut presenter = TuiPresenter::new(); + let wrapped = presenter.present_element( + render_agent_message(&states, &received, ctx), + TuiRect::new(0, 0, 24, 4), + ctx, + ); + let wrapped_lines = wrapped + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect::>(); + assert!(wrapped_lines[0].starts_with("✓ ")); + assert!(wrapped_lines[1..] + .iter() + .all(|line| line.starts_with(" "))); + + let fallback = presenter.present_element( + render_agent_message( + &states, + &message(UI_RUN_ID, "Finished verification", " "), + ctx, + ), + TuiRect::new(0, 0, 40, 2), + ctx, + ); + assert_eq!( + fallback.buffer.to_lines()[1].trim_end(), + " Finished verification" + ); + }); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 1d0b0a0889a..1547b06933e 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,6 +9,7 @@ mod agent_block; mod agent_block_sections; +mod agent_message; mod alt_screen_view; mod autoupdate; mod clipboard; @@ -39,6 +40,7 @@ mod resume; mod session_registry; mod skills_menu; mod slash_commands; +mod status; mod terminal_background; mod terminal_block; mod terminal_content_element; diff --git a/crates/warp_tui/src/status.rs b/crates/warp_tui/src/status.rs new file mode 100644 index 00000000000..0897e574898 --- /dev/null +++ b/crates/warp_tui/src/status.rs @@ -0,0 +1,62 @@ +//! Shared status presentation for compact TUI transcript rows. + +use warpui_core::elements::tui::TuiStyle; + +use crate::tui_builder::TuiUiBuilder; + +/// Coarse UI state shared by tool calls and orchestration participants. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TuiStatusState { + /// Content is still being assembled and may be incomplete. + Constructing, + /// Work is queued but has not started. + Pending, + /// Work is blocked on user input or approval. + Blocked, + /// Work is actively executing. + Running, + /// Work completed successfully. + Succeeded, + /// Work completed with an error. + Failed, + /// Work was cancelled. + Cancelled, +} + +impl TuiStatusState { + /// The compact leading glyph for this status. + pub(crate) fn glyph(self) -> &'static str { + match self { + Self::Constructing | Self::Pending => "○", + Self::Blocked | Self::Cancelled => "■", + Self::Running => "●", + Self::Succeeded => "✓", + Self::Failed => "×", + } + } + + /// The semantic theme style for this status glyph. + pub(crate) fn glyph_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running => builder.attention_glyph_style(), + Self::Succeeded => builder.success_glyph_style(), + Self::Failed => builder.error_text_style(), + Self::Cancelled => builder.muted_text_style(), + } + } + + /// The semantic text style paired with this status. + pub(crate) fn label_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running | Self::Succeeded | Self::Failed | Self::Cancelled => { + builder.primary_text_style() + } + } + } +} + +#[cfg(test)] +#[path = "status_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/status_tests.rs b/crates/warp_tui/src/status_tests.rs new file mode 100644 index 00000000000..18ca2fa56e4 --- /dev/null +++ b/crates/warp_tui/src/status_tests.rs @@ -0,0 +1,50 @@ +use warp::tui_export::Appearance; +use warpui_core::App; + +use super::TuiStatusState; +use crate::tui_builder::TuiUiBuilder; + +#[test] +fn status_glyphs_match_the_shared_transcript_contract() { + assert_eq!(TuiStatusState::Constructing.glyph(), "○"); + assert_eq!(TuiStatusState::Pending.glyph(), "○"); + assert_eq!(TuiStatusState::Blocked.glyph(), "■"); + assert_eq!(TuiStatusState::Running.glyph(), "●"); + assert_eq!(TuiStatusState::Succeeded.glyph(), "✓"); + assert_eq!(TuiStatusState::Failed.glyph(), "×"); + assert_eq!(TuiStatusState::Cancelled.glyph(), "■"); +} + +#[test] +fn status_styles_reuse_semantic_builder_styles() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.read(|ctx| { + let builder = TuiUiBuilder::from_app(ctx); + assert_eq!( + TuiStatusState::Pending.glyph_style(&builder), + builder.dim_text_style() + ); + assert_eq!( + TuiStatusState::Blocked.glyph_style(&builder), + builder.attention_glyph_style() + ); + assert_eq!( + TuiStatusState::Running.glyph_style(&builder), + builder.attention_glyph_style() + ); + assert_eq!( + TuiStatusState::Succeeded.glyph_style(&builder), + builder.success_glyph_style() + ); + assert_eq!( + TuiStatusState::Failed.glyph_style(&builder), + builder.error_text_style() + ); + assert_eq!( + TuiStatusState::Cancelled.glyph_style(&builder), + builder.muted_text_style() + ); + }); + }); +} diff --git a/crates/warp_tui/src/tool_call_labels.rs b/crates/warp_tui/src/tool_call_labels.rs index 93dd59bbf75..6c21d28db94 100644 --- a/crates/warp_tui/src/tool_call_labels.rs +++ b/crates/warp_tui/src/tool_call_labels.rs @@ -11,7 +11,7 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use self::ToolCallDisplayState as State; +use crate::status::TuiStatusState as State; /// Ground-truth state of the terminal block backing a shell-command tool /// call, resolved by the caller. When a block exists, its state supersedes @@ -42,29 +42,6 @@ pub(crate) struct ResolvedCommandBlock { /// so tool-call rows stay scannable one-liners. const MAX_INLINE_LEN: usize = 80; -/// The coarse display state of a tool call, derived from its action status. -/// -/// TUI-local presentation collapse of the shared [`AIActionStatus`]; the GUI -/// has no equivalent enum — its per-tool views consume `AIActionStatus` -/// directly and re-derive per-site booleans (queued/cancelled/streaming). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolCallDisplayState { - /// The tool call's arguments are still streaming in: it has no action - /// status yet and the exchange output is still streaming, so argument - /// fields may be empty or partial and must not be interpolated. - Constructing, - /// No status yet (stream finished), preprocessing, or queued behind - /// other actions. - Pending, - /// Blocked on user confirmation. - AwaitingApproval, - /// Executing asynchronously. - Running, - Succeeded, - Failed, - Cancelled, -} - /// Collapses an optional action status into the coarse display state. /// `output_streaming` is whether the exchange output is still streaming; /// a status-less action in a streaming output is still being constructed @@ -75,7 +52,7 @@ pub(crate) fn tool_call_display_state( status: Option<&AIActionStatus>, output_streaming: bool, block_state: Option, -) -> ToolCallDisplayState { +) -> State { // A block existing means the command actually started executing, so its // state is authoritative over the action status/result. match block_state { @@ -94,7 +71,7 @@ pub(crate) fn tool_call_display_state( match status { None if output_streaming => State::Constructing, None | Some(AIActionStatus::Preprocessing | AIActionStatus::Queued) => State::Pending, - Some(AIActionStatus::Blocked) => State::AwaitingApproval, + Some(AIActionStatus::Blocked) => State::Blocked, Some(AIActionStatus::RunningAsync) => State::Running, Some(finished @ AIActionStatus::Finished(_)) => { if finished.is_cancelled() { @@ -108,21 +85,6 @@ pub(crate) fn tool_call_display_state( } } -/// The leading status glyph for a tool-call row; the caller colors it to -/// mirror the GUI's inline action icons (`action_icon` in the GUI's -/// `output.rs`): grey circle while pending, yellow block awaiting approval, -/// yellow dot running, green check on success, red x on failure, grey block -/// on cancellation. -pub(crate) fn tool_call_glyph(state: ToolCallDisplayState) -> &'static str { - match state { - State::Constructing | State::Pending => "○", - State::AwaitingApproval | State::Cancelled => "■", - State::Running => "●", - State::Succeeded => "✓", - State::Failed => "×", - } -} - /// Returns the one-line transcript label for a tool call in its current state. pub(crate) fn tool_call_label( action: &AIAgentAction, @@ -136,7 +98,7 @@ pub(crate) fn tool_call_label( .map(|result| &result.result); let label = label_for_action(&action.action, state, result, block); match state { - State::AwaitingApproval => format!("{label} (awaiting approval)"), + State::Blocked => format!("{label} (awaiting approval)"), State::Constructing | State::Pending | State::Running @@ -155,7 +117,7 @@ pub(crate) fn tool_call_label( /// view's "Generating command..."). fn label_for_action( action: &AIAgentActionType, - state: ToolCallDisplayState, + state: State, result: Option<&AIAgentActionResultType>, block: Option<&ResolvedCommandBlock>, ) -> String { @@ -171,7 +133,7 @@ fn label_for_action( let cmd = single_line(executed.unwrap_or(command)); match state { State::Constructing => "Generating command…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Run `{cmd}`"), + State::Pending | State::Blocked => format!("Run `{cmd}`"), State::Running => format!("Running `{cmd}`"), State::Succeeded => match block_state { Some(CommandBlockState::Finished { .. }) => format!("Ran `{cmd}`"), @@ -204,7 +166,7 @@ fn label_for_action( } AIAgentActionType::WriteToLongRunningShellCommand { .. } => match state { State::Constructing => "Writing command input…".to_owned(), - State::Pending | State::AwaitingApproval => "Write input to running command".to_owned(), + State::Pending | State::Blocked => "Write input to running command".to_owned(), State::Running => "Writing input to running command…".to_owned(), State::Succeeded => "Wrote input to running command".to_owned(), State::Failed => "Failed to write to running command".to_owned(), @@ -214,7 +176,7 @@ fn label_for_action( let files = files_summary(request.locations.iter().map(|location| &location.name)); match state { State::Constructing => "Reading files…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read {files}") } State::Running => format!("Reading {files}"), @@ -226,7 +188,7 @@ fn label_for_action( let file = single_line(&request.file_path); match state { State::Constructing => "Preparing upload…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Upload {file}"), + State::Pending | State::Blocked => format!("Upload {file}"), State::Running => format!("Uploading {file}"), State::Succeeded => format!("Uploaded {file}"), State::Failed => format!("Upload of {file} failed"), @@ -242,7 +204,7 @@ fn label_for_action( .unwrap_or_default(); match state { State::Constructing => "Searching codebase…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Search for \"{query}\"{scope}") } State::Running => format!("Searching for \"{query}\"{scope}"), @@ -285,7 +247,7 @@ fn label_for_action( let path = display_path(path); match state { State::Constructing => "Grepping…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Grep for {queries} in {path}") } State::Running => format!("Grepping for {queries} in {path}"), @@ -326,7 +288,7 @@ fn label_for_action( // GUI's "Reading \"{name}\" MCP resource..." loading text. State::Constructing if name.is_empty() => "Reading MCP resource…".to_owned(), State::Constructing => format!("Reading \"{name}\" MCP resource…"), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read MCP resource {resource}") } State::Running => format!("Reading MCP resource {resource}"), @@ -341,7 +303,7 @@ fn label_for_action( // text; the tool name is available before its args finish. State::Constructing if name.is_empty() => "Calling MCP tool…".to_owned(), State::Constructing => format!("Calling \"{name}\" MCP tool…"), - State::Pending | State::AwaitingApproval => format!("Call MCP tool {name}"), + State::Pending | State::Blocked => format!("Call MCP tool {name}"), State::Running => format!("Calling MCP tool {name}"), State::Succeeded => format!("Called MCP tool {name}"), State::Failed => format!("MCP tool {name} failed"), @@ -350,7 +312,7 @@ fn label_for_action( } AIAgentActionType::SuggestNewConversation { .. } => match state { State::Constructing => "Suggesting a new conversation…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running | State::Failed => { + State::Pending | State::Blocked | State::Running | State::Failed => { "Suggested starting a new conversation".to_owned() } State::Succeeded => match result { @@ -368,7 +330,7 @@ fn label_for_action( let documents = count_label(request.document_ids.len(), "document", "documents"); match state { State::Constructing => "Reading documents…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read {documents}") } State::Running => format!("Reading {documents}"), @@ -377,7 +339,7 @@ fn label_for_action( } } AIAgentActionType::EditDocuments(request) => match state { - State::Pending | State::AwaitingApproval => "Update plan".to_owned(), + State::Pending | State::Blocked => "Update plan".to_owned(), State::Constructing | State::Running => "Updating plan…".to_owned(), State::Succeeded => format!( "Updated plan ({})", @@ -387,7 +349,7 @@ fn label_for_action( State::Cancelled => "Update plan cancelled".to_owned(), }, AIAgentActionType::CreateDocuments(request) => match state { - State::Pending | State::AwaitingApproval => "Create plan".to_owned(), + State::Pending | State::Blocked => "Create plan".to_owned(), State::Constructing | State::Running => "Generating plan…".to_owned(), State::Succeeded => { let count = request.documents.len(); @@ -401,9 +363,7 @@ fn label_for_action( State::Cancelled => "Create plan cancelled".to_owned(), }, AIAgentActionType::ReadShellCommandOutput { .. } => match state { - State::Pending | State::AwaitingApproval | State::Succeeded => { - "Read command output".to_owned() - } + State::Pending | State::Blocked | State::Succeeded => "Read command output".to_owned(), State::Constructing | State::Running => "Reading command output…".to_owned(), State::Failed => "Failed to read command output".to_owned(), State::Cancelled => "Read command output cancelled".to_owned(), @@ -413,7 +373,7 @@ fn label_for_action( let comments = count_label(comments.len(), "review comment", "review comments"); match state { State::Constructing => "Preparing review comments…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Insert {comments}"), + State::Pending | State::Blocked => format!("Insert {comments}"), State::Running => format!("Inserting {comments}…"), State::Succeeded => format!("Inserted {comments}"), State::Failed => "Failed to insert review comments".to_owned(), @@ -424,14 +384,14 @@ fn label_for_action( summary_label(&request.task_summary, state) } AIAgentActionType::StartRecording { .. } => match state { - State::Pending | State::AwaitingApproval => "Start recording".to_owned(), + State::Pending | State::Blocked => "Start recording".to_owned(), State::Constructing | State::Running => "Starting recording…".to_owned(), State::Succeeded => "Started screen recording".to_owned(), State::Failed => "Recording failed to start".to_owned(), State::Cancelled => "Start recording cancelled".to_owned(), }, AIAgentActionType::StopRecording { .. } => match state { - State::Pending | State::AwaitingApproval => "Stop recording".to_owned(), + State::Pending | State::Blocked => "Stop recording".to_owned(), State::Constructing | State::Running => "Stopping recording…".to_owned(), State::Succeeded => "Saved screen recording".to_owned(), State::Failed => "Failed to save recording".to_owned(), @@ -441,7 +401,7 @@ fn label_for_action( let skill = single_line(&request.skill.display_label()); match state { State::Constructing => "Reading skill…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read skill {skill}") } State::Running => format!("Reading skill {skill}"), @@ -450,7 +410,7 @@ fn label_for_action( } } AIAgentActionType::FetchConversation { .. } => match state { - State::Pending | State::AwaitingApproval => "Fetch conversation".to_owned(), + State::Pending | State::Blocked => "Fetch conversation".to_owned(), State::Constructing | State::Running => "Fetching conversation…".to_owned(), State::Succeeded => "Fetched conversation".to_owned(), State::Failed => "Fetch conversation failed".to_owned(), @@ -468,7 +428,7 @@ fn label_for_action( }; match state { State::Constructing => "Configuring agent…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Start {agent}"), + State::Pending | State::Blocked => format!("Start {agent}"), State::Running => format!("Starting {agent}…"), State::Succeeded => format!("Started agent {name}"), State::Failed => format!("Failed to start agent {name}"), @@ -481,7 +441,7 @@ fn label_for_action( let subject = single_line(subject); match state { State::Constructing => "Composing message…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Send message: {subject}"), + State::Pending | State::Blocked => format!("Send message: {subject}"), State::Running => format!( "Sending message to {}: {subject}", count_label(addresses.len(), "agent", "agents") @@ -493,7 +453,7 @@ fn label_for_action( } AIAgentActionType::TransferShellCommandControlToUser { reason } => match state { State::Constructing => "Handing control to you…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running => { + State::Pending | State::Blocked | State::Running => { format!("Handing control to you: {}", single_line(reason)) } State::Succeeded => "You are in control".to_owned(), @@ -502,7 +462,7 @@ fn label_for_action( }, AIAgentActionType::AskUserQuestion { questions } => match state { State::Constructing => "Preparing question…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running => format!( + State::Pending | State::Blocked | State::Running => format!( "Asking {}", count_label(questions.len(), "question", "questions") ), @@ -533,7 +493,7 @@ fn label_for_action( AIAgentActionType::RunAgents(request) => { let total = request.agent_run_configs.len(); match state { - State::Constructing | State::Pending | State::AwaitingApproval => { + State::Constructing | State::Pending | State::Blocked => { "Configuring agents…".to_owned() } State::Running => { @@ -576,7 +536,7 @@ fn label_for_action( } } AIAgentActionType::WaitForEvents { .. } => match state { - State::Constructing | State::Pending | State::AwaitingApproval | State::Running => { + State::Constructing | State::Pending | State::Blocked | State::Running => { "Waiting for agent events…".to_owned() } State::Succeeded => "Done waiting for agent events".to_owned(), @@ -591,14 +551,14 @@ fn label_for_action( fn file_glob_label( patterns: &[String], path: Option<&str>, - state: ToolCallDisplayState, + state: State, matched_count: Option, ) -> String { let patterns = single_line(&patterns.join(", ")); let path = display_path(path.unwrap_or(".")); match state { State::Constructing => "Finding files…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Find files matching {patterns} in {path}") } State::Running => format!("Finding files matching {patterns} in {path}"), @@ -617,11 +577,11 @@ fn file_glob_label( /// Labels computer-use calls with their agent-supplied summary, marking only /// terminal non-success states (matching the GUI, which shows the summary /// verbatim). -fn summary_label(summary: &str, state: ToolCallDisplayState) -> String { +fn summary_label(summary: &str, state: State) -> String { let summary = single_line(summary); match state { State::Constructing => "Preparing computer use…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running | State::Succeeded => summary, + State::Pending | State::Blocked | State::Running | State::Succeeded => summary, State::Failed => format!("{summary} — failed"), State::Cancelled => format!("{summary} — cancelled"), } @@ -629,10 +589,10 @@ fn summary_label(summary: &str, state: ToolCallDisplayState) -> String { /// Generic label for action types without bespoke text, derived from the /// action's user-friendly name. -fn fallback_label(action: &AIAgentActionType, state: ToolCallDisplayState) -> String { +fn fallback_label(action: &AIAgentActionType, state: State) -> String { let name = action.user_friendly_name(); match state { - State::Pending | State::AwaitingApproval => name, + State::Pending | State::Blocked => name, State::Constructing | State::Running => format!("{name}…"), State::Succeeded => format!("{name} — done"), State::Failed => format!("{name} — failed"), diff --git a/crates/warp_tui/src/tool_call_labels_tests.rs b/crates/warp_tui/src/tool_call_labels_tests.rs index 7db35df1ec6..6aa4a362ac2 100644 --- a/crates/warp_tui/src/tool_call_labels_tests.rs +++ b/crates/warp_tui/src/tool_call_labels_tests.rs @@ -6,7 +6,8 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use super::{tool_call_label, CommandBlockState, ResolvedCommandBlock}; +use super::{tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock}; +use crate::status::TuiStatusState; /// Builds a `Finished` status wrapping the given result. fn finished(result: AIAgentActionResultType) -> AIActionStatus { @@ -43,6 +44,26 @@ fn command_action(command: &str) -> AIAgentAction { } } +#[test] +fn tool_call_states_map_to_shared_tui_statuses() { + assert_eq!( + tool_call_display_state(None, true, None), + TuiStatusState::Constructing + ); + assert_eq!( + tool_call_display_state(None, false, None), + TuiStatusState::Pending + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::Blocked), false, None), + TuiStatusState::Blocked + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::RunningAsync), false, None), + TuiStatusState::Running + ); +} + /// One end-to-end pass over a tool call's lifecycle: the label text must /// change as the action moves through constructing (args still streaming), /// pending, awaiting approval, running, and terminal states. diff --git a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs index cd48a24225d..92d0955ee86 100644 --- a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs +++ b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs @@ -850,6 +850,7 @@ fn updating_agent_block_source( .block_list_mut() .mark_rich_content_dirty(view_id); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); terminal_model.lock().block_list_mut().append_rich_content( diff --git a/crates/warp_tui/src/tui_file_edits_view.rs b/crates/warp_tui/src/tui_file_edits_view.rs index 102ef174b42..44425e03ab7 100644 --- a/crates/warp_tui/src/tui_file_edits_view.rs +++ b/crates/warp_tui/src/tui_file_edits_view.rs @@ -36,9 +36,9 @@ use warpui_core::elements::tui::{ use warpui_core::elements::MouseStateHandle; use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext}; -use crate::agent_block_sections::{tool_call_glyph_style, tool_call_label_style}; use crate::editor_element::{TuiEditorElement, TuiEditorStyles}; -use crate::tool_call_labels::{tool_call_display_state, tool_call_glyph, ToolCallDisplayState}; +use crate::status::TuiStatusState; +use crate::tool_call_labels::tool_call_display_state; use crate::tui_builder::TuiUiBuilder; use crate::tui_diff_storage::{TuiDiffStorage, TuiDiffStorageEvent, TuiDiffStorageHandle}; @@ -253,7 +253,7 @@ impl TuiFileEditsView { } /// The action's display state, driving the header glyph and styling. - fn display_state(&self, app: &AppContext) -> ToolCallDisplayState { + fn display_state(&self, app: &AppContext) -> TuiStatusState { let status = self .action_model .as_ref(app) @@ -364,13 +364,13 @@ impl TuiFileEditsView { let state = self.display_state(app); // State lives in the glyph, mirroring `render_tool_call_section`. - let glyph_style = tool_call_glyph_style(state, builder); - let name_style = tool_call_label_style(state, builder); + let glyph_style = state.glyph_style(builder); + let name_style = state.label_style(builder); let bold = |style: TuiStyle| style.add_modifier(Modifier::BOLD); let embolden = |style: TuiStyle| if hovered { bold(style) } else { style }; let mut spans = vec![ - (format!("{} ", tool_call_glyph(state)), glyph_style), + (format!("{} ", state.glyph()), glyph_style), (label.to_owned(), embolden(bold(name_style))), ]; if let Some((added, removed)) = line_stats { diff --git a/crates/warp_tui/src/tui_shell_command_view.rs b/crates/warp_tui/src/tui_shell_command_view.rs index 79edbb7ba7d..f232c62f097 100644 --- a/crates/warp_tui/src/tui_shell_command_view.rs +++ b/crates/warp_tui/src/tui_shell_command_view.rs @@ -22,14 +22,11 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use crate::agent_block_sections::{ - render_fallback_tool_call_section, tool_call_glyph_style, tool_call_label_style, -}; +use crate::agent_block_sections::render_fallback_tool_call_section; use crate::terminal_block::TerminalBlockElement; use crate::terminal_use::user_controls_running_command; use crate::tool_call_labels::{ - tool_call_display_state, tool_call_glyph, tool_call_label, CommandBlockState, - ResolvedCommandBlock, + tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock, }; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::{TuiCLISubagentView, TuiCLISubagentViewEvent}; @@ -258,15 +255,15 @@ impl TuiView for TuiShellCommandView { let builder = TuiUiBuilder::from_app(app); let display_state = tool_call_display_state(status.as_ref(), false, Some(block.details.state)); - let glyph_style = tool_call_glyph_style(display_state, &builder); - let mut label_style = tool_call_label_style(display_state, &builder); + let glyph_style = display_state.glyph_style(&builder); + let mut label_style = display_state.label_style(&builder); if self.header_mouse_state.lock().unwrap().is_hovered() { label_style = label_style.add_modifier(Modifier::BOLD); } let collapsed = self.state.is_collapsed() && !self.user_controls_command(); let label = tool_call_label(&self.action, status.as_ref(), false, Some(&block.details)); let header_spans = vec![ - (format!("{} ", tool_call_glyph(display_state)), glyph_style), + (format!("{} ", display_state.glyph()), glyph_style), (format!("{label} "), label_style), ]; From ecc40d719beca9cb6a84cb2895ee59a41dfedb28 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 22:45:38 -0400 Subject: [PATCH 32/40] add defaults --- crates/warp_tui/src/agent_block.rs | 17 +-- crates/warp_tui/src/agent_block_tests.rs | 21 ++- crates/warp_tui/src/agent_message.rs | 80 +++++------ crates/warp_tui/src/agent_message_tests.rs | 50 ++++++- .../orchestrated_agent_identity_styling.rs | 9 ++ crates/warp_tui/src/orchestration_model.rs | 133 ++++++++++++++++++ 6 files changed, 246 insertions(+), 64 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 945014d596b..6fdf5864c7c 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -867,9 +867,7 @@ impl TuiAIBlock { app, ) } - TuiAIBlockSection::AgentMessage(message) => { - render_agent_message(&self.collapsible_states, message, app) - } + TuiAIBlockSection::AgentMessage(_) => return None, }) } fn rich_text_sections(message_id: &MessageId, text: &AIAgentText) -> Vec { @@ -1163,9 +1161,12 @@ impl TuiAIBlock { app, ) } - TuiAIBlockSection::AgentMessage(message) => { - render_agent_message(&self.collapsible_states, message, app) - } + TuiAIBlockSection::AgentMessage(message) => render_agent_message( + &self.collapsible_states, + message, + self.conversation_id, + app, + ), }; // One row of bottom padding separates sections; the last section @@ -1211,8 +1212,8 @@ fn last_row_content_width(element: &mut Box, width: u16, height: } /// The copy-able logical text for a section, or `None` for section kinds with no -/// clean logical form (tool calls, reasoning, summaries, todo lists), which fall -/// back to per-row grid text. +/// clean logical form (tool calls, reasoning, summaries, todo lists, or agent +/// messages), which fall back to per-row grid text. fn section_logical_text(section: &TuiAIBlockSection) -> Option { match section { TuiAIBlockSection::Input(text) => Some(text.clone()), diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index fd1001d4686..c062b09a45c 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -37,6 +37,7 @@ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; use crate::agent_message::agent_message_section_id; +use crate::orchestration_model::TuiOrchestrationModel; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -294,13 +295,7 @@ fn agent_block_renders_multiple_tool_calls_in_order() { fn orchestration_outputs_render_without_wait_for_events_tool_row() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); - let received = ReceivedMessageDisplay { - message_id: "message-1".to_string(), - sender_agent_id: "researcher".to_string(), - addresses: vec!["lead".to_string()], - subject: "Investigation complete".to_string(), - message_body: "Found the issue".to_string(), - }; + app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let wait_action = AIAgentAction { id: AIAgentActionId::from("wait-action".to_string()), action: AIAgentActionType::WaitForEvents { @@ -310,6 +305,13 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { task_id: TaskId::new("wait-task".to_string()), requires_result: false, }; + let received = ReceivedMessageDisplay { + message_id: "message-1".to_string(), + sender_agent_id: "researcher".to_string(), + addresses: vec!["lead".to_string()], + subject: "Investigation complete".to_string(), + message_body: "Found the issue".to_string(), + }; let block = test_agent_block( &mut app, FakeAgentBlockModel { @@ -340,6 +342,10 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { block.sections(app_ctx), vec![TuiAIBlockSection::AgentMessage(received)], ); + let lines = render_block_lines(block, 80, app_ctx); + assert_eq!(lines.len(), 1); + assert!(lines[0].ends_with(" ▸")); + assert!(!lines[0].contains("lifecycle event")); }); }); } @@ -550,6 +556,7 @@ fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { fn agent_message_defaults_collapsed_and_expands_through_block_state() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); + app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let received = received_message("run-1", "progress", "Starting work"); let message_id = agent_message_section_id(&received); let block = test_agent_block( diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs index 254d3d971e8..a6df1dc2fd8 100644 --- a/crates/warp_tui/src/agent_message.rs +++ b/crates/warp_tui/src/agent_message.rs @@ -1,16 +1,16 @@ //! Rich TUI rendering for messages received from orchestration participants. use warp::tui_export::{ - BlocklistAIHistoryModel, ConversationStatus, MessageId, ReceivedMessageDisplay, + AIConversationId, BlocklistAIHistoryModel, ConversationStatus, MessageId, + ReceivedMessageDisplay, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{tui_collapsible, Modifier, TuiContainer, TuiElement, TuiText}; use warpui_core::AppContext; use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; -use crate::orchestrated_agent_identity_styling::{ - assign_agent_identity_indices, stable_hash, AgentIdentity, -}; +use crate::orchestrated_agent_identity_styling::{stable_hash, AgentIdentity}; +use crate::orchestration_model::{TuiOrchestrationModel, TuiOrchestrationParticipantKind}; use crate::status::TuiStatusState; use crate::tui_builder::TuiUiBuilder; @@ -36,55 +36,41 @@ fn agent_status(status: &ConversationStatus) -> TuiStatusState { /// Resolves a sender's name and sibling-stable identity. fn message_presentation( sender_agent_id: &str, + current_conversation_id: AIConversationId, builder: &TuiUiBuilder, app: &AppContext, ) -> AgentMessagePresentation { let history = BlocklistAIHistoryModel::as_ref(app); - let sender = history - .conversation_id_for_agent_id(sender_agent_id) - .and_then(|conversation_id| history.conversation(&conversation_id)) - .or_else(|| { - history - .all_live_conversations() - .into_iter() - .map(|(_, conversation)| conversation) - .find(|conversation| { - conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) - }) - }); - let name = sender - .and_then(|conversation| conversation.agent_name()) - .unwrap_or("Unknown agent") - .to_owned(); + let palette = builder.agent_identity_palette(); + let participant = TuiOrchestrationModel::as_ref(app).participant_snapshot( + current_conversation_id, + sender_agent_id, + palette.len(), + app, + ); + let sender = participant + .conversation_id + .and_then(|conversation_id| history.conversation(&conversation_id)); + let name = participant.display_name; let status = sender .map(|conversation| agent_status(conversation.status())) .unwrap_or(TuiStatusState::Running); - let palette = builder.agent_identity_palette(); - let sibling_identity_index = sender.and_then(|conversation| { - let parent_id = conversation.parent_conversation_id()?; - let siblings = history.child_conversations_of(parent_id); - let sender_index = siblings - .iter() - .position(|sibling| sibling.id() == conversation.id())?; - let indices = assign_agent_identity_indices( - siblings - .iter() - .map(|sibling| sibling.agent_name().unwrap_or("Agent")), - palette.len(), - ); - indices.get(sender_index).copied() - }); let fallback_identity_index = (!palette.is_empty()).then(|| { usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() }); - let identity = sibling_identity_index - .or(fallback_identity_index) - .and_then(|index| palette.get(index)) - .cloned() - .unwrap_or(AgentIdentity { - glyph: "⟡", - style: builder.accent_text_style(), - }); + let identity = match participant.kind { + TuiOrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), + TuiOrchestrationParticipantKind::Child => participant + .identity_index + .or(fallback_identity_index) + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + TuiOrchestrationParticipantKind::Unknown => fallback_identity_index + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + }; AgentMessagePresentation { name, status, @@ -101,10 +87,16 @@ pub(crate) fn agent_message_section_id(message: &ReceivedMessageDisplay) -> Mess pub(crate) fn render_agent_message( states: &CollapsibleSectionStates, message: &ReceivedMessageDisplay, + current_conversation_id: AIConversationId, app: &AppContext, ) -> Box { let builder = TuiUiBuilder::from_app(app); - let presentation = message_presentation(&message.sender_agent_id, &builder, app); + let presentation = message_presentation( + &message.sender_agent_id, + current_conversation_id, + &builder, + app, + ); let header_spans = [ ( format!("{} ", presentation.status.glyph()), diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs index b5fc5683ab9..bb25f582fdc 100644 --- a/crates/warp_tui/src/agent_message_tests.rs +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -9,22 +9,31 @@ use warpui_core::{App, EntityId}; use super::{agent_message_section_id, agent_status, render_agent_message}; use crate::agent_block::CollapsibleSectionStates; +use crate::orchestration_model::TuiOrchestrationModel; use crate::status::TuiStatusState; use crate::tui_builder::TuiUiBuilder; const INFRA_RUN_ID: &str = "00000000-0000-0000-0000-000000000001"; const UI_RUN_ID: &str = "00000000-0000-0000-0000-000000000002"; +const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000003"; /// Registers the appearance and history models needed by the renderer. fn register_models(app: &mut App) { app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); + app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); } /// Creates one parent conversation for child-message tests. fn add_parent(app: &mut App) -> AIConversationId { app.update(|ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - history.start_new_conversation(EntityId::new(), false, false, false, ctx) + let conversation_id = + history.start_new_conversation(EntityId::new(), false, false, false, ctx); + history + .conversation_mut(&conversation_id) + .expect("parent conversation was just created") + .set_run_id(PARENT_RUN_ID.to_string()); + conversation_id }) }) } @@ -36,7 +45,7 @@ fn add_child( name: &str, run_id: &str, status: ConversationStatus, -) { +) -> AIConversationId { app.update(|ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { let surface_id = EntityId::new(); @@ -49,6 +58,36 @@ fn add_child( conversation.set_run_id(run_id.to_owned()); history.set_parent_for_conversation(conversation_id, parent_id); history.update_conversation_status(surface_id, conversation_id, status, ctx); + conversation_id + }) + }) +} + +#[test] +fn parent_sender_renders_as_orchestrator_in_child_transcript() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + let child_id = add_child( + &mut app, + parent_id, + "ui-implementer", + UI_RUN_ID, + ConversationStatus::InProgress, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let received = message(PARENT_RUN_ID, "instruction", "Hi from the orchestrator"); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render_agent_message(&states, &received, child_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + let header = frame.buffer.to_lines()[0].clone(); + assert!(header.contains("Orchestrator"), "{header}"); + assert!(!header.contains("Unknown agent"), "{header}"); }); }); } @@ -116,7 +155,7 @@ fn running_child_message_matches_the_design_layout_and_styles() { let message = message(INFRA_RUN_ID, "progress", "Starting to build infrastructure"); let mut presenter = TuiPresenter::new(); let frame = presenter.present_element( - render_agent_message(&states, &message, ctx), + render_agent_message(&states, &message, parent_id, ctx), TuiRect::new(0, 0, 80, 2), ctx, ); @@ -143,7 +182,7 @@ fn running_child_message_matches_the_design_layout_and_styles() { states.set_collapsed(agent_message_section_id(&message), false); let expanded = presenter.present_element( - render_agent_message(&states, &message, ctx), + render_agent_message(&states, &message, parent_id, ctx), TuiRect::new(0, 0, 80, 2), ctx, ); @@ -186,7 +225,7 @@ fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { states.set_collapsed(agent_message_section_id(&received), false); let mut presenter = TuiPresenter::new(); let wrapped = presenter.present_element( - render_agent_message(&states, &received, ctx), + render_agent_message(&states, &received, parent_id, ctx), TuiRect::new(0, 0, 24, 4), ctx, ); @@ -206,6 +245,7 @@ fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { render_agent_message( &states, &message(UI_RUN_ID, "Finished verification", " "), + parent_id, ctx, ), TuiRect::new(0, 0, 40, 2), diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs index 736d848bdc7..07a09655a1d 100644 --- a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -18,6 +18,15 @@ pub(crate) struct AgentIdentity { pub(crate) style: TuiStyle, } +impl Default for AgentIdentity { + fn default() -> Self { + Self { + glyph: "⟡", + style: TuiStyle::default(), + } + } +} + /// Builds the identity palette from the seven color roles in the design: /// themed cyan, blue, magenta, lilac, pink, green, and yellow. Lilac uses /// bright magenta while the remaining roles use their normal ANSI slots. diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 7d4ececf0ca..2eaa991bbde 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -24,10 +24,40 @@ use warp::tui_export::{ use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle}; +use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::session::create_local_terminal_session; use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; use crate::terminal_session_view::TuiTerminalSessionEvent; +/// Semantic role of one orchestration participant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiOrchestrationParticipantKind { + Orchestrator, + Child, + Unknown, +} + +/// Plain-data participant presentation shared by orchestration surfaces. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiOrchestrationParticipantSnapshot { + pub(crate) kind: TuiOrchestrationParticipantKind, + pub(crate) conversation_id: Option, + pub(crate) display_name: String, + pub(crate) identity_index: Option, +} + +impl TuiOrchestrationParticipantSnapshot { + /// Fallback for a sender that cannot be linked to the current tree. + fn unknown() -> Self { + Self { + kind: TuiOrchestrationParticipantKind::Unknown, + conversation_id: None, + display_name: "Unknown agent".to_string(), + identity_index: None, + } + } +} + /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension @@ -38,6 +68,26 @@ pub(crate) struct TuiOrchestrationModel { event_consumers_by_session: HashMap>, } +/// Resolves the topmost loaded orchestration parent, rejecting cycles. +fn orchestration_root( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> Option { + history.conversation(&conversation_id)?; + let mut current = conversation_id; + let mut visited = HashSet::new(); + while visited.insert(current) { + let conversation = history.conversation(¤t)?; + let Some(parent_id) = + history.resolved_parent_conversation_id_for_conversation(conversation) + else { + return Some(current); + }; + current = parent_id; + } + None +} + impl Entity for TuiOrchestrationModel { type Event = (); } @@ -45,6 +95,89 @@ impl Entity for TuiOrchestrationModel { impl SingletonEntity for TuiOrchestrationModel {} impl TuiOrchestrationModel { + /// Creates an unsubscribed model for participant-rendering tests. + #[cfg(test)] + pub(crate) fn new_for_test() -> Self { + Self { + child_session_by_conversation: HashMap::new(), + event_consumers_by_session: HashMap::new(), + } + } + + /// Resolves a sender's role, label, conversation, and stable child identity. + pub(crate) fn participant_snapshot( + &self, + current_conversation_id: AIConversationId, + sender_agent_id: &str, + identity_palette_len: usize, + ctx: &AppContext, + ) -> TuiOrchestrationParticipantSnapshot { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let Some(root_conversation_id) = orchestration_root(history, current_conversation_id) + else { + return TuiOrchestrationParticipantSnapshot::unknown(); + }; + let current_conversation = history.conversation(¤t_conversation_id); + let root_conversation = history.conversation(&root_conversation_id); + let orchestrator_agent_id = root_conversation + .and_then(|conversation| conversation.orchestration_agent_id()) + .or_else(|| { + current_conversation + .and_then(|conversation| conversation.parent_agent_id()) + .map(str::to_owned) + }); + let sender_conversation = history + .conversation_id_for_agent_id(sender_agent_id) + .and_then(|conversation_id| history.conversation(&conversation_id)) + .or_else(|| { + history + .all_live_conversations() + .into_iter() + .map(|(_, conversation)| conversation) + .find(|conversation| { + conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) + }) + }); + let sender_is_orchestrator = orchestrator_agent_id.as_deref() == Some(sender_agent_id) + || sender_conversation + .is_some_and(|conversation| conversation.id() == root_conversation_id); + if sender_is_orchestrator { + return TuiOrchestrationParticipantSnapshot { + kind: TuiOrchestrationParticipantKind::Orchestrator, + conversation_id: Some(root_conversation_id), + display_name: "Orchestrator".to_string(), + identity_index: None, + }; + } + let Some(sender_conversation) = sender_conversation else { + return TuiOrchestrationParticipantSnapshot::unknown(); + }; + let identity_index = sender_conversation + .parent_conversation_id() + .and_then(|parent_id| { + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == sender_conversation.id())?; + let indices = assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + identity_palette_len, + ); + indices.get(sender_index).copied() + }); + TuiOrchestrationParticipantSnapshot { + kind: TuiOrchestrationParticipantKind::Child, + conversation_id: Some(sender_conversation.id()), + display_name: sender_conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string(), + identity_index, + } + } /// Registers the singleton and subscribes it to [`TuiSessions`] so every /// session's `StartAgentExecutor` gets wired as sessions register. Must /// run before any session is created. From f097df9522b6aeb0656d670e1ae33eb4117f0b1c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 14:55:43 -0400 Subject: [PATCH 33/40] update shared code --- .../block/view_impl/orchestration.rs | 70 ++++----- .../ai/blocklist/orchestration_topology.rs | 78 ++++++++++ .../blocklist/orchestration_topology_tests.rs | 80 +++++++++++ app/src/tui_export.rs | 4 + crates/warp_tui/src/agent_block_sections.rs | 7 +- crates/warp_tui/src/agent_block_tests.rs | 3 - crates/warp_tui/src/agent_message.rs | 96 +++++++++---- crates/warp_tui/src/agent_message_tests.rs | 71 +++++----- crates/warp_tui/src/lib.rs | 1 - crates/warp_tui/src/orchestration_model.rs | 133 ------------------ crates/warp_tui/src/status.rs | 62 -------- crates/warp_tui/src/status_tests.rs | 50 ------- crates/warp_tui/src/tool_call_labels.rs | 56 +++++++- crates/warp_tui/src/tool_call_labels_tests.rs | 16 ++- crates/warp_tui/src/tui_file_edits_view.rs | 5 +- specs/CODE-1822/TECH.md | 49 +++++-- specs/code-1822-tui-local-children/TECH.md | 98 ++++++++++--- 17 files changed, 489 insertions(+), 390 deletions(-) delete mode 100644 crates/warp_tui/src/status.rs delete mode 100644 crates/warp_tui/src/status_tests.rs diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index f15ed6bee0c..6af5e6afd99 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration.rs @@ -37,6 +37,10 @@ use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size}; use crate::ai::blocklist::inline_action::requested_action::{ render_requested_action_row, render_requested_action_row_for_text, }; +use crate::ai::blocklist::orchestration_topology::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, + OrchestrationParticipantKind, +}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::appearance::Appearance; use crate::terminal::view::TerminalAction; @@ -63,14 +67,6 @@ impl OrchestrationParticipant { } } - fn unknown_child() -> Self { - Self { - display_name: "Unknown agent".to_string(), - avatar: OrchestrationAvatar::agent("Unknown agent".to_string()), - conversation_id: None, - } - } - fn is_orchestrator(&self) -> bool { matches!(&self.avatar, OrchestrationAvatar::Orchestrator) } @@ -90,21 +86,27 @@ fn participant_for_agent_id( orchestrator_agent_id: Option<&str>, app: &AppContext, ) -> OrchestrationParticipant { - if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) { - if let Some(conversation) = - BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) - { - return participant_for_conversation( - conversation, - orchestrator_agent_id, - Some(agent_id), - ); + let participant = resolve_orchestration_participant( + BlocklistAIHistoryModel::as_ref(app), + agent_id, + orchestrator_agent_id, + ); + let avatar = match participant.kind { + OrchestrationParticipantKind::Orchestrator => OrchestrationAvatar::Orchestrator, + OrchestrationParticipantKind::Agent | OrchestrationParticipantKind::Unknown => { + OrchestrationAvatar::agent(participant.display_name.clone()) } + }; + OrchestrationParticipant { + display_name: participant.display_name, + avatar, + conversation_id: match participant.kind { + OrchestrationParticipantKind::Orchestrator | OrchestrationParticipantKind::Unknown => { + None + } + OrchestrationParticipantKind::Agent => participant.conversation_id, + }, } - if orchestrator_agent_id.is_some_and(|id| id == agent_id) { - return OrchestrationParticipant::orchestrator(); - } - OrchestrationParticipant::unknown_child() } fn participant_for_conversation( @@ -131,18 +133,6 @@ fn participant_for_conversation( } } -fn orchestrator_agent_id_for_conversation( - conversation: &AIConversation, - app: &AppContext, -) -> Option { - match conversation.parent_conversation_id() { - Some(parent_id) => BlocklistAIHistoryModel::as_ref(app) - .conversation(&parent_id) - .and_then(|parent| parent.orchestration_agent_id()), - None => conversation.orchestration_agent_id(), - } -} - fn participant_for_current_conversation( props: Props, orchestrator_agent_id: Option<&str>, @@ -343,10 +333,9 @@ pub(super) fn render_messages_received_from_agents( if messages.is_empty() { return Empty::new().finish(); } - let orchestrator_agent_id = props - .model - .conversation(app) - .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); + let orchestrator_agent_id = props.model.conversation(app).and_then(|conversation| { + orchestrator_agent_id_for_conversation(BlocklistAIHistoryModel::as_ref(app), conversation) + }); let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); for (index, msg) in messages.iter().enumerate() { let sender = @@ -422,10 +411,9 @@ pub(super) fn render_send_message( let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let status = props.action_model.as_ref(app).get_action_status(action_id); - let orchestrator_agent_id = props - .model - .conversation(app) - .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); + let orchestrator_agent_id = props.model.conversation(app).and_then(|conversation| { + orchestrator_agent_id_for_conversation(BlocklistAIHistoryModel::as_ref(app), conversation) + }); let recipient_participants = participant_for_agent_ids(address, orchestrator_agent_id.as_deref(), app); let recipients = participant_display_names(&recipient_participants); diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index ff556cc32d3..1e419b24088 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -15,6 +15,84 @@ pub enum OrchestrationNavigationDirection { Next, } +/// Semantic role of a participant in an orchestration transcript. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrchestrationParticipantKind { + Orchestrator, + Agent, + Unknown, +} + +/// Frontend-independent identity for an orchestration participant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedOrchestrationParticipant { + pub kind: OrchestrationParticipantKind, + pub conversation_id: Option, + pub display_name: String, +} + +impl ResolvedOrchestrationParticipant { + fn orchestrator(conversation_id: Option) -> Self { + Self { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id, + display_name: "Orchestrator".to_string(), + } + } + + fn unknown() -> Self { + Self { + kind: OrchestrationParticipantKind::Unknown, + conversation_id: None, + display_name: "Unknown agent".to_string(), + } + } +} + +/// Returns the agent ID of the conversation that orchestrates `conversation`. +pub fn orchestrator_agent_id_for_conversation( + history: &BlocklistAIHistoryModel, + conversation: &AIConversation, +) -> Option { + match history.resolved_parent_conversation_id_for_conversation(conversation) { + Some(parent_id) => history + .conversation(&parent_id) + .and_then(AIConversation::orchestration_agent_id) + .or_else(|| conversation.parent_agent_id().map(str::to_owned)), + None => conversation + .parent_agent_id() + .map(str::to_owned) + .or_else(|| conversation.orchestration_agent_id()), + } +} + +/// Resolves a server-side agent ID to frontend-independent participant data. +pub fn resolve_orchestration_participant( + history: &BlocklistAIHistoryModel, + agent_id: &str, + orchestrator_agent_id: Option<&str>, +) -> ResolvedOrchestrationParticipant { + let conversation_id = history.conversation_id_for_agent_id(agent_id); + if orchestrator_agent_id == Some(agent_id) { + return ResolvedOrchestrationParticipant::orchestrator(conversation_id); + } + let Some(conversation_id) = conversation_id else { + return ResolvedOrchestrationParticipant::unknown(); + }; + let Some(conversation) = history.conversation(&conversation_id) else { + return ResolvedOrchestrationParticipant::unknown(); + }; + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent, + conversation_id: Some(conversation_id), + display_name: conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string(), + } +} + const DONE_STATUS_KEY: u8 = 3; fn pill_status_sort_key(status: Option<&ConversationStatus>) -> u8 { diff --git a/app/src/ai/blocklist/orchestration_topology_tests.rs b/app/src/ai/blocklist/orchestration_topology_tests.rs index 92d742fdeeb..133b008f350 100644 --- a/app/src/ai/blocklist/orchestration_topology_tests.rs +++ b/app/src/ai/blocklist/orchestration_topology_tests.rs @@ -1,9 +1,89 @@ +use uuid::Uuid; use warpui::{App, EntityId, ModelHandle}; use super::*; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::test_util::settings::initialize_history_persistence_for_tests; + +#[test] +fn participant_resolution_uses_the_direct_parent_as_orchestrator() { + App::test((), |mut app| async move { + initialize_history_persistence_for_tests(&mut app); + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let surface_id = EntityId::new(); + let root_run_id = Uuid::new_v4().to_string(); + let child_run_id = Uuid::new_v4().to_string(); + let grandchild_run_id = Uuid::new_v4().to_string(); + + let (root_id, child_id, grandchild_id) = history_model.update(&mut app, |history, ctx| { + let root_id = history.start_new_conversation(surface_id, false, false, false, ctx); + history.assign_run_id_for_conversation( + root_id, + root_run_id.clone(), + None, + surface_id, + ctx, + ); + let child_id = history.start_new_child_conversation( + surface_id, + "child".to_string(), + root_id, + None, + ctx, + ); + history.assign_run_id_for_conversation( + child_id, + child_run_id.clone(), + None, + surface_id, + ctx, + ); + let grandchild_id = history.start_new_child_conversation( + surface_id, + "grandchild".to_string(), + child_id, + None, + ctx, + ); + history.assign_run_id_for_conversation( + grandchild_id, + grandchild_run_id, + None, + surface_id, + ctx, + ); + (root_id, child_id, grandchild_id) + }); + + history_model.read(&app, |history, _| { + let grandchild = history + .conversation(&grandchild_id) + .expect("grandchild conversation exists"); + assert_eq!( + orchestrator_agent_id_for_conversation(history, grandchild), + Some(child_run_id.clone()) + ); + assert_eq!( + resolve_orchestration_participant(history, &child_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id: Some(child_id), + display_name: "Orchestrator".to_string(), + } + ); + assert_eq!( + resolve_orchestration_participant(history, &root_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent, + conversation_id: Some(root_id), + display_name: "Agent".to_string(), + } + ); + }); + }); +} + #[test] fn pill_order_keys_prioritize_attention_then_in_progress_then_done() { let blocked = ConversationStatus::Blocked { diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 38bdd5e363f..06043a6fb96 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -61,6 +61,10 @@ pub use crate::ai::blocklist::history_model::{ pub use crate::ai::blocklist::orchestration_event_streamer::{ register_agent_event_consumer, unregister_agent_event_consumer, }; +pub use crate::ai::blocklist::orchestration_topology::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, + OrchestrationParticipantKind, ResolvedOrchestrationParticipant, +}; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ apply_child_agent_model_override, block_context_from_terminal_model, diff --git a/crates/warp_tui/src/agent_block_sections.rs b/crates/warp_tui/src/agent_block_sections.rs index e90a344da3f..f09051fe3d3 100644 --- a/crates/warp_tui/src/agent_block_sections.rs +++ b/crates/warp_tui/src/agent_block_sections.rs @@ -78,9 +78,10 @@ pub(crate) fn render_input_section(text: &str, app: &AppContext) -> Box, diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index c062b09a45c..ab2aefd0875 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -37,7 +37,6 @@ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; use crate::agent_message::agent_message_section_id; -use crate::orchestration_model::TuiOrchestrationModel; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -295,7 +294,6 @@ fn agent_block_renders_multiple_tool_calls_in_order() { fn orchestration_outputs_render_without_wait_for_events_tool_row() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); - app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let wait_action = AIAgentAction { id: AIAgentActionId::from("wait-action".to_string()), action: AIAgentActionType::WaitForEvents { @@ -556,7 +554,6 @@ fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { fn agent_message_defaults_collapsed_and_expands_through_block_state() { App::test((), |mut app| async move { app.add_singleton_model(|_| Appearance::mock()); - app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); let received = received_message("run-1", "progress", "Starting work"); let message_id = agent_message_section_id(&received); let block = test_agent_block( diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs index a6df1dc2fd8..6ea4fe618a3 100644 --- a/crates/warp_tui/src/agent_message.rs +++ b/crates/warp_tui/src/agent_message.rs @@ -1,38 +1,79 @@ //! Rich TUI rendering for messages received from orchestration participants. use warp::tui_export::{ - AIConversationId, BlocklistAIHistoryModel, ConversationStatus, MessageId, + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, AIConversationId, + BlocklistAIHistoryModel, ConversationStatus, MessageId, OrchestrationParticipantKind, ReceivedMessageDisplay, }; use warpui::SingletonEntity; -use warpui_core::elements::tui::{tui_collapsible, Modifier, TuiContainer, TuiElement, TuiText}; +use warpui_core::elements::tui::{ + tui_collapsible, Modifier, TuiContainer, TuiElement, TuiStyle, TuiText, +}; use warpui_core::AppContext; use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; -use crate::orchestrated_agent_identity_styling::{stable_hash, AgentIdentity}; -use crate::orchestration_model::{TuiOrchestrationModel, TuiOrchestrationParticipantKind}; -use crate::status::TuiStatusState; +use crate::orchestrated_agent_identity_styling::{ + assign_agent_identity_indices, stable_hash, AgentIdentity, +}; use crate::tui_builder::TuiUiBuilder; /// Render-ready identity and lifecycle presentation for a message sender. struct AgentMessagePresentation { name: String, - status: TuiStatusState, + status: ConversationStatus, identity: AgentIdentity, } -/// Maps a shared conversation lifecycle into the common TUI status contract. -fn agent_status(status: &ConversationStatus) -> TuiStatusState { + +/// Compact glyph for a conversation's lifecycle status. +fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { match status { ConversationStatus::InProgress | ConversationStatus::TransientError - | ConversationStatus::WaitingForEvents => TuiStatusState::Running, - ConversationStatus::Success => TuiStatusState::Succeeded, - ConversationStatus::Error => TuiStatusState::Failed, - ConversationStatus::Cancelled => TuiStatusState::Cancelled, - ConversationStatus::Blocked { .. } => TuiStatusState::Blocked, + | ConversationStatus::WaitingForEvents => "●", + ConversationStatus::Success => "✓", + ConversationStatus::Error => "×", + ConversationStatus::Cancelled | ConversationStatus::Blocked { .. } => "■", } } +/// Semantic theme style for a conversation's lifecycle glyph. +fn conversation_status_glyph_style( + status: &ConversationStatus, + builder: &TuiUiBuilder, +) -> TuiStyle { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents + | ConversationStatus::Blocked { .. } => builder.attention_glyph_style(), + ConversationStatus::Success => builder.success_glyph_style(), + ConversationStatus::Error => builder.error_text_style(), + ConversationStatus::Cancelled => builder.muted_text_style(), + } +} + +/// Returns a child's stable identity index among its siblings. +fn child_identity_index( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, + palette_len: usize, +) -> Option { + let conversation = history.conversation(&conversation_id)?; + let parent_id = history.resolved_parent_conversation_id_for_conversation(conversation)?; + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == conversation_id)?; + assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + palette_len, + ) + .get(sender_index) + .copied() +} + /// Resolves a sender's name and sibling-stable identity. fn message_presentation( sender_agent_id: &str, @@ -42,31 +83,36 @@ fn message_presentation( ) -> AgentMessagePresentation { let history = BlocklistAIHistoryModel::as_ref(app); let palette = builder.agent_identity_palette(); - let participant = TuiOrchestrationModel::as_ref(app).participant_snapshot( - current_conversation_id, + let orchestrator_agent_id = history + .conversation(¤t_conversation_id) + .and_then(|conversation| orchestrator_agent_id_for_conversation(history, conversation)); + let participant = resolve_orchestration_participant( + history, sender_agent_id, - palette.len(), - app, + orchestrator_agent_id.as_deref(), ); let sender = participant .conversation_id .and_then(|conversation_id| history.conversation(&conversation_id)); let name = participant.display_name; let status = sender - .map(|conversation| agent_status(conversation.status())) - .unwrap_or(TuiStatusState::Running); + .map(|conversation| conversation.status().clone()) + .unwrap_or(ConversationStatus::InProgress); let fallback_identity_index = (!palette.is_empty()).then(|| { usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() }); let identity = match participant.kind { - TuiOrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), - TuiOrchestrationParticipantKind::Child => participant - .identity_index + OrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), + OrchestrationParticipantKind::Agent => participant + .conversation_id + .and_then(|conversation_id| { + child_identity_index(history, conversation_id, palette.len()) + }) .or(fallback_identity_index) .and_then(|index| palette.get(index)) .cloned() .unwrap_or_default(), - TuiOrchestrationParticipantKind::Unknown => fallback_identity_index + OrchestrationParticipantKind::Unknown => fallback_identity_index .and_then(|index| palette.get(index)) .cloned() .unwrap_or_default(), @@ -99,8 +145,8 @@ pub(crate) fn render_agent_message( ); let header_spans = [ ( - format!("{} ", presentation.status.glyph()), - presentation.status.glyph_style(&builder), + format!("{} ", conversation_status_glyph(&presentation.status)), + conversation_status_glyph_style(&presentation.status, &builder), ), ( format!("{} ", presentation.identity.glyph), diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs index bb25f582fdc..f2158bd59df 100644 --- a/crates/warp_tui/src/agent_message_tests.rs +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -1,38 +1,39 @@ use warp::tui_export::{ - AIConversationId, Appearance, BlocklistAIHistoryModel, ConversationStatus, - ReceivedMessageDisplay, + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + ConversationStatus, ReceivedMessageDisplay, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; use warpui_core::presenter::tui::TuiPresenter; use warpui_core::{App, EntityId}; -use super::{agent_message_section_id, agent_status, render_agent_message}; +use super::{agent_message_section_id, conversation_status_glyph, render_agent_message}; use crate::agent_block::CollapsibleSectionStates; -use crate::orchestration_model::TuiOrchestrationModel; -use crate::status::TuiStatusState; use crate::tui_builder::TuiUiBuilder; + const INFRA_RUN_ID: &str = "00000000-0000-0000-0000-000000000001"; const UI_RUN_ID: &str = "00000000-0000-0000-0000-000000000002"; const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000003"; /// Registers the appearance and history models needed by the renderer. fn register_models(app: &mut App) { - app.add_singleton_model(|_| Appearance::mock()); - app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); - app.add_singleton_model(|_| TuiOrchestrationModel::new_for_test()); + register_tui_session_view_test_singletons(app); } /// Creates one parent conversation for child-message tests. fn add_parent(app: &mut App) -> AIConversationId { app.update(|ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let surface_id = EntityId::new(); let conversation_id = - history.start_new_conversation(EntityId::new(), false, false, false, ctx); - history - .conversation_mut(&conversation_id) - .expect("parent conversation was just created") - .set_run_id(PARENT_RUN_ID.to_string()); + history.start_new_conversation(surface_id, false, false, false, ctx); + history.assign_run_id_for_conversation( + conversation_id, + PARENT_RUN_ID.to_string(), + None, + surface_id, + ctx, + ); conversation_id }) }) @@ -55,8 +56,14 @@ fn add_child( .conversation_mut(&conversation_id) .expect("child conversation was just created"); conversation.set_agent_name(name.to_owned()); - conversation.set_run_id(run_id.to_owned()); history.set_parent_for_conversation(conversation_id, parent_id); + history.assign_run_id_for_conversation( + conversation_id, + run_id.to_owned(), + None, + surface_id, + ctx, + ); history.update_conversation_status(surface_id, conversation_id, status, ctx); conversation_id }) @@ -104,36 +111,30 @@ fn message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { } #[test] -fn conversation_statuses_map_to_shared_tui_statuses() { +fn conversation_statuses_render_expected_glyphs() { assert_eq!( - agent_status(&ConversationStatus::InProgress), - TuiStatusState::Running + conversation_status_glyph(&ConversationStatus::InProgress), + "●" ); assert_eq!( - agent_status(&ConversationStatus::TransientError), - TuiStatusState::Running + conversation_status_glyph(&ConversationStatus::TransientError), + "●" ); assert_eq!( - agent_status(&ConversationStatus::WaitingForEvents), - TuiStatusState::Running + conversation_status_glyph(&ConversationStatus::WaitingForEvents), + "●" ); assert_eq!( - agent_status(&ConversationStatus::Blocked { + conversation_status_glyph(&ConversationStatus::Blocked { blocked_action: "approval".to_owned(), }), - TuiStatusState::Blocked - ); - assert_eq!( - agent_status(&ConversationStatus::Success), - TuiStatusState::Succeeded + "■" ); + assert_eq!(conversation_status_glyph(&ConversationStatus::Success), "✓"); + assert_eq!(conversation_status_glyph(&ConversationStatus::Error), "×"); assert_eq!( - agent_status(&ConversationStatus::Error), - TuiStatusState::Failed - ); - assert_eq!( - agent_status(&ConversationStatus::Cancelled), - TuiStatusState::Cancelled + conversation_status_glyph(&ConversationStatus::Cancelled), + "■" ); } @@ -172,8 +173,8 @@ fn running_child_message_matches_the_design_layout_and_styles() { let builder = TuiUiBuilder::from_app(ctx); assert_eq!( frame.buffer[(0, 0)].fg, - TuiStatusState::Running - .glyph_style(&builder) + builder + .attention_glyph_style() .fg .expect("running status has a foreground") ); diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 1547b06933e..8868506c927 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -40,7 +40,6 @@ mod resume; mod session_registry; mod skills_menu; mod slash_commands; -mod status; mod terminal_background; mod terminal_block; mod terminal_content_element; diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 2eaa991bbde..7d4ececf0ca 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -24,40 +24,10 @@ use warp::tui_export::{ use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle}; -use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::session::create_local_terminal_session; use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; use crate::terminal_session_view::TuiTerminalSessionEvent; -/// Semantic role of one orchestration participant. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TuiOrchestrationParticipantKind { - Orchestrator, - Child, - Unknown, -} - -/// Plain-data participant presentation shared by orchestration surfaces. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TuiOrchestrationParticipantSnapshot { - pub(crate) kind: TuiOrchestrationParticipantKind, - pub(crate) conversation_id: Option, - pub(crate) display_name: String, - pub(crate) identity_index: Option, -} - -impl TuiOrchestrationParticipantSnapshot { - /// Fallback for a sender that cannot be linked to the current tree. - fn unknown() -> Self { - Self { - kind: TuiOrchestrationParticipantKind::Unknown, - conversation_id: None, - display_name: "Unknown agent".to_string(), - identity_index: None, - } - } -} - /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension @@ -68,26 +38,6 @@ pub(crate) struct TuiOrchestrationModel { event_consumers_by_session: HashMap>, } -/// Resolves the topmost loaded orchestration parent, rejecting cycles. -fn orchestration_root( - history: &BlocklistAIHistoryModel, - conversation_id: AIConversationId, -) -> Option { - history.conversation(&conversation_id)?; - let mut current = conversation_id; - let mut visited = HashSet::new(); - while visited.insert(current) { - let conversation = history.conversation(¤t)?; - let Some(parent_id) = - history.resolved_parent_conversation_id_for_conversation(conversation) - else { - return Some(current); - }; - current = parent_id; - } - None -} - impl Entity for TuiOrchestrationModel { type Event = (); } @@ -95,89 +45,6 @@ impl Entity for TuiOrchestrationModel { impl SingletonEntity for TuiOrchestrationModel {} impl TuiOrchestrationModel { - /// Creates an unsubscribed model for participant-rendering tests. - #[cfg(test)] - pub(crate) fn new_for_test() -> Self { - Self { - child_session_by_conversation: HashMap::new(), - event_consumers_by_session: HashMap::new(), - } - } - - /// Resolves a sender's role, label, conversation, and stable child identity. - pub(crate) fn participant_snapshot( - &self, - current_conversation_id: AIConversationId, - sender_agent_id: &str, - identity_palette_len: usize, - ctx: &AppContext, - ) -> TuiOrchestrationParticipantSnapshot { - let history = BlocklistAIHistoryModel::as_ref(ctx); - let Some(root_conversation_id) = orchestration_root(history, current_conversation_id) - else { - return TuiOrchestrationParticipantSnapshot::unknown(); - }; - let current_conversation = history.conversation(¤t_conversation_id); - let root_conversation = history.conversation(&root_conversation_id); - let orchestrator_agent_id = root_conversation - .and_then(|conversation| conversation.orchestration_agent_id()) - .or_else(|| { - current_conversation - .and_then(|conversation| conversation.parent_agent_id()) - .map(str::to_owned) - }); - let sender_conversation = history - .conversation_id_for_agent_id(sender_agent_id) - .and_then(|conversation_id| history.conversation(&conversation_id)) - .or_else(|| { - history - .all_live_conversations() - .into_iter() - .map(|(_, conversation)| conversation) - .find(|conversation| { - conversation.orchestration_agent_id().as_deref() == Some(sender_agent_id) - }) - }); - let sender_is_orchestrator = orchestrator_agent_id.as_deref() == Some(sender_agent_id) - || sender_conversation - .is_some_and(|conversation| conversation.id() == root_conversation_id); - if sender_is_orchestrator { - return TuiOrchestrationParticipantSnapshot { - kind: TuiOrchestrationParticipantKind::Orchestrator, - conversation_id: Some(root_conversation_id), - display_name: "Orchestrator".to_string(), - identity_index: None, - }; - } - let Some(sender_conversation) = sender_conversation else { - return TuiOrchestrationParticipantSnapshot::unknown(); - }; - let identity_index = sender_conversation - .parent_conversation_id() - .and_then(|parent_id| { - let siblings = history.child_conversations_of(parent_id); - let sender_index = siblings - .iter() - .position(|sibling| sibling.id() == sender_conversation.id())?; - let indices = assign_agent_identity_indices( - siblings - .iter() - .map(|sibling| sibling.agent_name().unwrap_or("Agent")), - identity_palette_len, - ); - indices.get(sender_index).copied() - }); - TuiOrchestrationParticipantSnapshot { - kind: TuiOrchestrationParticipantKind::Child, - conversation_id: Some(sender_conversation.id()), - display_name: sender_conversation - .agent_name() - .filter(|name| !name.is_empty()) - .unwrap_or("Agent") - .to_string(), - identity_index, - } - } /// Registers the singleton and subscribes it to [`TuiSessions`] so every /// session's `StartAgentExecutor` gets wired as sessions register. Must /// run before any session is created. diff --git a/crates/warp_tui/src/status.rs b/crates/warp_tui/src/status.rs deleted file mode 100644 index 0897e574898..00000000000 --- a/crates/warp_tui/src/status.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Shared status presentation for compact TUI transcript rows. - -use warpui_core::elements::tui::TuiStyle; - -use crate::tui_builder::TuiUiBuilder; - -/// Coarse UI state shared by tool calls and orchestration participants. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum TuiStatusState { - /// Content is still being assembled and may be incomplete. - Constructing, - /// Work is queued but has not started. - Pending, - /// Work is blocked on user input or approval. - Blocked, - /// Work is actively executing. - Running, - /// Work completed successfully. - Succeeded, - /// Work completed with an error. - Failed, - /// Work was cancelled. - Cancelled, -} - -impl TuiStatusState { - /// The compact leading glyph for this status. - pub(crate) fn glyph(self) -> &'static str { - match self { - Self::Constructing | Self::Pending => "○", - Self::Blocked | Self::Cancelled => "■", - Self::Running => "●", - Self::Succeeded => "✓", - Self::Failed => "×", - } - } - - /// The semantic theme style for this status glyph. - pub(crate) fn glyph_style(self, builder: &TuiUiBuilder) -> TuiStyle { - match self { - Self::Constructing | Self::Pending => builder.dim_text_style(), - Self::Blocked | Self::Running => builder.attention_glyph_style(), - Self::Succeeded => builder.success_glyph_style(), - Self::Failed => builder.error_text_style(), - Self::Cancelled => builder.muted_text_style(), - } - } - - /// The semantic text style paired with this status. - pub(crate) fn label_style(self, builder: &TuiUiBuilder) -> TuiStyle { - match self { - Self::Constructing | Self::Pending => builder.dim_text_style(), - Self::Blocked | Self::Running | Self::Succeeded | Self::Failed | Self::Cancelled => { - builder.primary_text_style() - } - } - } -} - -#[cfg(test)] -#[path = "status_tests.rs"] -mod tests; diff --git a/crates/warp_tui/src/status_tests.rs b/crates/warp_tui/src/status_tests.rs deleted file mode 100644 index 18ca2fa56e4..00000000000 --- a/crates/warp_tui/src/status_tests.rs +++ /dev/null @@ -1,50 +0,0 @@ -use warp::tui_export::Appearance; -use warpui_core::App; - -use super::TuiStatusState; -use crate::tui_builder::TuiUiBuilder; - -#[test] -fn status_glyphs_match_the_shared_transcript_contract() { - assert_eq!(TuiStatusState::Constructing.glyph(), "○"); - assert_eq!(TuiStatusState::Pending.glyph(), "○"); - assert_eq!(TuiStatusState::Blocked.glyph(), "■"); - assert_eq!(TuiStatusState::Running.glyph(), "●"); - assert_eq!(TuiStatusState::Succeeded.glyph(), "✓"); - assert_eq!(TuiStatusState::Failed.glyph(), "×"); - assert_eq!(TuiStatusState::Cancelled.glyph(), "■"); -} - -#[test] -fn status_styles_reuse_semantic_builder_styles() { - App::test((), |app| async move { - app.add_singleton_model(|_| Appearance::mock()); - app.read(|ctx| { - let builder = TuiUiBuilder::from_app(ctx); - assert_eq!( - TuiStatusState::Pending.glyph_style(&builder), - builder.dim_text_style() - ); - assert_eq!( - TuiStatusState::Blocked.glyph_style(&builder), - builder.attention_glyph_style() - ); - assert_eq!( - TuiStatusState::Running.glyph_style(&builder), - builder.attention_glyph_style() - ); - assert_eq!( - TuiStatusState::Succeeded.glyph_style(&builder), - builder.success_glyph_style() - ); - assert_eq!( - TuiStatusState::Failed.glyph_style(&builder), - builder.error_text_style() - ); - assert_eq!( - TuiStatusState::Cancelled.glyph_style(&builder), - builder.muted_text_style() - ); - }); - }); -} diff --git a/crates/warp_tui/src/tool_call_labels.rs b/crates/warp_tui/src/tool_call_labels.rs index 6c21d28db94..1b4c81fba30 100644 --- a/crates/warp_tui/src/tool_call_labels.rs +++ b/crates/warp_tui/src/tool_call_labels.rs @@ -10,8 +10,10 @@ use warp::tui_export::{ StartAgentExecutionMode, SuggestNewConversationResult, }; use warp_core::command::ExitCode; +use warpui_core::elements::tui::TuiStyle; -use crate::status::TuiStatusState as State; +use self::ToolCallDisplayState as State; +use crate::tui_builder::TuiUiBuilder; /// Ground-truth state of the terminal block backing a shell-command tool /// call, resolved by the caller. When a block exists, its state supersedes @@ -42,6 +44,56 @@ pub(crate) struct ResolvedCommandBlock { /// so tool-call rows stay scannable one-liners. const MAX_INLINE_LEN: usize = 80; +/// Coarse presentation state for a tool call. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolCallDisplayState { + /// The tool call's arguments are still streaming and may be incomplete. + Constructing, + /// The tool call is waiting to begin execution. + Pending, + /// The tool call is blocked on user confirmation. + Blocked, + /// The tool call is executing asynchronously. + Running, + Succeeded, + Failed, + Cancelled, +} + +impl ToolCallDisplayState { + /// The compact leading glyph for this state. + pub(crate) fn glyph(self) -> &'static str { + match self { + Self::Constructing | Self::Pending => "○", + Self::Blocked | Self::Cancelled => "■", + Self::Running => "●", + Self::Succeeded => "✓", + Self::Failed => "×", + } + } + + /// The semantic theme style for this state's glyph. + pub(crate) fn glyph_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running => builder.attention_glyph_style(), + Self::Succeeded => builder.success_glyph_style(), + Self::Failed => builder.error_text_style(), + Self::Cancelled => builder.muted_text_style(), + } + } + + /// The semantic text style paired with this state. + pub(crate) fn label_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running | Self::Succeeded | Self::Failed | Self::Cancelled => { + builder.primary_text_style() + } + } + } +} + /// Collapses an optional action status into the coarse display state. /// `output_streaming` is whether the exchange output is still streaming; /// a status-less action in a streaming output is still being constructed @@ -52,7 +104,7 @@ pub(crate) fn tool_call_display_state( status: Option<&AIActionStatus>, output_streaming: bool, block_state: Option, -) -> State { +) -> ToolCallDisplayState { // A block existing means the command actually started executing, so its // state is authoritative over the action status/result. match block_state { diff --git a/crates/warp_tui/src/tool_call_labels_tests.rs b/crates/warp_tui/src/tool_call_labels_tests.rs index 6aa4a362ac2..a2ab0ad6d06 100644 --- a/crates/warp_tui/src/tool_call_labels_tests.rs +++ b/crates/warp_tui/src/tool_call_labels_tests.rs @@ -6,8 +6,10 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use super::{tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock}; -use crate::status::TuiStatusState; +use super::{ + tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock, + ToolCallDisplayState, +}; /// Builds a `Finished` status wrapping the given result. fn finished(result: AIAgentActionResultType) -> AIActionStatus { @@ -45,22 +47,22 @@ fn command_action(command: &str) -> AIAgentAction { } #[test] -fn tool_call_states_map_to_shared_tui_statuses() { +fn tool_call_statuses_map_to_tool_call_display_states() { assert_eq!( tool_call_display_state(None, true, None), - TuiStatusState::Constructing + ToolCallDisplayState::Constructing ); assert_eq!( tool_call_display_state(None, false, None), - TuiStatusState::Pending + ToolCallDisplayState::Pending ); assert_eq!( tool_call_display_state(Some(&AIActionStatus::Blocked), false, None), - TuiStatusState::Blocked + ToolCallDisplayState::Blocked ); assert_eq!( tool_call_display_state(Some(&AIActionStatus::RunningAsync), false, None), - TuiStatusState::Running + ToolCallDisplayState::Running ); } diff --git a/crates/warp_tui/src/tui_file_edits_view.rs b/crates/warp_tui/src/tui_file_edits_view.rs index 44425e03ab7..bdbca9930f5 100644 --- a/crates/warp_tui/src/tui_file_edits_view.rs +++ b/crates/warp_tui/src/tui_file_edits_view.rs @@ -37,8 +37,7 @@ use warpui_core::elements::MouseStateHandle; use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext}; use crate::editor_element::{TuiEditorElement, TuiEditorStyles}; -use crate::status::TuiStatusState; -use crate::tool_call_labels::tool_call_display_state; +use crate::tool_call_labels::{tool_call_display_state, ToolCallDisplayState}; use crate::tui_builder::TuiUiBuilder; use crate::tui_diff_storage::{TuiDiffStorage, TuiDiffStorageEvent, TuiDiffStorageHandle}; @@ -253,7 +252,7 @@ impl TuiFileEditsView { } /// The action's display state, driving the header glyph and styling. - fn display_state(&self, app: &AppContext) -> TuiStatusState { + fn display_state(&self, app: &AppContext) -> ToolCallDisplayState { let status = self .action_model .as_ref(app) diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 21cd6d6f56d..a98b6077ed6 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -20,14 +20,26 @@ The frontend-neutral edit state, option snapshots, and the reusable selector thi Live catalogs come from `HarnessAvailabilityModel` ([`app/src/ai/harness_availability.rs`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/harness_availability.rs)), `LLMPreferences`, `CloudAmbientAgentEnvironment`, `ConnectedSelfHostedWorkersModel`, `CloudAgentSettings`, `UserWorkspaces`. ### TUI plumbing -- [`crates/warp_tui/src/agent_block.rs (105-306) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/agent_block.rs#L105-L306) — `TuiToolCallView` enum plus `sync_action_views`, the lazy per-action child-view registration seam (currently `FileEdits`, `ShellCommand`). +- `crates/warp_tui/src/agent_block.rs` — `TuiToolCallView` plus `sync_action_views`, the lazy per-action child-view registration seam for `FileEdits`, `ShellCommand`, and `OrchestrationBlock`. - [`crates/warp_tui/src/terminal_session_view.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/terminal_session_view.rs) — renders transcript, inline menu, input box, footer; focuses the input at startup (620) and after restore flows (808, 839, 867). - [`crates/warp_tui/src/inline_menu.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/inline_menu.rs) — `TuiInlineMenuHandle`/`TuiInlineMenuSnapshot`; scroll/selection math shared with GUI via `warp_search_core::inline_menu::InlineMenuSelection`. - [`crates/warp_tui/src/tool_call_labels.rs (503-577) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tool_call_labels.rs#L503-L577) — existing static RunAgents status labels (kept for restored/terminal fallbacks). - [`crates/warp_tui/src/tui_builder.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tui_builder.rs) — `TuiUiBuilder` theme→style recipes; all colors derive from `WarpTheme`, no raw hex. - [`app/src/tui_export.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/tui_export.rs) — the sole `warp` → `warp_tui` export seam. -There is no TUI permission/confirmation UI for `RunAgents` today and no generalized input-hiding mechanism; the closest precedent is the inline-menu overlay, which keeps the input visible and focused. +The `RunAgents` permission card is registered as a stateful `TuiAIBlock` child view. Session input replacement is derived from the front-of-queue blocker rather than stored as a separate suppression flag, so draft input state remains owned by the normal input view. + +### Local child runtime and participant identity (later stack layers) +The permission card is followed by three runtime layers: +- [specs/code-1822-tui-multi-session/TECH.md](../code-1822-tui-multi-session/TECH.md) introduces `TuiSessions`, retaining a complete view and terminal manager for each focused or background terminal surface. +- [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md) introduces `TuiOrchestrationModel`, which materializes native local Oz children as background TUI sessions and owns only session/event-consumer runtime mappings. +- The rich-message change on top renders `MessagesReceivedFromAgents` using frontend-neutral participant discovery shared with the GUI. + +Incoming `ReceivedMessageDisplay` values carry a server-side sender run id, not a display name, status, or local conversation id. `BlocklistAIHistoryModel` already owns the durable data needed to interpret that id: the run-id reverse index, loaded conversations, immediate-parent links, parent-to-children index, participant names, and `ConversationStatus`. `app/src/ai/blocklist/orchestration_topology.rs` therefore owns the shared semantic bridge: +- `orchestrator_agent_id_for_conversation` resolves the current conversation's immediate parent agent. +- `resolve_orchestration_participant` maps the sender run id to role, local conversation id, and display name through the history index. + +This resolution is a one-parent lookup plus an indexed agent-id lookup, not a second graph traversal. `TuiOrchestrationModel` remains an ephemeral session materializer so restored, remote, or otherwise pre-existing conversations do not require duplicated participant metadata in the TUI coordinator. GUI and TUI apply their own presentation after the shared semantic result is resolved. ## Proposed changes ### 1. TUI orchestration block `crates/warp_tui/src/orchestration_block.rs` @@ -63,26 +75,47 @@ Input visibility is a pure function of the front-of-queue blocker rather than a ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. +### 5. Full-view sessions and local child materialization +`TuiSessions` replaces the single-session root with a registry that retains focused and background `TuiTerminalSessionView`s. The root renders and routes input only to the focused session. `TuiOrchestrationModel` subscribes to every registered session's `StartAgentExecutor`, including child sessions, so nested local Oz children can be materialized without putting background views into the render or responder chain. + +The coordinator uses the shared local-launch helpers, creates the child's background session, establishes conversation lineage in `BlocklistAIHistoryModel`, applies inherited and requested model settings, registers event consumers, and submits the first prompt. Its state is limited to child-conversation → session and session → event-consumer ownership; conversation topology and participant metadata are not mirrored. + +### 6. Rich orchestration transcript messages +`TuiAIBlockSection::AgentMessage` preserves each received message payload. `agent_message.rs` resolves the current conversation's immediate orchestrator and the sender through the shared history/topology API, then applies TUI-only presentation: +- direct `ConversationStatus` glyph/style, +- deterministic sibling-based identity color and glyph, +- bold participant name, +- collapsed-by-default body with subject fallback and hanging indentation. +Opaque `EventsFromAgents` ids render no transcript row. Tool calls keep a separate `ToolCallDisplayState` because constructing and pending are tool-call states, not conversation lifecycle states. ## Testing and validation Focused unit coverage: - `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. Focus regression coverage drives the model-page search editor, confirms a result as a row click does, then verifies that Acceptance owns focus so `Ctrl+E` is no longer shadowed by the hidden editor. The interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. - `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. -- `agent_identity_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. +- `orchestrated_agent_identity_styling_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. - `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. +- `orchestration_topology_tests.rs` covers shared participant resolution and immediate-parent semantics for nested agents. +- `agent_message_tests.rs` covers orchestrator/agent labels, direct conversation-status presentation, identity styling, collapse/expand behavior, wrapping, and subject fallback. +- `agent_block_tests.rs` covers rich-message section extraction, omission of opaque lifecycle ids and `WaitForEvents`, and block-owned collapse state. +- `tool_call_labels_tests.rs` independently covers tool-call-only presentation states. Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, model search → click result → `Ctrl+E` reopening configuration from Acceptance, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. ## Orchestration -The work ships as a four-PR Graphite stack, each mergeable on its own: -1. `harry/code-1822-edit-state` — the frontend-neutral orchestration domain module (`app/src/ai/orchestration/`: edit state, session, transitions, providers, validation) and the executor retarget; specified in [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md). -2. `harry/code-1822-option-snapshots` — option snapshots and their builders, plus the behavior-preserving GUI picker adaptation onto them; specified in [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md). -3. `harry/code-1822-tui-option-selector` — the reusable `TuiOptionSelector` primitive; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). -4. The final PR (this spec's remaining scope) — the TUI orchestration block, generalized input replacement, theming, and agent identity, reviewed against the PRODUCT invariants. +The implementation ships as a Graphite stack whose layers remain independently reviewable: +1. `harry/code-1822-generic-editor-view` — reusable TUI editor view; specified in [specs/code-1822-tui-generic-editor-view/TECH.md](../code-1822-tui-generic-editor-view/TECH.md). +2. `harry/code-1822-tui-option-selector` — reusable `TuiOptionSelector`; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). +3. `harry/code-1822-tui-orchestration-card` — permission/configuration card, input replacement, theming, and request identity. +4. `harry/code-1822-tui-multi-session` — retained full-view session registry; specified in [specs/code-1822-tui-multi-session/TECH.md](../code-1822-tui-multi-session/TECH.md). +5. `harry/code-1822-tui-local-children` — native local Oz child materialization; specified in [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md). +6. `harry/code-1822-rich-child-message-rendering` — shared participant resolution and rich received-message rows. +7. `harry/code-1822-tui-tab-bar-component` and `harry/code-1822-orchestration-tab-bar` — child-session navigation and orchestration-specific tab presentation. +8. `harry/code-1822-cloud-agent-orchestration` — remote/cloud child materialization. ## Risks and mitigations - Catalog events arriving mid-configuration can reshape option lists — the selector preserves the selected id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. - Focus derivation vs. event ordering: `SpawningStarted` must flip `wants_focus` before the next render; both arrive through the same entity-event loop, and the render-time derivation (not cached state) makes late events self-correcting. - Theme switches would rebuild the identity palette; the card pins its palette at construction so in-flight requests keep stable identities, at the cost of using pre-switch colors until the next request. +- Participant lookup depends on history indexes being updated through canonical history-model mutation APIs. Tests and runtime launch paths use those APIs rather than adding render-time scans or mirroring participant state in `TuiOrchestrationModel`. diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index d58e564dc78..0a93eb68d94 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -1,7 +1,9 @@ -# TECH: `TuiOrchestrationModel` + background local child agents +# TECH: TUI local child agents and rich orchestration messages This change builds on the full-view `TuiSessions` container. Accepting a local `run_agents` request in the TUI creates native Oz children in background terminal sessions while the parent remains focused and receives orchestration traffic. +Received messages render as rich, collapsible participant rows in both parent +and child transcripts. ## Architecture ### Shared local Oz launch contract The GUI and TUI share the frontend-neutral parts of native child launch through @@ -57,8 +59,36 @@ The coordinator stores only frontend-specific runtime ownership (`crates/warp_tui/src/orchestration_model.rs (31-39)`): - `child_session_by_conversation` maps a child conversation to its background session. - `event_consumers_by_session` records which conversation streams each live session consumes. -Conversation lineage remains canonical in `BlocklistAIHistoryModel`. Removing a -conversation also removes its id from `children_by_parent` +`TuiOrchestrationModel` intentionally does not duplicate participant names, +statuses, agent-id indexes, or ancestry. It is an ephemeral materializer for +TUI sessions; restored conversations and participants created by other +frontends can exist without passing through it. + +Conversation identity and lineage remain canonical in +`BlocklistAIHistoryModel`: +- `agent_id_to_conversation_id` resolves the server-side run id carried by an + incoming message to the loaded `AIConversation`. +- `parent_conversation_id` / `parent_agent_id` identify the current + conversation's immediate orchestrator. +- `children_by_parent` provides sibling order for deterministic TUI identity + styling. +- `AIConversation` owns the participant's display name and + `ConversationStatus`. + +`app/src/ai/blocklist/orchestration_topology.rs` exposes the semantic resolution +shared by GUI and TUI: +- `orchestrator_agent_id_for_conversation` resolves only the immediate parent, + with `parent_agent_id` as the fallback when the parent conversation is not + loaded. +- `resolve_orchestration_participant` uses the history model's reverse index to + return the participant role, local conversation id, and display name. + +This is not a second orchestration graph or a full-tree traversal. It bridges +the two identifiers available at render time: the current local conversation +id and `ReceivedMessageDisplay::sender_agent_id`. Frontends project the shared +semantic result into their own presentation: the GUI chooses an avatar and +navigation behavior, while the TUI chooses a terminal glyph/color identity. +Removing a conversation also removes its id from `children_by_parent` (`app/src/ai/blocklist/history_model.rs (2112-2182)`). ### Unsupported modes and failed launch cleanup Local CLI-harness and remote requests resolve as explicit per-child failures @@ -72,39 +102,73 @@ and echoes its id to `StartAgentExecutor`. The resulting cleanup event: - unregisters consumers when the session is removed. This leaves no dead child conversation, session, or streamer registration. ### Transcript rendering -`crates/warp_tui/src/agent_block.rs (775-888)`: +`crates/warp_tui/src/agent_block.rs`: - suppresses the `WaitForEvents` tool-call row, matching the GUI, -- renders sender and subject for each `MessagesReceivedFromAgents` entry, and -- renders the number of received lifecycle events for `EventsFromAgents`. -Lifecycle output currently contains event ids rather than event details, so the -TUI intentionally renders a count rather than a sender/status transition. +- preserves every `MessagesReceivedFromAgents` payload as an `AgentMessage` + section, and +- omits `EventsFromAgents` rows because those outputs contain opaque ids rather + than displayable participant or lifecycle data. + +`crates/warp_tui/src/agent_message.rs` owns TUI presentation: +1. Resolve the current conversation's immediate orchestrator through the + shared topology helper. +2. Resolve the sender run id through `BlocklistAIHistoryModel`. +3. Read the sender's display name and `ConversationStatus` from the resolved + conversation. +4. Assign a deterministic TUI identity from the sender's sibling order; use a + stable sender-id hash only when no loaded sibling relationship exists. +5. Render a collapsed-by-default row containing the conversation-status glyph, + participant identity glyph, bold name, and disclosure chevron. Expansion + shows the message body with a hanging indent, falling back to the subject + when the body is blank. + +Conversation rows use `ConversationStatus` directly. Tool calls retain the +separate `ToolCallDisplayState` because constructing and pending tool calls are +not conversation lifecycle states. Both use the same semantic +`TuiUiBuilder` color recipes without forcing their domain models into one enum. ## Exports `app/src/tui_export.rs (52-75)` exposes the shared child-launch functions and prepared result plus the `StartAgentExecutor` request/event/outcome types needed -by the TUI surface bridge. Server-client and execution-profile implementation -types remain behind the shared launch API. +by the TUI surface bridge. It also exports the frontend-neutral participant +resolution functions and result types from `orchestration_topology`. GUI +elements, TUI styles, server-client types, and execution-profile implementation +types remain behind their respective boundaries. ## Non-goals - Local CLI-harness children (Claude, Codex, OpenCode, Gemini). - Remote/cloud child materialization. - Navigation to or revealing background child sessions. - Removing completed child sessions; successful children remain retained like GUI hidden panes. -- Rich message bodies and per-event lifecycle status rendering. +- Rendering opaque lifecycle event ids as transcript content. ## Testing and validation - `crates/warp_tui/src/orchestration_model_tests.rs (154-221)` verifies that local-harness and remote requests resolve with explicit failures while leaving no child topology, extra session, or event-consumer state. It also verifies that failed-launch cleanup preserves unrelated retained sessions. -- `crates/warp_tui/src/agent_block_tests.rs (290-362)` renders orchestration messages and lifecycle - counts while asserting that `WaitForEvents` contributes no tool row. +- `app/src/ai/blocklist/orchestration_topology_tests.rs` verifies shared + participant discovery and that a grandchild resolves its direct parent, + rather than the tree root, as orchestrator. +- `crates/warp_tui/src/agent_message_tests.rs` verifies parent/orchestrator + labeling, direct `ConversationStatus` glyphs and styles, deterministic child + identity presentation, collapse behavior, wrapping, and subject fallback. +- `crates/warp_tui/src/agent_block_tests.rs` verifies that received messages + remain distinct rich sections, opaque lifecycle ids render no row, + `WaitForEvents` contributes no tool row, and collapse state is owned by the + agent block. +- `crates/warp_tui/src/tool_call_labels_tests.rs` keeps tool-call-only + constructing, pending, blocked, running, and terminal presentation covered + independently of conversation lifecycle state. - `app/src/ai/llms_tests.rs (936-959)` verifies that an explicit surface override precedes the TUI file-backed default. - `app/src/ai/blocklist/history_model_tests.rs (1872-1897)` verifies that removing a child conversation cleans the parent index. -- `cargo check -p warp_tui` passes. -- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` passes. -- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` passes. + +Validation commands: +- `cargo nextest run -p warp_tui` +- `cargo nextest run -p warp -E 'test(orchestration_topology)'` +- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` +- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` +- `./script/format` ## Follow-ups - Add local CLI-harness children by reusing the existing local-harness preparation path. - Add a TUI-native remote child materializer. -- Add richer received-message and lifecycle-event rendering. - Add child-session navigation and status UI on top of the retained `TuiSessions` entries. From 87dd32b329fae4562f2bd63f87d0ae51d72c4682 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 15:28:16 -0400 Subject: [PATCH 34/40] fix exmpty padding for exchange --- crates/warp_tui/src/agent_block.rs | 10 ++++++- crates/warp_tui/src/agent_block_tests.rs | 33 ++++++++++++++++++++++ specs/CODE-1822/TECH.md | 2 +- specs/code-1822-tui-local-children/TECH.md | 4 +-- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 6fdf5864c7c..e31c85ac478 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -1087,8 +1087,16 @@ impl TuiAIBlock { /// Builds this block's generic TUI element tree. fn render_element(&self, app: &AppContext) -> Box { let output_streaming = self.block_model.status(app).is_streaming(); - let mut column = TuiFlex::column(); + + // Keep the view registered so a streaming exchange can gain visible + // sections later, but do not reserve inter-block padding while every + // message in this exchange is intentionally hidden. let sections = self.sections(app); + if sections.is_empty() { + return TuiFlex::column().finish(); + } + + let mut column = TuiFlex::column(); let last_index = sections.len().saturating_sub(1); for (index, section) in sections.iter().enumerate() { let element = match section { diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index ab2aefd0875..75d9e5d3680 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -348,6 +348,39 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { }); } +#[test] +fn hidden_only_orchestration_exchange_has_zero_height() { + App::test((), |mut app| async move { + let wait_action = AIAgentAction { + id: AIAgentActionId::from("wait-action".to_string()), + action: AIAgentActionType::WaitForEvents { + tool_call_id: "wait-call".to_string(), + idle_timeout_seconds: 600, + }, + task_id: TaskId::new("wait-task".to_string()), + requires_result: false, + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("m1", wait_action), + AIAgentOutputMessage::events_from_agents( + MessageId::new("m2".to_owned()), + vec!["event-1".to_owned()], + ), + ]), + }, + ); + + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(block.sections(ctx).is_empty()); + assert_eq!(desired_height(block, 80, ctx), 0); + }); + }); +} #[test] fn tool_call_row_glyph_and_colors_reflect_state() { App::test((), |app| async move { diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index a98b6077ed6..7a0131caae4 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -96,7 +96,7 @@ Focused unit coverage: - `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. - `orchestration_topology_tests.rs` covers shared participant resolution and immediate-parent semantics for nested agents. - `agent_message_tests.rs` covers orchestrator/agent labels, direct conversation-status presentation, identity styling, collapse/expand behavior, wrapping, and subject fallback. -- `agent_block_tests.rs` covers rich-message section extraction, omission of opaque lifecycle ids and `WaitForEvents`, and block-owned collapse state. +- `agent_block_tests.rs` covers rich-message section extraction, omission of opaque lifecycle ids and `WaitForEvents`, zero-height rendering for hidden-only exchanges, and block-owned collapse state. - `tool_call_labels_tests.rs` independently covers tool-call-only presentation states. Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, model search → click result → `Ctrl+E` reopening configuration from Acceptance, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 0a93eb68d94..eb58404a52f 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -152,8 +152,8 @@ types remain behind their respective boundaries. identity presentation, collapse behavior, wrapping, and subject fallback. - `crates/warp_tui/src/agent_block_tests.rs` verifies that received messages remain distinct rich sections, opaque lifecycle ids render no row, - `WaitForEvents` contributes no tool row, and collapse state is owned by the - agent block. + `WaitForEvents` contributes no tool row, hidden-only exchanges reserve no + whitespace, and collapse state is owned by the agent block. - `crates/warp_tui/src/tool_call_labels_tests.rs` keeps tool-call-only constructing, pending, blocked, running, and terminal presentation covered independently of conversation lifecycle state. From 732e09627c3b211847faefd41e7f54496047e377 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:29:21 -0400 Subject: [PATCH 35/40] Add reusable TUI tab bar component specs Co-Authored-By: Oz --- .../PRODUCT.md | 91 ++++++++++++++++++ specs/code-1822-tui-tab-bar-component/TECH.md | 95 +++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 specs/code-1822-tui-tab-bar-component/PRODUCT.md create mode 100644 specs/code-1822-tui-tab-bar-component/TECH.md diff --git a/specs/code-1822-tui-tab-bar-component/PRODUCT.md b/specs/code-1822-tui-tab-bar-component/PRODUCT.md new file mode 100644 index 00000000000..8f7f2a7e9d4 --- /dev/null +++ b/specs/code-1822-tui-tab-bar-component/PRODUCT.md @@ -0,0 +1,91 @@ +# PRODUCT: Reusable TUI Tab-Bar Component +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) + +## Summary +The TUI element library gains a reusable horizontal tab-bar component that renders an optional main tab and a pageable list of secondary tabs. The component owns width-dependent layout and interaction geometry while callers own application selection, focus, and page state through semantic inputs and callbacks. + +## Figma +The orchestration designs establish the component states; the component itself remains domain-neutral: +- Unfocused tab bar: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-20498&m=dev +- Focused tab bar: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-19947&m=dev +- Truncated final tab and overflow: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=881-21464&m=dev + +## Goals +- Give TUI surfaces one reusable component for horizontal tabs, truncation, overflow paging, and pointer interaction. +- Keep application-specific selection and page state outside the component. +- Keep width-derived visible ranges and page boundaries private to the component. + +## Non-goals +- Knowing about orchestration, conversations, sessions, agents, or Warp-specific navigation. +- Owning application selection, keyboard focus, or persisted page anchors. +- Choosing colors, icons, labels, maximum widths, or keybindings for a caller. +- Rendering context menus, close buttons, drag reordering, or pinned-tab controls. + +## Behavior +### Inputs and output +1. A caller can provide: + - An optional main tab. + - An ordered list of secondary tabs. + - A stable key and label for every tab. + - An optional leading glyph and glyph style for every tab. + - The selected tab key, if any. + - Whether the bar is focused. + - Focused, unfocused, selected, background, divider, and overflow styles. + - An optional maximum label width in terminal display cells. + - The current secondary-page anchor. +2. The component renders exactly one terminal row. It never wraps tabs onto another row. +3. The main tab, when present, is fixed at the leading edge and does not participate in secondary-tab paging. +4. The caller controls any label or divider surrounding the tabs; the component does not hard-code product copy. +5. An empty secondary list is valid. The component renders the supplied main tab and surrounding chrome without overflow controls. + +### Ownership +6. The component privately owns: + - Stable mouse state for tabs and overflow controls. + - Width-dependent tab packing. + - The settled visible secondary range. + - Previous and next page boundaries. + - Hit-test geometry. +7. The caller cannot read or mutate the settled visible range or page-boundary geometry. +8. The component does not mutate application selection, page state, focus, models, or caller-owned collections. +9. The component communicates only semantic outcomes: + - `SelectTab(key)` when a tab or keyboard navigation chooses a tab. + - `PageChanged(anchor_key)` when an overflow control chooses another page. +10. Rebuilding or resizing the element does not recreate mouse state for tab keys that remain present. +11. Removed tab keys release their retained component state and cannot remain clickable. + +### Selection and focus presentation +12. The selected key determines which tab uses the selected treatment. The component has no pending selection distinct from the caller's selected key. +13. Focused and unfocused selected treatments are independently caller-configurable. +14. Focus changes affect presentation only. Focusing the component does not select a different tab or change the page. +15. If the selected key is absent, the component renders no selected tab and continues to lay out and dispatch interactions normally. + +### Label width and truncation +16. Width calculations use terminal display cells rather than Unicode scalar count or byte length. +17. When a maximum label width is supplied, every label is constrained to that many display cells, including the ellipsis. +18. A label exceeding its maximum is truncated with `...`. +19. A label within its maximum is rendered in full. +20. Wide and combining Unicode characters never split into invalid text or corrupt following cell alignment. +21. The last visible secondary tab may be truncated below its configured maximum when required to preserve an applicable overflow control. +22. At narrow widths, fixed leading content and applicable overflow controls take priority over secondary-label content. The component never paints outside its assigned row. + +### Paging +23. The component packs secondary tabs beginning at the caller's page anchor. +24. A next overflow control appears when later secondary tabs are hidden. +25. A previous overflow control appears when earlier secondary tabs are hidden. +26. A control with no page in its direction is not actionable. +27. Activating an overflow control emits `PageChanged` with the anchor computed from the component's settled layout. +28. Paging does not emit `SelectTab`. +29. Paging does not change focus. +30. When the caller supplies a new page anchor, the next layout settles the visible range from that anchor and clamps an unavailable anchor to a valid page. +31. Resizing recomputes visible tabs and page boundaries from the same supplied order and anchor. + +### Navigation and pointer behavior +32. Activating a visible tab emits `SelectTab` for that tab's stable key. +33. A tab remains clickable regardless of the bar's focused presentation. +34. Activating a tab never changes focus by itself. +35. The component can resolve previous and next navigation from its private settled layout: + - When the selected tab is visible, navigation uses the complete supplied order and wraps at both ends. + - When the selected tab is off-page, previous resolves to the last visible secondary tab and next resolves to the first visible secondary tab. +36. Resolved navigation emits only `SelectTab`; the caller decides what selecting that key means. +37. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. +38. Pointer press/release outside a target does not invoke its callback. diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md new file mode 100644 index 00000000000..838d0982bad --- /dev/null +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -0,0 +1,95 @@ +# TECH: Reusable TUI Tab-Bar Component +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/code-1822-tui-tab-bar-component/PRODUCT.md](./PRODUCT.md) +Inspected commit: `caa826c2ef395faee32c87c19c533a44ef88d81b` + +## Context +The TUI cell-grid library has the layout, styling, retained-geometry, and click primitives needed for a horizontal tab bar, but no component that owns tab packing or paging: +- [`crates/warpui_core/src/elements/tui/mod.rs (31-74) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/mod.rs#L31-L74) — exports the TUI element vocabulary and `TuiElement`. +- [`crates/warpui_core/src/elements/tui/mod.rs (215-292) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/mod.rs#L215-L292) — `layout` must remain side-effect free, while `after_layout` is the settled post-layout side-effect seam. +- [`crates/warpui_core/src/elements/tui/hoverable.rs (40-189) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/hoverable.rs#L40-L189) — `TuiHoverable` requires stable `MouseStateHandle`s across per-frame element reconstruction and limits hit testing to retained painted bounds. +- [`crates/warpui_core/src/elements/tui/flex.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/flex.rs) — row composition and child layout. +- [`crates/warpui_core/src/elements/tui/text.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/text.rs) — styled terminal text and existing single-element truncation. + +The component is intentionally domain-neutral. The orchestration integration that first consumes it is specified separately in `specs/code-1822-tui-orchestration-tab-bar/`. + +## Proposed changes +### Public component contract +Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's public types from `crates/warpui_core/src/elements/tui/mod.rs`. + +Use a stable generic tab key (`K: Clone + Eq + Hash + 'static`) rather than indices so dynamic reordering cannot retarget callbacks. The public data surface contains: +- `TuiTab`: key, label, optional leading glyph/style, and selected state. +- `TuiTabBarStyles`: caller-supplied background, focused/unfocused selected, normal, divider, and overflow styles. +- `TuiTabBarConfig`: optional main tab, ordered secondary tabs, focus presentation, page anchor, optional maximum label cells, styles, and semantic callbacks. +- `TuiTabBarNavigationDirection`: `Previous` or `Next`. +- `TuiTabBar`: the reusable component retained by the caller and updated/rendered from config. + +The component exposes high-level operations only: +- `render(config) -> Box` +- `navigate(direction)`, which resolves against private settled layout and invokes `SelectTab`. + +It does not expose visible indices, visible keys, page boundaries, measured widths, or mouse handles. + +### Private retained state +`TuiTabBar` privately retains: +- `HashMap` for currently supplied tabs. +- Previous/next overflow mouse handles. +- The latest settled `TabBarLayout`. +- The latest ordered keys and selected key needed to resolve navigation against that layout. + +Every config update prunes removed keys before rendering. Existing keys reuse their mouse handles. The retained layout is invalidated when ordered tabs, page anchor, maximum width, or settled row width changes. + +The per-frame element receives an internal shared state reference so `after_layout` can publish the settled `TabBarLayout` back to the component without exposing it to the caller. This handle is private to `tab_bar.rs`; it is not part of the public contract described by PRODUCT (7). + +### Layout algorithm +Implement a pure internal layout function that receives the available columns and config-derived tab measurements and returns a `TabBarLayout` containing painted tab slices, truncation widths, overflow visibility, and previous/next anchor keys. + +The algorithm: +1. Measure fixed caller-supplied leading content, the optional main tab, and divider. +2. Normalize each label to the optional maximum display-cell width. +3. Resolve the requested page anchor against the ordered secondary keys, clamping a missing anchor. +4. Reserve a previous overflow control when the page does not start at the first secondary tab. +5. Pack secondary tabs from the anchor while reserving a next overflow control whenever later tabs remain. +6. If the final otherwise-visible tab does not fit in full, shrink its label to the remaining display cells while preserving its leading glyph and required next control. +7. Omit a secondary tab rather than produce invalid or negative-width geometry when the row is too narrow. + +Use terminal display-cell width and grapheme-safe truncation. Keep the ellipsis inside the requested width. The component paints from the returned layout rather than independently remeasuring, so rendering, hit testing, overflow callbacks, and navigation share one result. + +### Semantic interaction dispatch +Wrap every painted tab and overflow control in `TuiHoverable` with component-owned mouse state: +- A tab click invokes `SelectTab(key)`. +- A previous/next overflow click invokes `PageChanged(anchor_key)` from the settled layout. +- Neither callback changes focus or application state directly. + +`navigate(Previous | Next)` uses the private settled layout: +- If the selected key is visible, resolve the adjacent key from the complete supplied sequence of main tab followed by secondary tabs and wrap. +- If the selected key is off-page, resolve `Previous` to the last visible secondary key and `Next` to the first visible secondary key. +- If no target exists, emit nothing. + +The consuming view remains responsible for binding keys and forwarding directions. This keeps keymap policy outside the component while keeping width-dependent target resolution inside it. + +## Testing and validation +Add `crates/warpui_core/src/elements/tui/tab_bar_tests.rs` using the element render and event-dispatch test harness: +- Optional/absent main tab, empty secondary tabs, and caller-supplied chrome — PRODUCT (1-5). +- Private mouse-state reuse and pruning across config changes — PRODUCT (6-11). +- Focused/unfocused selected treatments and missing selection — PRODUCT (12-15). +- ASCII, wide Unicode, and combining-character measurement; configured and final-tab truncation — PRODUCT (16-22). +- Initial, middle, and final pages; anchor clamping; resize; previous/next control visibility — PRODUCT (23-31). +- Tab clicks, overflow clicks, hit bounds, cancelled press/release, and focus independence — PRODUCT (32-34, 37-38). +- Visible and off-page previous/next navigation, including complete-order wraparound — PRODUCT (35-36). + +Run: +- `cargo nextest run -p warpui_core --features tui tab_bar` +- `cargo test -p warpui_core --features tui tab_bar` +- `./script/format` +- The repository-prescribed Clippy command before submitting the branch. + +## Parallelization +Do not split this component across child agents. Its public contract, private retained state, layout result, and event tests are tightly coupled and should land as one coherent PR. Long-running workspace validation can run separately after focused tests pass. + +## Risks and mitigations +- **Public geometry leakage:** keep `TabBarLayout` and its shared internal state private to `tab_bar.rs`; expose semantic callbacks only. +- **Layout/event disagreement:** paint and dispatch from one settled layout result. +- **Stale navigation after mutation:** invalidate private layout on config changes and resolve callback keys against the latest supplied key set. +- **Unicode width corruption:** centralize display-cell truncation and cover wide/combining cases directly. +- **Hover-state churn:** key mouse state by stable tab key and prune only removed keys. From 1e10f72f85db2bc9c759d1f33b8eccb843ed4466 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:46:48 -0400 Subject: [PATCH 36/40] Implement reusable TUI tab bar component Co-Authored-By: Oz --- Cargo.lock | 2 + crates/warpui_core/Cargo.toml | 2 + crates/warpui_core/src/elements/tui/mod.rs | 5 + .../warpui_core/src/elements/tui/tab_bar.rs | 687 ++++++++++++++++++ .../src/elements/tui/tab_bar_tests.rs | 267 +++++++ .../PRODUCT.md | 5 +- specs/code-1822-tui-tab-bar-component/TECH.md | 14 +- 7 files changed, 973 insertions(+), 9 deletions(-) create mode 100644 crates/warpui_core/src/elements/tui/tab_bar.rs create mode 100644 crates/warpui_core/src/elements/tui/tab_bar_tests.rs diff --git a/Cargo.lock b/Cargo.lock index cc8bd1a15ba..481ce73120c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16162,6 +16162,8 @@ dependencies = [ "tokio", "tracing", "trait-set", + "unicode-segmentation", + "unicode-width 0.1.14", "vec1", "warp_errors", "warp_util", diff --git a/crates/warpui_core/Cargo.toml b/crates/warpui_core/Cargo.toml index 67860c9e6f7..8e741036360 100644 --- a/crates/warpui_core/Cargo.toml +++ b/crates/warpui_core/Cargo.toml @@ -84,6 +84,8 @@ titlecase = "1.0" tokio.workspace = true tracing.workspace = true trait-set = "0.3.0" +unicode-segmentation.workspace = true +unicode-width.workspace = true vec1.workspace = true warp_util.workspace = true diff --git a/crates/warpui_core/src/elements/tui/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index 06aca87822b..4a8c7729b5d 100644 --- a/crates/warpui_core/src/elements/tui/mod.rs +++ b/crates/warpui_core/src/elements/tui/mod.rs @@ -45,6 +45,7 @@ mod scene; mod scrollable; mod selectable; mod shimmering_text; +mod tab_bar; mod text; mod viewported_list; @@ -75,6 +76,10 @@ pub use selectable::{ TuiSelectionHandle, TuiSelectionSpan, }; pub use shimmering_text::TuiShimmeringText; +pub use tab_bar::{ + TuiTab, TuiTabBar, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, + TuiTabBarStyles, TuiTabBarText, +}; pub use text::TuiText; pub use viewported_list::{ TuiViewportContent, TuiViewportPosition, TuiViewportVerticalAlignment, TuiViewportWindow, diff --git a/crates/warpui_core/src/elements/tui/tab_bar.rs b/crates/warpui_core/src/elements/tui/tab_bar.rs new file mode 100644 index 00000000000..532610e0a99 --- /dev/null +++ b/crates/warpui_core/src/elements/tui/tab_bar.rs @@ -0,0 +1,687 @@ +//! Retained horizontal TUI tab bar with an optional fixed main tab and +//! width-aware paging for secondary tabs. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; +use std::rc::Rc; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use super::{ + TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiFlex, TuiHoverable, TuiLayoutContext, + TuiPaintContext, TuiPaintSurface, TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, + TuiSize, TuiStyle, TuiText, +}; +use crate::elements::MouseStateHandle; +use crate::AppContext; + +const ELLIPSIS: &str = "..."; + +/// Styled text used for fixed tab-bar chrome or a tab's leading glyph. +#[derive(Clone, Debug, Default)] +pub struct TuiTabBarText { + pub text: String, + pub style: TuiStyle, +} + +impl TuiTabBarText { + /// Creates styled tab-bar text. + pub fn new(text: impl Into, style: TuiStyle) -> Self { + Self { + text: text.into(), + style, + } + } +} + +/// One stable tab supplied by a tab-bar owner. +#[derive(Clone, Debug)] +pub struct TuiTab { + pub key: K, + pub label: String, + pub leading: Option, +} + +impl TuiTab { + /// Creates a tab without a leading glyph. + pub fn new(key: K, label: impl Into) -> Self { + Self { + key, + label: label.into(), + leading: None, + } + } + + /// Adds styled leading text to the tab. + pub fn with_leading(mut self, leading: TuiTabBarText) -> Self { + self.leading = Some(leading); + self + } +} + +/// Caller-supplied styles for a tab-bar row. +#[derive(Clone, Copy, Debug, Default)] +pub struct TuiTabBarStyles { + pub bar: TuiStyle, + pub tab: TuiStyle, + pub selected_focused: TuiStyle, + pub selected_unfocused: TuiStyle, +} + +/// Semantic events emitted by pointer interaction with the tab bar. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TuiTabBarEvent { + SelectTab(K), + PageChanged(K), +} + +/// Direction for width-aware tab navigation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TuiTabBarNavigationDirection { + Previous, + Next, +} + +type EventHandler = Rc Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext)>; + +/// Per-render input for [`TuiTabBar`]. +pub struct TuiTabBarConfig { + pub leading: Option, + pub main_tab: Option>, + pub divider: Option, + pub tabs: Vec>, + pub selected_key: Option, + pub focused: bool, + pub page_anchor: Option, + pub maximum_label_columns: Option, + pub tab_padding_columns: u16, + pub secondary_gap_columns: u16, + pub previous_overflow: TuiTabBarText, + pub next_overflow: TuiTabBarText, + pub styles: TuiTabBarStyles, + on_event: EventHandler, +} + +impl TuiTabBarConfig { + /// Creates a tab-bar configuration with neutral chrome defaults. + pub fn new( + tabs: Vec>, + on_event: impl for<'a> Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext) + 'static, + ) -> Self { + Self { + leading: None, + main_tab: None, + divider: None, + tabs, + selected_key: None, + focused: false, + page_anchor: None, + maximum_label_columns: None, + tab_padding_columns: 1, + secondary_gap_columns: 1, + previous_overflow: TuiTabBarText::new("←", TuiStyle::default()), + next_overflow: TuiTabBarText::new("→", TuiStyle::default()), + styles: TuiTabBarStyles::default(), + on_event: Rc::new(on_event), + } + } +} + +#[derive(Clone)] +struct RenderedTab { + tab: TuiTab, + label: String, + width: u16, +} + +#[derive(Clone)] +struct PageLayout { + start: usize, + end: usize, + tabs: Vec>, + previous_anchor: Option, + next_anchor: Option, +} + +#[derive(Clone)] +enum LayoutPiece { + Text(TuiTabBarText), + Gap(u16), + Tab(RenderedTab), + Overflow { + text: TuiTabBarText, + anchor: K, + is_previous: bool, + }, +} + +#[derive(Clone)] +struct TabBarLayout { + pieces: Vec>, + order: Vec, + selected_key: Option, + visible_secondary_keys: Vec, +} + +struct TuiTabBarState { + mouse_states: HashMap, + previous_overflow_mouse_state: MouseStateHandle, + next_overflow_mouse_state: MouseStateHandle, + settled_layout: Option>, +} + +impl Default for TuiTabBarState { + fn default() -> Self { + Self { + mouse_states: HashMap::new(), + previous_overflow_mouse_state: MouseStateHandle::default(), + next_overflow_mouse_state: MouseStateHandle::default(), + settled_layout: None, + } + } +} + +/// Retained TUI tab-bar component. Width-derived layout stays private; owners +/// receive only semantic tab keys and page anchors. +pub struct TuiTabBar { + state: Rc>>, +} + +impl Default for TuiTabBar { + fn default() -> Self { + Self { + state: Rc::new(RefCell::new(TuiTabBarState::default())), + } + } +} + +impl TuiTabBar +where + K: Clone + Eq + Hash + 'static, +{ + /// Creates an empty retained tab-bar component. + pub fn new() -> Self { + Self::default() + } + + /// Builds the per-frame tab-bar element from caller-owned semantic state. + pub fn render(&self, config: TuiTabBarConfig) -> Box { + let live_keys: HashSet<_> = config + .main_tab + .iter() + .chain(config.tabs.iter()) + .map(|tab| tab.key.clone()) + .collect(); + { + let mut state = self.state.borrow_mut(); + state.mouse_states.retain(|key, _| live_keys.contains(key)); + for key in live_keys { + state.mouse_states.entry(key).or_default(); + } + state.settled_layout = None; + } + TabBarElement { + state: self.state.clone(), + config, + row: None, + layout: None, + size: None, + origin: None, + } + .finish() + } + + /// Resolves navigation from the component's private settled layout. + pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { + let state = self.state.borrow(); + let layout = state.settled_layout.as_ref()?; + if layout.order.is_empty() { + return None; + } + let selected_index = layout + .selected_key + .as_ref() + .and_then(|selected| layout.order.iter().position(|key| key == selected)); + let selected_is_visible = layout.selected_key.as_ref().is_some_and(|selected| { + layout.visible_secondary_keys.contains(selected) + || layout.order.first() == Some(selected) + }); + if selected_is_visible { + let selected_index = selected_index?; + let target_index = match direction { + TuiTabBarNavigationDirection::Previous => selected_index + .checked_sub(1) + .unwrap_or(layout.order.len() - 1), + TuiTabBarNavigationDirection::Next => (selected_index + 1) % layout.order.len(), + }; + return layout.order.get(target_index).cloned(); + } + match direction { + TuiTabBarNavigationDirection::Previous => layout.visible_secondary_keys.last().cloned(), + TuiTabBarNavigationDirection::Next => layout.visible_secondary_keys.first().cloned(), + } + } +} + +struct TabBarElement { + state: Rc>>, + config: TuiTabBarConfig, + row: Option, + layout: Option>, + size: Option, + origin: Option, +} + +impl TabBarElement +where + K: Clone + Eq + Hash + 'static, +{ + /// Resolves a tab's caller-supplied style from selection and focus. + fn tab_style(&self, key: &K) -> TuiStyle { + if self.config.selected_key.as_ref() != Some(key) { + self.config.styles.tab + } else if self.config.focused { + self.config.styles.selected_focused + } else { + self.config.styles.selected_unfocused + } + } + + /// Builds one clickable tab from its settled label and retained mouse state. + fn tab_element(&self, tab: &RenderedTab) -> Box { + let padding = " ".repeat(usize::from(self.config.tab_padding_columns)); + let mut spans = vec![(padding.clone(), TuiStyle::default())]; + if let Some(leading) = &tab.tab.leading { + spans.push((leading.text.clone(), leading.style)); + if !tab.label.is_empty() { + spans.push((" ".to_string(), TuiStyle::default())); + } + } + spans.push((tab.label.clone(), TuiStyle::default())); + spans.push((padding, TuiStyle::default())); + let state = self + .state + .borrow() + .mouse_states + .get(&tab.tab.key) + .cloned() + .expect("tab mouse state was initialized before layout"); + let key = tab.tab.key.clone(); + let on_event = self.config.on_event.clone(); + TuiHoverable::new( + state, + TuiText::from_spans(spans) + .with_style(self.tab_style(&tab.tab.key)) + .truncate() + .finish(), + ) + .on_click(move |event_ctx, app| { + on_event(TuiTabBarEvent::SelectTab(key.clone()), event_ctx, app); + }) + .finish() + } + + /// Builds a clickable overflow control for a settled page anchor. + fn overflow_element( + &self, + text: &TuiTabBarText, + anchor: K, + is_previous: bool, + ) -> Box { + let state = { + let state = self.state.borrow(); + if is_previous { + state.previous_overflow_mouse_state.clone() + } else { + state.next_overflow_mouse_state.clone() + } + }; + let on_event = self.config.on_event.clone(); + TuiHoverable::new( + state, + TuiText::new(text.text.clone()) + .with_style(text.style) + .truncate() + .finish(), + ) + .on_click(move |event_ctx, app| { + on_event(TuiTabBarEvent::PageChanged(anchor.clone()), event_ctx, app); + }) + .finish() + } + + /// Converts settled layout pieces into the row delegated to for paint and events. + fn build_row(&self, layout: &TabBarLayout) -> TuiFlex { + let mut row = TuiFlex::row(); + for piece in &layout.pieces { + let element = match piece { + LayoutPiece::Text(text) => TuiText::new(text.text.clone()) + .with_style(text.style) + .truncate() + .finish(), + LayoutPiece::Gap(columns) => TuiText::new(" ".repeat(usize::from(*columns))) + .with_style(self.config.styles.bar) + .truncate() + .finish(), + LayoutPiece::Tab(tab) => self.tab_element(tab), + LayoutPiece::Overflow { + text, + anchor, + is_previous, + } => self.overflow_element(text, anchor.clone(), *is_previous), + }; + row = row.child(element); + } + row + } +} + +impl TuiElement for TabBarElement +where + K: Clone + Eq + Hash + 'static, +{ + fn layout( + &mut self, + constraint: TuiConstraint, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> TuiSize { + let size = TuiSize::new( + constraint.constrain_width(constraint.max.width), + constraint.constrain_height(u16::from(constraint.max.height > 0)), + ); + let layout = tab_bar_layout(&self.config, size.width); + let mut row = self.build_row(&layout); + row.layout(TuiConstraint::loose(size), ctx, app); + self.row = Some(row); + self.layout = Some(layout); + self.size = Some(size); + size + } + + fn after_layout(&mut self, ctx: &mut TuiLayoutContext, app: &AppContext) { + if let Some(row) = &mut self.row { + row.after_layout(ctx, app); + } + self.state.borrow_mut().settled_layout = self.layout.clone(); + } + + fn render( + &mut self, + origin: TuiScreenPosition, + surface: &mut TuiPaintSurface<'_>, + ctx: &mut TuiPaintContext, + ) { + self.origin = Some(ctx.scene_point(origin)); + let Some(size) = self.size else { + return; + }; + surface.set_style(origin, size, self.config.styles.bar); + if let Some(row) = &mut self.row { + row.render(origin, surface, ctx); + } + } + + fn size(&self) -> Option { + self.size + } + + fn origin(&self) -> Option { + self.origin + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + if let Some(row) = &mut self.row { + row.present(ctx); + } + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + event_ctx: &mut TuiEventContext<'_>, + app: &AppContext, + ) -> bool { + self.row + .as_mut() + .is_some_and(|row| row.dispatch_event(event, event_ctx, app)) + } +} + +/// Settles the complete row, including fixed chrome and one secondary page. +fn tab_bar_layout(config: &TuiTabBarConfig, available_columns: u16) -> TabBarLayout +where + K: Clone + Eq, +{ + let mut pieces = Vec::new(); + let mut fixed_columns = 0u16; + if let Some(leading) = &config.leading { + fixed_columns = fixed_columns.saturating_add(text_width(&leading.text)); + pieces.push(LayoutPiece::Text(leading.clone())); + } + if let Some(main_tab) = &config.main_tab { + let rendered = render_tab(main_tab, config, None); + fixed_columns = fixed_columns.saturating_add(rendered.width); + pieces.push(LayoutPiece::Tab(rendered)); + } + if let Some(divider) = &config.divider { + fixed_columns = fixed_columns.saturating_add(text_width(÷r.text)); + pieces.push(LayoutPiece::Text(divider.clone())); + } + + let secondary_columns = available_columns.saturating_sub(fixed_columns); + let start = config + .page_anchor + .as_ref() + .and_then(|anchor| config.tabs.iter().position(|tab| &tab.key == anchor)) + .unwrap_or_default(); + let mut page = page_layout(config, start, secondary_columns); + page.previous_anchor = previous_page_anchor(config, page.start, secondary_columns) + .and_then(|index| config.tabs.get(index).map(|tab| tab.key.clone())); + if page.start > 0 { + if let Some(anchor) = &page.previous_anchor { + pieces.push(LayoutPiece::Overflow { + text: config.previous_overflow.clone(), + anchor: anchor.clone(), + is_previous: true, + }); + if !page.tabs.is_empty() || page.next_anchor.is_some() { + pieces.push(LayoutPiece::Gap(config.secondary_gap_columns)); + } + } + } + for (index, tab) in page.tabs.iter().enumerate() { + if index > 0 { + pieces.push(LayoutPiece::Gap(config.secondary_gap_columns)); + } + pieces.push(LayoutPiece::Tab(tab.clone())); + } + if let Some(anchor) = &page.next_anchor { + if !page.tabs.is_empty() { + pieces.push(LayoutPiece::Gap(config.secondary_gap_columns)); + } + pieces.push(LayoutPiece::Overflow { + text: config.next_overflow.clone(), + anchor: anchor.clone(), + is_previous: false, + }); + } + + let order = config + .main_tab + .iter() + .chain(config.tabs.iter()) + .map(|tab| tab.key.clone()) + .collect(); + TabBarLayout { + pieces, + order, + selected_key: config.selected_key.clone(), + visible_secondary_keys: page.tabs.into_iter().map(|tab| tab.tab.key).collect(), + } +} + +/// Packs one secondary page into the columns left after fixed chrome. +fn page_layout( + config: &TuiTabBarConfig, + requested_start: usize, + available_columns: u16, +) -> PageLayout +where + K: Clone, +{ + let start = requested_start.min(config.tabs.len().saturating_sub(1)); + let show_previous = start > 0; + let previous_columns = if show_previous { + text_width(&config.previous_overflow.text).saturating_add(config.secondary_gap_columns) + } else { + 0 + }; + let mut remaining = available_columns.saturating_sub(previous_columns); + let next_columns = + text_width(&config.next_overflow.text).saturating_add(config.secondary_gap_columns); + let mut rendered_tabs = Vec::new(); + let mut end = start; + + for (offset, tab) in config.tabs.iter().enumerate().skip(start) { + let gap = if rendered_tabs.is_empty() { + 0 + } else { + config.secondary_gap_columns + }; + let has_later_tabs = offset + 1 < config.tabs.len(); + let reserve_next = if has_later_tabs { next_columns } else { 0 }; + let available_for_tab = remaining.saturating_sub(gap).saturating_sub(reserve_next); + let rendered = render_tab(tab, config, None); + if rendered.width <= available_for_tab { + remaining = remaining.saturating_sub(gap.saturating_add(rendered.width)); + rendered_tabs.push(rendered); + end = offset + 1; + continue; + } + let fixed = tab_fixed_columns(tab, config.tab_padding_columns); + let minimum = fixed.saturating_add(u16::from(!tab.label.is_empty())); + if available_for_tab >= minimum { + rendered_tabs.push(render_tab(tab, config, Some(available_for_tab))); + end = offset + 1; + } + break; + } + + let next_index = if end < config.tabs.len() { + Some(if end > start { + end + } else { + start.saturating_add(1).min(config.tabs.len() - 1) + }) + } else { + None + }; + PageLayout { + start, + end, + tabs: rendered_tabs, + previous_anchor: None, + next_anchor: next_index.and_then(|index| config.tabs.get(index).map(|tab| tab.key.clone())), + } +} + +/// Finds the preceding deterministic page anchor for `current_start`. +fn previous_page_anchor( + config: &TuiTabBarConfig, + current_start: usize, + available_columns: u16, +) -> Option +where + K: Clone, +{ + if current_start == 0 { + return None; + } + let mut anchor = 0usize; + loop { + let page = page_layout(config, anchor, available_columns); + let next = if page.end > anchor { + page.end + } else { + anchor.saturating_add(1) + }; + if next >= current_start { + return Some(anchor); + } + if next <= anchor || next >= config.tabs.len() { + return Some(current_start.saturating_sub(1)); + } + anchor = next; + } +} + +/// Truncates one tab to its configured or width-derived total column budget. +fn render_tab( + tab: &TuiTab, + config: &TuiTabBarConfig, + total_columns: Option, +) -> RenderedTab +where + K: Clone, +{ + let fixed_columns = tab_fixed_columns(tab, config.tab_padding_columns); + let configured_label_columns = config + .maximum_label_columns + .unwrap_or_else(|| text_width(&tab.label)); + let label_columns = total_columns + .map(|columns| columns.saturating_sub(fixed_columns)) + .unwrap_or(configured_label_columns) + .min(configured_label_columns); + let label = truncate_with_ellipsis(&tab.label, label_columns); + RenderedTab { + tab: tab.clone(), + width: fixed_columns.saturating_add(text_width(&label)), + label, + } +} + +/// Columns occupied by tab padding and optional leading text. +fn tab_fixed_columns(tab: &TuiTab, padding_columns: u16) -> u16 { + let leading_columns = tab + .leading + .as_ref() + .map(|leading| text_width(&leading.text)) + .unwrap_or_default(); + padding_columns + .saturating_mul(2) + .saturating_add(leading_columns) + .saturating_add(u16::from(tab.leading.is_some() && !tab.label.is_empty())) +} + +/// Returns terminal display-cell width, saturating at `u16::MAX`. +fn text_width(text: &str) -> u16 { + u16::try_from(UnicodeWidthStr::width(text)).unwrap_or(u16::MAX) +} + +/// Truncates at grapheme boundaries and keeps as much of `...` as fits. +fn truncate_with_ellipsis(text: &str, maximum_columns: u16) -> String { + if text_width(text) <= maximum_columns { + return text.to_owned(); + } + let ellipsis_columns = text_width(ELLIPSIS).min(maximum_columns); + let prefix_columns = maximum_columns.saturating_sub(ellipsis_columns); + let mut prefix = String::new(); + let mut prefix_width = 0u16; + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let grapheme_width = text_width(grapheme); + if prefix_width.saturating_add(grapheme_width) > prefix_columns { + break; + } + prefix.push_str(grapheme); + prefix_width = prefix_width.saturating_add(grapheme_width); + } + prefix.push_str(&".".repeat(usize::from(ellipsis_columns))); + prefix +} + +#[cfg(test)] +#[path = "tab_bar_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs new file mode 100644 index 00000000000..6815d1594d3 --- /dev/null +++ b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs @@ -0,0 +1,267 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use ratatui::style::{Color, Modifier}; + +use super::{ + page_layout, tab_bar_layout, text_width, truncate_with_ellipsis, TuiTab, TuiTabBar, + TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, TuiTabBarStyles, TuiTabBarText, +}; +use crate::elements::tui::test_support::dispatch_presented_event; +use crate::elements::tui::{TuiBufferExt, TuiEvent, TuiPoint, TuiRect, TuiStyle}; +use crate::event::ModifiersState; +use crate::presenter::tui::TuiPresenter; +use crate::App; + +type Events = Rc>>>; + +fn tab(key: u8, label: &str) -> TuiTab { + TuiTab::new(key, label) +} + +fn config(tabs: Vec>, events: &Events) -> TuiTabBarConfig { + let events = events.clone(); + let mut config = TuiTabBarConfig::new(tabs, move |event, _, _| { + events.borrow_mut().push(event); + }); + config.styles = TuiTabBarStyles { + bar: TuiStyle::default().bg(Color::Black), + tab: TuiStyle::default().fg(Color::White), + selected_focused: TuiStyle::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + selected_unfocused: TuiStyle::default().add_modifier(Modifier::BOLD), + }; + config +} + +fn left_mouse_down(x: u16) -> TuiEvent { + TuiEvent::LeftMouseDown { + position: TuiPoint::new(x, 0), + modifiers: ModifiersState::default(), + click_count: 1, + is_first_mouse: false, + } +} + +fn left_mouse_up(x: u16) -> TuiEvent { + TuiEvent::LeftMouseUp { + position: TuiPoint::new(x, 0), + modifiers: ModifiersState::default(), + } +} + +#[test] +fn truncates_by_display_columns_without_splitting_graphemes() { + assert_eq!(truncate_with_ellipsis("infrastructure", 8), "infra..."); + assert_eq!(truncate_with_ellipsis("abcdef", 2), ".."); + assert_eq!(truncate_with_ellipsis("界界界界", 7), "界界..."); + assert_eq!(truncate_with_ellipsis("e\u{301}clair", 5), "e\u{301}c..."); + assert_eq!(text_width("界界..."), 7); +} + +#[test] +fn main_tab_is_fixed_while_secondary_tabs_page() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut config = config( + vec![tab(2, "alpha"), tab(3, "beta"), tab(4, "gamma")], + &events, + ); + config.leading = Some(TuiTabBarText::new("Agents:", TuiStyle::default())); + config.main_tab = Some(tab(1, "main")); + config.divider = Some(TuiTabBarText::new("|", TuiStyle::default())); + config.page_anchor = Some(3); + + let layout = tab_bar_layout(&config, 24); + assert_eq!(layout.order, vec![1, 2, 3, 4]); + assert!(layout + .pieces + .iter() + .any(|piece| matches!(piece, super::LayoutPiece::Tab(tab) if tab.tab.key == 1))); + assert!(!layout.visible_secondary_keys.contains(&2)); + assert!(layout.visible_secondary_keys.contains(&3)); +} + +#[test] +fn page_layout_reserves_overflow_and_truncates_the_last_visible_tab() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut config = config( + vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie-long"), + tab(4, "delta"), + ], + &events, + ); + config.maximum_label_columns = Some(20); + let page = page_layout(&config, 0, 22); + + assert_eq!(page.start, 0); + assert!(page.next_anchor.is_some()); + assert!(!page.tabs.is_empty()); + assert_ne!(page.tabs.last().unwrap().label, "charlie-long"); + let tabs_width: u16 = page.tabs.iter().map(|tab| tab.width).sum(); + assert!(tabs_width <= 22); +} + +#[test] +fn invalid_page_anchor_clamps_to_first_page() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut config = config(vec![tab(1, "one"), tab(2, "two")], &events); + config.page_anchor = Some(99); + + let layout = tab_bar_layout(&config, 40); + assert_eq!(layout.visible_secondary_keys, vec![1, 2]); +} + +#[test] +fn navigation_wraps_when_selected_tab_is_visible() { + App::test((), |app| async move { + app.read(|app_ctx| { + let events = Rc::new(RefCell::new(Vec::new())); + let bar = TuiTabBar::new(); + let mut config = config(vec![tab(2, "two"), tab(3, "three")], &events); + config.main_tab = Some(tab(1, "main")); + config.selected_key = Some(1); + let mut presenter = TuiPresenter::new(); + presenter.present_element(bar.render(config), TuiRect::new(0, 0, 40, 1), app_ctx); + + assert_eq!( + bar.navigation_target(TuiTabBarNavigationDirection::Previous), + Some(3) + ); + assert_eq!( + bar.navigation_target(TuiTabBarNavigationDirection::Next), + Some(2) + ); + }); + }); +} + +#[test] +fn focused_selection_uses_the_caller_supplied_style() { + App::test((), |app| async move { + app.read(|app_ctx| { + let events = Rc::new(RefCell::new(Vec::new())); + let bar = TuiTabBar::new(); + let mut config = config(vec![tab(1, "one"), tab(2, "two")], &events); + config.selected_key = Some(1); + config.focused = true; + let mut presenter = TuiPresenter::new(); + let frame = + presenter.present_element(bar.render(config), TuiRect::new(0, 0, 20, 1), app_ctx); + + let selected = &frame.buffer[(1, 0)]; + assert_eq!(selected.bg, Color::Magenta); + assert!(selected.modifier.contains(Modifier::BOLD)); + }); + }); +} + +#[test] +fn navigation_uses_visible_boundaries_when_selection_is_off_page() { + App::test((), |app| async move { + app.read(|app_ctx| { + let events = Rc::new(RefCell::new(Vec::new())); + let bar = TuiTabBar::new(); + let mut config = config( + vec![ + tab(2, "alpha"), + tab(3, "bravo"), + tab(4, "charlie"), + tab(5, "delta"), + ], + &events, + ); + config.main_tab = Some(tab(1, "main")); + config.selected_key = Some(2); + config.page_anchor = Some(4); + let mut presenter = TuiPresenter::new(); + presenter.present_element(bar.render(config), TuiRect::new(0, 0, 24, 1), app_ctx); + let state = bar.state.borrow(); + let visible = &state + .settled_layout + .as_ref() + .unwrap() + .visible_secondary_keys; + assert!(!visible.contains(&2)); + let first = visible.first().copied(); + let last = visible.last().copied(); + drop(state); + + assert_eq!( + bar.navigation_target(TuiTabBarNavigationDirection::Previous), + last + ); + assert_eq!( + bar.navigation_target(TuiTabBarNavigationDirection::Next), + first + ); + }); + }); +} + +#[test] +fn clicking_tab_emits_only_select_event() { + App::test((), |app| async move { + app.read(|app_ctx| { + let events = Rc::new(RefCell::new(Vec::new())); + let bar = TuiTabBar::new(); + let config = config(vec![tab(1, "one"), tab(2, "two")], &events); + let mut presenter = TuiPresenter::new(); + let frame = + presenter.present_element(bar.render(config), TuiRect::new(0, 0, 20, 1), app_ctx); + assert!(frame.buffer.to_lines()[0].contains("one")); + + assert!(dispatch_presented_event(&mut presenter, &left_mouse_down(2), app_ctx).0); + assert!(dispatch_presented_event(&mut presenter, &left_mouse_up(2), app_ctx).0); + assert_eq!(*events.borrow(), vec![TuiTabBarEvent::SelectTab(1)]); + }); + }); +} + +#[test] +fn clicking_overflow_emits_page_change_without_selection() { + App::test((), |app| async move { + app.read(|app_ctx| { + let events = Rc::new(RefCell::new(Vec::new())); + let bar = TuiTabBar::new(); + let config = config( + vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")], + &events, + ); + let mut presenter = TuiPresenter::new(); + let frame = + presenter.present_element(bar.render(config), TuiRect::new(0, 0, 12, 1), app_ctx); + let line = &frame.buffer.to_lines()[0]; + let arrow = line.find('→').expect("next overflow is visible") as u16; + + assert!(dispatch_presented_event(&mut presenter, &left_mouse_down(arrow), app_ctx).0); + assert!(dispatch_presented_event(&mut presenter, &left_mouse_up(arrow), app_ctx).0); + assert!(matches!( + events.borrow().as_slice(), + [TuiTabBarEvent::PageChanged(_)] + )); + }); + }); +} + +#[test] +fn retained_mouse_state_is_reused_and_removed_keys_are_pruned() { + let events = Rc::new(RefCell::new(Vec::new())); + let bar = TuiTabBar::new(); + let _ = bar.render(config(vec![tab(1, "one"), tab(2, "two")], &events)); + let first_handle = bar.state.borrow().mouse_states.get(&1).cloned().unwrap(); + + let _ = bar.render(config(vec![tab(1, "one"), tab(3, "three")], &events)); + let state = bar.state.borrow(); + assert_eq!(state.mouse_states.len(), 2); + assert!(!state.mouse_states.contains_key(&2)); + assert!(Arc::ptr_eq( + &first_handle, + state.mouse_states.get(&1).unwrap() + )); +} diff --git a/specs/code-1822-tui-tab-bar-component/PRODUCT.md b/specs/code-1822-tui-tab-bar-component/PRODUCT.md index 8f7f2a7e9d4..90848f2b4d1 100644 --- a/specs/code-1822-tui-tab-bar-component/PRODUCT.md +++ b/specs/code-1822-tui-tab-bar-component/PRODUCT.md @@ -48,8 +48,9 @@ The orchestration designs establish the component states; the component itself r 7. The caller cannot read or mutate the settled visible range or page-boundary geometry. 8. The component does not mutate application selection, page state, focus, models, or caller-owned collections. 9. The component communicates only semantic outcomes: - - `SelectTab(key)` when a tab or keyboard navigation chooses a tab. + - `SelectTab(key)` when a visible tab is clicked. - `PageChanged(anchor_key)` when an overflow control chooses another page. + - A target tab key when the caller requests previous or next keyboard navigation. 10. Rebuilding or resizing the element does not recreate mouse state for tab keys that remain present. 11. Removed tab keys release their retained component state and cannot remain clickable. @@ -86,6 +87,6 @@ The orchestration designs establish the component states; the component itself r 35. The component can resolve previous and next navigation from its private settled layout: - When the selected tab is visible, navigation uses the complete supplied order and wraps at both ends. - When the selected tab is off-page, previous resolves to the last visible secondary tab and next resolves to the first visible secondary tab. -36. Resolved navigation emits only `SelectTab`; the caller decides what selecting that key means. +36. Resolved navigation returns only the target key; the caller decides what selecting that key means. 37. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. 38. Pointer press/release outside a target does not invoke its callback. diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md index 838d0982bad..28ac5f37e96 100644 --- a/specs/code-1822-tui-tab-bar-component/TECH.md +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -18,15 +18,15 @@ The component is intentionally domain-neutral. The orchestration integration tha Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's public types from `crates/warpui_core/src/elements/tui/mod.rs`. Use a stable generic tab key (`K: Clone + Eq + Hash + 'static`) rather than indices so dynamic reordering cannot retarget callbacks. The public data surface contains: -- `TuiTab`: key, label, optional leading glyph/style, and selected state. -- `TuiTabBarStyles`: caller-supplied background, focused/unfocused selected, normal, divider, and overflow styles. -- `TuiTabBarConfig`: optional main tab, ordered secondary tabs, focus presentation, page anchor, optional maximum label cells, styles, and semantic callbacks. +- `TuiTab`: key, label, and optional leading glyph/style. +- `TuiTabBarStyles`: caller-supplied bar, normal-tab, focused-selected, and unfocused-selected styles. +- `TuiTabBarConfig`: optional main tab, ordered secondary tabs, selected key, focus presentation, page anchor, optional maximum label cells, caller-styled fixed/overflow text, and semantic pointer callbacks. - `TuiTabBarNavigationDirection`: `Previous` or `Next`. - `TuiTabBar`: the reusable component retained by the caller and updated/rendered from config. The component exposes high-level operations only: - `render(config) -> Box` -- `navigate(direction)`, which resolves against private settled layout and invokes `SelectTab`. +- `navigation_target(direction) -> Option`, which resolves against private settled layout. It does not expose visible indices, visible keys, page boundaries, measured widths, or mouse handles. @@ -61,12 +61,12 @@ Wrap every painted tab and overflow control in `TuiHoverable` with component-own - A previous/next overflow click invokes `PageChanged(anchor_key)` from the settled layout. - Neither callback changes focus or application state directly. -`navigate(Previous | Next)` uses the private settled layout: +`navigation_target(Previous | Next)` uses the private settled layout: - If the selected key is visible, resolve the adjacent key from the complete supplied sequence of main tab followed by secondary tabs and wrap. - If the selected key is off-page, resolve `Previous` to the last visible secondary key and `Next` to the first visible secondary key. -- If no target exists, emit nothing. +- If no target exists, return `None`. -The consuming view remains responsible for binding keys and forwarding directions. This keeps keymap policy outside the component while keeping width-dependent target resolution inside it. +The consuming view remains responsible for binding keys, forwarding directions, and applying the returned semantic target. This keeps keymap and application-selection policy outside the component while keeping width-dependent target resolution inside it. ## Testing and validation Add `crates/warpui_core/src/elements/tui/tab_bar_tests.rs` using the element render and event-dispatch test harness: From 3cc18886b407606339d34207a5c857a1354ab742 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 17:58:12 -0400 Subject: [PATCH 37/40] Keep tab pages stable across selection Co-Authored-By: Oz --- .../warpui_core/src/elements/tui/tab_bar.rs | 43 ++++++++++++++ .../src/elements/tui/tab_bar_tests.rs | 56 +++++++++++++++++++ .../PRODUCT.md | 16 +++--- specs/code-1822-tui-tab-bar-component/TECH.md | 9 +-- 4 files changed, 113 insertions(+), 11 deletions(-) diff --git a/crates/warpui_core/src/elements/tui/tab_bar.rs b/crates/warpui_core/src/elements/tui/tab_bar.rs index 532610e0a99..9783a19b0fd 100644 --- a/crates/warpui_core/src/elements/tui/tab_bar.rs +++ b/crates/warpui_core/src/elements/tui/tab_bar.rs @@ -95,6 +95,7 @@ pub struct TuiTabBarConfig { pub selected_key: Option, pub focused: bool, pub page_anchor: Option, + pub reveal_selected: bool, pub maximum_label_columns: Option, pub tab_padding_columns: u16, pub secondary_gap_columns: u16, @@ -118,6 +119,7 @@ impl TuiTabBarConfig { selected_key: None, focused: false, page_anchor: None, + reveal_selected: false, maximum_label_columns: None, tab_padding_columns: 1, secondary_gap_columns: 1, @@ -478,6 +480,19 @@ where .and_then(|anchor| config.tabs.iter().position(|tab| &tab.key == anchor)) .unwrap_or_default(); let mut page = page_layout(config, start, secondary_columns); + if config.reveal_selected { + if let Some(selected_index) = config + .selected_key + .as_ref() + .and_then(|selected| config.tabs.iter().position(|tab| &tab.key == selected)) + { + if selected_index < page.start || selected_index >= page.end { + let stable_anchor = + stable_page_anchor_for_index(config, selected_index, secondary_columns); + page = page_layout(config, stable_anchor, secondary_columns); + } + } + } page.previous_anchor = previous_page_anchor(config, page.start, secondary_columns) .and_then(|index| config.tabs.get(index).map(|tab| tab.key.clone())); if page.start > 0 { @@ -618,6 +633,34 @@ where } } +/// Finds the deterministic page anchor whose range contains `target`. +fn stable_page_anchor_for_index( + config: &TuiTabBarConfig, + target: usize, + available_columns: u16, +) -> usize +where + K: Clone, +{ + let mut anchor = 0usize; + while anchor < target { + let page = page_layout(config, anchor, available_columns); + if target < page.end { + return anchor; + } + let next = if page.end > anchor { + page.end + } else { + anchor.saturating_add(1) + }; + if next <= anchor || next >= config.tabs.len() { + break; + } + anchor = next; + } + anchor.min(target) +} + /// Truncates one tab to its configured or width-derived total column budget. fn render_tab( tab: &TuiTab, diff --git a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs index 6815d1594d3..4e7ae89b668 100644 --- a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs +++ b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs @@ -117,6 +117,62 @@ fn invalid_page_anchor_clamps_to_first_page() { assert_eq!(layout.visible_secondary_keys, vec![1, 2]); } +#[test] +fn selected_tab_reveal_preserves_page_until_selection_crosses_boundary() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut config = config( + vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie"), + tab(4, "delta"), + ], + &events, + ); + config.reveal_selected = true; + config.selected_key = Some(1); + let first_page = tab_bar_layout(&config, 20); + assert!(first_page.visible_secondary_keys.len() >= 2); + + config.selected_key = Some(2); + let same_page = tab_bar_layout(&config, 20); + assert_eq!( + same_page.visible_secondary_keys, first_page.visible_secondary_keys, + "selection within a page must not re-anchor the list" + ); + + let first_hidden_index = page_layout(&config, 0, 20).end; + let first_hidden_key = config.tabs[first_hidden_index].key; + config.selected_key = Some(first_hidden_key); + let next_page = tab_bar_layout(&config, 20); + assert_eq!( + next_page.visible_secondary_keys.first(), + Some(&first_hidden_key), + "crossing the boundary reveals the stable next page" + ); +} + +#[test] +fn explicit_page_can_keep_the_selected_tab_off_page() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut config = config( + vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie"), + tab(4, "delta"), + ], + &events, + ); + config.page_anchor = Some(3); + config.selected_key = Some(1); + config.reveal_selected = false; + + let layout = tab_bar_layout(&config, 20); + assert!(!layout.visible_secondary_keys.contains(&1)); + assert_eq!(layout.visible_secondary_keys.first(), Some(&3)); +} + #[test] fn navigation_wraps_when_selected_tab_is_visible() { App::test((), |app| async move { diff --git a/specs/code-1822-tui-tab-bar-component/PRODUCT.md b/specs/code-1822-tui-tab-bar-component/PRODUCT.md index 90848f2b4d1..5524da8ef10 100644 --- a/specs/code-1822-tui-tab-bar-component/PRODUCT.md +++ b/specs/code-1822-tui-tab-bar-component/PRODUCT.md @@ -30,6 +30,7 @@ The orchestration designs establish the component states; the component itself r - An optional leading glyph and glyph style for every tab. - The selected tab key, if any. - Whether the bar is focused. + - Whether an off-page selected secondary tab should be revealed. - Focused, unfocused, selected, background, divider, and overflow styles. - An optional maximum label width in terminal display cells. - The current secondary-page anchor. @@ -79,14 +80,15 @@ The orchestration designs establish the component states; the component itself r 29. Paging does not change focus. 30. When the caller supplies a new page anchor, the next layout settles the visible range from that anchor and clamps an unavailable anchor to a valid page. 31. Resizing recomputes visible tabs and page boundaries from the same supplied order and anchor. +32. When selected-tab reveal is enabled and the selected secondary tab is off-page, the component shows the deterministic page containing it. Selection changes within the current page do not shift that page. ### Navigation and pointer behavior -32. Activating a visible tab emits `SelectTab` for that tab's stable key. -33. A tab remains clickable regardless of the bar's focused presentation. -34. Activating a tab never changes focus by itself. -35. The component can resolve previous and next navigation from its private settled layout: +33. Activating a visible tab emits `SelectTab` for that tab's stable key. +34. A tab remains clickable regardless of the bar's focused presentation. +35. Activating a tab never changes focus by itself. +36. The component can resolve previous and next navigation from its private settled layout: - When the selected tab is visible, navigation uses the complete supplied order and wraps at both ends. - When the selected tab is off-page, previous resolves to the last visible secondary tab and next resolves to the first visible secondary tab. -36. Resolved navigation returns only the target key; the caller decides what selecting that key means. -37. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. -38. Pointer press/release outside a target does not invoke its callback. +37. Resolved navigation returns only the target key; the caller decides what selecting that key means. +38. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. +39. Pointer press/release outside a target does not invoke its callback. diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md index 28ac5f37e96..147f7558f44 100644 --- a/specs/code-1822-tui-tab-bar-component/TECH.md +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -20,7 +20,7 @@ Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's Use a stable generic tab key (`K: Clone + Eq + Hash + 'static`) rather than indices so dynamic reordering cannot retarget callbacks. The public data surface contains: - `TuiTab`: key, label, and optional leading glyph/style. - `TuiTabBarStyles`: caller-supplied bar, normal-tab, focused-selected, and unfocused-selected styles. -- `TuiTabBarConfig`: optional main tab, ordered secondary tabs, selected key, focus presentation, page anchor, optional maximum label cells, caller-styled fixed/overflow text, and semantic pointer callbacks. +- `TuiTabBarConfig`: optional main tab, ordered secondary tabs, selected key, focus presentation, page anchor, selected-tab reveal policy, optional maximum label cells, caller-styled fixed/overflow text, and semantic pointer callbacks. - `TuiTabBarNavigationDirection`: `Previous` or `Next`. - `TuiTabBar`: the reusable component retained by the caller and updated/rendered from config. @@ -52,6 +52,7 @@ The algorithm: 5. Pack secondary tabs from the anchor while reserving a next overflow control whenever later tabs remain. 6. If the final otherwise-visible tab does not fit in full, shrink its label to the remaining display cells while preserving its leading glyph and required next control. 7. Omit a secondary tab rather than produce invalid or negative-width geometry when the row is too narrow. +8. If selected-tab reveal is enabled and the selected secondary tab is outside the settled page, walk the same deterministic page boundaries from the beginning and render the page containing it. Use terminal display-cell width and grapheme-safe truncation. Keep the ellipsis inside the requested width. The component paints from the returned layout rather than independently remeasuring, so rendering, hit testing, overflow callbacks, and navigation share one result. @@ -74,9 +75,9 @@ Add `crates/warpui_core/src/elements/tui/tab_bar_tests.rs` using the element ren - Private mouse-state reuse and pruning across config changes — PRODUCT (6-11). - Focused/unfocused selected treatments and missing selection — PRODUCT (12-15). - ASCII, wide Unicode, and combining-character measurement; configured and final-tab truncation — PRODUCT (16-22). -- Initial, middle, and final pages; anchor clamping; resize; previous/next control visibility — PRODUCT (23-31). -- Tab clicks, overflow clicks, hit bounds, cancelled press/release, and focus independence — PRODUCT (32-34, 37-38). -- Visible and off-page previous/next navigation, including complete-order wraparound — PRODUCT (35-36). +- Initial, middle, and final pages; anchor clamping; resize; previous/next control visibility; stable selected-tab reveal — PRODUCT (23-32). +- Tab clicks, overflow clicks, hit bounds, cancelled press/release, and focus independence — PRODUCT (33-35, 38-39). +- Visible and off-page previous/next navigation, including complete-order wraparound — PRODUCT (36-37). Run: - `cargo nextest run -p warpui_core --features tui tab_bar` From ca1d306b6b7a3d2b7faf01e2d4ea66d95b52ace9 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 16:42:50 -0400 Subject: [PATCH 38/40] add much better coverage and ownership --- crates/warp_tui/src/tui_column_layout.rs | 32 +- crates/warpui_core/src/elements/tui/mod.rs | 4 +- .../warpui_core/src/elements/tui/tab_bar.rs | 911 ++++++++++-------- .../src/elements/tui/tab_bar_tests.rs | 447 +++++---- .../src/elements/tui/text_helpers.rs | 34 + .../src/elements/tui/text_helpers_tests.rs | 10 + .../PRODUCT.md | 10 +- specs/code-1822-tui-tab-bar-component/TECH.md | 42 +- 8 files changed, 847 insertions(+), 643 deletions(-) create mode 100644 crates/warpui_core/src/elements/tui/text_helpers.rs create mode 100644 crates/warpui_core/src/elements/tui/text_helpers_tests.rs diff --git a/crates/warp_tui/src/tui_column_layout.rs b/crates/warp_tui/src/tui_column_layout.rs index 11c7453fdba..b818bbf08ee 100644 --- a/crates/warp_tui/src/tui_column_layout.rs +++ b/crates/warp_tui/src/tui_column_layout.rs @@ -1,8 +1,6 @@ //! Shared width allocation and text formatting for two-column TUI rows. -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -const ELLIPSIS: &str = "..."; +use warpui_core::elements::tui::{text_width, truncate_with_ellipsis}; /// Width policy for a group of two-column rows. #[derive(Clone, Copy)] @@ -52,12 +50,12 @@ pub(crate) fn tui_two_column_layout<'a>( longest_first_columns = Some( longest_first_columns .unwrap_or_default() - .max(UnicodeWidthStr::width(first)), + .max(usize::from(text_width(first))), ); longest_second_columns = Some( longest_second_columns .unwrap_or_default() - .max(UnicodeWidthStr::width(second)), + .max(usize::from(text_width(second))), ); } @@ -117,34 +115,12 @@ pub(crate) fn format_tui_first_column(text: &str, layout: TuiTwoColumnLayout) -> let content_columns = first_columns.saturating_sub(gap_columns); let mut formatted = truncate_with_ellipsis(text, content_columns); if layout.show_second { - let formatted_columns = UnicodeWidthStr::width(formatted.as_str()); + let formatted_columns = usize::from(text_width(&formatted)); formatted.push_str(&" ".repeat(first_columns - formatted_columns)); } formatted } -/// Truncates `text` to `maximum_columns`, using as much of `...` as fits. -fn truncate_with_ellipsis(text: &str, maximum_columns: usize) -> String { - if UnicodeWidthStr::width(text) <= maximum_columns { - return text.to_owned(); - } - - let ellipsis_columns = UnicodeWidthStr::width(ELLIPSIS).min(maximum_columns); - let prefix_columns = maximum_columns - ellipsis_columns; - let mut prefix = String::new(); - let mut prefix_width = 0; - for character in text.chars() { - let character_width = UnicodeWidthChar::width(character).unwrap_or_default(); - if prefix_width + character_width > prefix_columns { - break; - } - prefix.push(character); - prefix_width += character_width; - } - prefix.push_str(&".".repeat(ellipsis_columns)); - prefix -} - #[cfg(test)] #[path = "tui_column_layout_tests.rs"] mod tests; diff --git a/crates/warpui_core/src/elements/tui/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index 4a8c7729b5d..dd40ac90abc 100644 --- a/crates/warpui_core/src/elements/tui/mod.rs +++ b/crates/warpui_core/src/elements/tui/mod.rs @@ -47,6 +47,7 @@ mod selectable; mod shimmering_text; mod tab_bar; mod text; +mod text_helpers; mod viewported_list; pub use animated::TuiAnimated; @@ -78,9 +79,10 @@ pub use selectable::{ pub use shimmering_text::TuiShimmeringText; pub use tab_bar::{ TuiTab, TuiTabBar, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, - TuiTabBarStyles, TuiTabBarText, + TuiTabBarStyles, }; pub use text::TuiText; +pub use text_helpers::{text_width, truncate_with_ellipsis}; pub use viewported_list::{ TuiViewportContent, TuiViewportPosition, TuiViewportVerticalAlignment, TuiViewportWindow, TuiViewportedElement, TuiViewportedList, TuiViewportedListState, TuiVisibleViewportItem, diff --git a/crates/warpui_core/src/elements/tui/tab_bar.rs b/crates/warpui_core/src/elements/tui/tab_bar.rs index 9783a19b0fd..cd32f909550 100644 --- a/crates/warpui_core/src/elements/tui/tab_bar.rs +++ b/crates/warpui_core/src/elements/tui/tab_bar.rs @@ -1,62 +1,81 @@ //! Retained horizontal TUI tab bar with an optional fixed main tab and //! width-aware paging for secondary tabs. +//! +//! # Ownership +//! +//! The caller owns semantic application state: the ordered tabs, selected key, +//! focus state, and current page anchor. [`TuiTabBar`] owns only UI state that +//! must survive element-tree reconstruction: mouse handles and the navigation +//! data produced by the latest completed layout. +//! +//! # Per-frame flow +//! +//! 1. The caller passes [`TuiTabBarConfig`] and an event callback to +//! [`TuiTabBar::render`]. +//! 2. The returned [`TabBarElement`] waits for `layout` to provide the actual +//! row width. It builds each caller-supplied leading element once, measures +//! it, and keeps that same instance for the rendered row. +//! 3. [`tab_bar_layout`] performs the pure width calculation. It chooses the +//! visible secondary page, truncates labels, and computes previous/next page +//! anchors. +//! 4. [`TabBarElement::build_row`] assembles the fixed visual order: caller +//! label, main tab, divider, previous control, visible tabs, and next +//! control. +//! 5. `after_layout` publishes only [`SettledNavigation`] back to the retained +//! component. Callers can then request previous/next keyboard targets +//! without gaining access to private widths or visible ranges. +//! +//! Pointer handlers emit [`TuiTabBarEvent`] values only. They never mutate the +//! caller's selection, focus, page anchor, or tab collection. use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use std::hash::Hash; use std::rc::Rc; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; +use ratatui::style::Modifier; use super::{ - TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiFlex, TuiHoverable, TuiLayoutContext, - TuiPaintContext, TuiPaintSurface, TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, - TuiSize, TuiStyle, TuiText, + text_width, truncate_with_ellipsis, TuiConstraint, TuiContainer, TuiElement, TuiEvent, + TuiEventContext, TuiFlex, TuiHoverable, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, TuiText, }; use crate::elements::MouseStateHandle; use crate::AppContext; -const ELLIPSIS: &str = "..."; - -/// Styled text used for fixed tab-bar chrome or a tab's leading glyph. -#[derive(Clone, Debug, Default)] -pub struct TuiTabBarText { - pub text: String, - pub style: TuiStyle, -} - -impl TuiTabBarText { - /// Creates styled tab-bar text. - pub fn new(text: impl Into, style: TuiStyle) -> Self { - Self { - text: text.into(), - style, - } - } -} +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- /// One stable tab supplied by a tab-bar owner. -#[derive(Clone, Debug)] -pub struct TuiTab { - pub key: K, +#[derive(Clone)] +pub struct TuiTab { + /// Stable identity used for retained mouse state, callbacks, and paging. + pub key: String, + /// Human-readable text displayed after any leading element. pub label: String, - pub leading: Option, + /// Rebuilds caller-owned visual content for each TUI layout pass. + leading_element: Option Box>>, } -impl TuiTab { - /// Creates a tab without a leading glyph. - pub fn new(key: K, label: impl Into) -> Self { +impl TuiTab { + /// Creates a tab without leading content. + pub fn new(key: impl Into, label: impl Into) -> Self { Self { - key, + key: key.into(), label: label.into(), - leading: None, + leading_element: None, } } - /// Adds styled leading text to the tab. - pub fn with_leading(mut self, leading: TuiTabBarText) -> Self { - self.leading = Some(leading); + /// Adds arbitrary caller-rendered content before the tab label. + /// + /// The component invokes this factory once per layout pass. It measures the + /// returned element and moves that same instance into the visible tab. + pub fn with_leading_element( + mut self, + build_element: impl Fn() -> Box + 'static, + ) -> Self { + self.leading_element = Some(Rc::new(build_element)); self } } @@ -64,57 +83,73 @@ impl TuiTab { /// Caller-supplied styles for a tab-bar row. #[derive(Clone, Copy, Debug, Default)] pub struct TuiTabBarStyles { + /// Background style applied across the component's full assigned row. pub bar: TuiStyle, + /// Style for the caller-provided product label before the tabs. + pub leading: TuiStyle, + /// Style for the fixed divider and previous/next arrows. + pub chrome: TuiStyle, + /// Style for an unselected tab. pub tab: TuiStyle, + /// Style for the selected tab while the bar owns keyboard focus. pub selected_focused: TuiStyle, + /// Style for the selected tab while another surface owns keyboard focus. pub selected_unfocused: TuiStyle, } /// Semantic events emitted by pointer interaction with the tab bar. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum TuiTabBarEvent { - SelectTab(K), - PageChanged(K), +pub enum TuiTabBarEvent { + /// The user clicked a visible tab. + SelectTab(String), + /// The user clicked an overflow arrow targeting another page anchor. + PageChanged(String), } /// Direction for width-aware tab navigation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TuiTabBarNavigationDirection { + /// Resolve the tab before the current selection. Previous, + /// Resolve the tab after the current selection. Next, } - -type EventHandler = Rc Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext)>; +/// Callback shape shared by every clickable tab and overflow control. +/// Stored behind `Rc` so each child handler observes the same callback. +type EventHandler = dyn for<'a> Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext); /// Per-render input for [`TuiTabBar`]. -pub struct TuiTabBarConfig { - pub leading: Option, - pub main_tab: Option>, - pub divider: Option, - pub tabs: Vec>, - pub selected_key: Option, +pub struct TuiTabBarConfig { + /// Optional product label rendered before the main tab. + pub leading: Option, + /// Optional fixed tab that never participates in secondary paging. + pub main_tab: Option, + /// Ordered secondary tabs packed into the remaining row width. + pub tabs: Vec, + /// Key whose tab receives the selected style. + pub selected_key: Option, + /// Whether the selected tab uses the focused or unfocused selected style. pub focused: bool, - pub page_anchor: Option, + /// Secondary key from which the requested page begins. + pub page_anchor: Option, + /// Whether layout should replace an off-page anchor with the selected tab's page. pub reveal_selected: bool, + /// Maximum display-cell width of each label, including its ellipsis. pub maximum_label_columns: Option, + /// Blank display cells placed on each side of every tab. pub tab_padding_columns: u16, + /// Blank display cells separating secondary tabs and overflow arrows. pub secondary_gap_columns: u16, - pub previous_overflow: TuiTabBarText, - pub next_overflow: TuiTabBarText, + /// Caller-supplied semantic styles for the complete row. pub styles: TuiTabBarStyles, - on_event: EventHandler, } -impl TuiTabBarConfig { +impl TuiTabBarConfig { /// Creates a tab-bar configuration with neutral chrome defaults. - pub fn new( - tabs: Vec>, - on_event: impl for<'a> Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext) + 'static, - ) -> Self { + pub fn new(tabs: Vec) -> Self { Self { leading: None, main_tab: None, - divider: None, tabs, selected_key: None, focused: false, @@ -123,93 +158,114 @@ impl TuiTabBarConfig { maximum_label_columns: None, tab_padding_columns: 1, secondary_gap_columns: 1, - previous_overflow: TuiTabBarText::new("←", TuiStyle::default()), - next_overflow: TuiTabBarText::new("→", TuiStyle::default()), styles: TuiTabBarStyles::default(), - on_event: Rc::new(on_event), } } } +// ----------------------------------------------------------------------------- +// Private layout model +// ----------------------------------------------------------------------------- + +/// Render-ready description of one tab after label truncation. +/// +/// `source_index` reconnects a visible secondary tab to the leading element +/// that was built and measured before the pure layout calculation. #[derive(Clone)] -struct RenderedTab { - tab: TuiTab, +struct RenderedTab { + /// Stable callback and mouse-state identity. + key: String, + /// Index into the original secondary-tab and leading-element vectors. + source_index: usize, + /// Label after configured and width-derived truncation. label: String, + /// Total display cells occupied by padding, leading content, gap, and label. width: u16, } +/// One deterministic secondary page packed from `start`. #[derive(Clone)] -struct PageLayout { +struct PageLayout { + /// Inclusive index of the first secondary tab considered for this page. start: usize, + /// Exclusive index after the last tab that actually fit. end: usize, - tabs: Vec>, - previous_anchor: Option, - next_anchor: Option, + /// Visible tabs in paint order. + tabs: Vec, + /// Strictly later index that begins the next page, when one exists. + next_start: Option, } +/// Navigation-only data retained after the render-specific layout is discarded. #[derive(Clone)] -enum LayoutPiece { - Text(TuiTabBarText), - Gap(u16), - Tab(RenderedTab), - Overflow { - text: TuiTabBarText, - anchor: K, - is_previous: bool, - }, +struct SettledNavigation { + /// Complete navigation order: optional main tab followed by all secondaries. + order: Vec, + /// Explicit main-tab identity; never inferred from `order.first()`. + main_tab_key: Option, + /// Selection used by the layout that produced this state. + selected_key: Option, + /// Secondary keys that were actually painted. + visible_secondary_keys: Vec, } +/// Complete output of the pure width calculation for one frame. #[derive(Clone)] -struct TabBarLayout { - pieces: Vec>, - order: Vec, - selected_key: Option, - visible_secondary_keys: Vec, +struct TabBarLayout { + /// Fixed main tab after label truncation. + main_tab: Option, + /// Anchor emitted by the previous overflow control. + previous_anchor: Option, + /// Visible secondary tabs in paint order. + tabs: Vec, + /// Anchor emitted by the next overflow control. + next_anchor: Option, + /// Lightweight subset published to the retained component after layout. + navigation: SettledNavigation, } - -struct TuiTabBarState { - mouse_states: HashMap, +// ----------------------------------------------------------------------------- +// Retained component state +// ----------------------------------------------------------------------------- + +/// UI-only state that survives per-frame element reconstruction. +/// Application selection and page state deliberately do not live here. +#[derive(Default)] +struct TuiTabBarState { + /// Stable pointer state for every currently supplied tab key. + mouse_states: HashMap, + /// Pointer state for the previous overflow arrow. previous_overflow_mouse_state: MouseStateHandle, + /// Pointer state for the next overflow arrow. next_overflow_mouse_state: MouseStateHandle, - settled_layout: Option>, + /// Navigation data from the latest completed layout. + settled_navigation: Option, } -impl Default for TuiTabBarState { - fn default() -> Self { - Self { - mouse_states: HashMap::new(), - previous_overflow_mouse_state: MouseStateHandle::default(), - next_overflow_mouse_state: MouseStateHandle::default(), - settled_layout: None, - } - } +/// Retained owner for mouse state and settled keyboard navigation. +/// +/// Views keep one instance of this type and call [`Self::render`] each frame. +/// The returned element owns all width-dependent rendering for that frame. +#[derive(Default)] +pub struct TuiTabBar { + /// Shared bridge between the retained owner and the per-frame element. + state: Rc>, } -/// Retained TUI tab-bar component. Width-derived layout stays private; owners -/// receive only semantic tab keys and page anchors. -pub struct TuiTabBar { - state: Rc>>, -} - -impl Default for TuiTabBar { - fn default() -> Self { - Self { - state: Rc::new(RefCell::new(TuiTabBarState::default())), - } - } -} - -impl TuiTabBar -where - K: Clone + Eq + Hash + 'static, -{ +impl TuiTabBar { /// Creates an empty retained tab-bar component. pub fn new() -> Self { Self::default() } - /// Builds the per-frame tab-bar element from caller-owned semantic state. - pub fn render(&self, config: TuiTabBarConfig) -> Box { + /// Builds a per-frame element from caller-owned semantic state. + /// + /// This reconciles the retained mouse-state map with the supplied keys, + /// invalidates stale navigation, and moves the config into a + /// [`TabBarElement`] that will resolve paging during layout. + pub fn render(&self, config: TuiTabBarConfig, on_event: F) -> Box + where + F: for<'a> Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext) + 'static, + { let live_keys: HashSet<_> = config .main_tab .iter() @@ -222,11 +278,12 @@ where for key in live_keys { state.mouse_states.entry(key).or_default(); } - state.settled_layout = None; + state.settled_navigation = None; } TabBarElement { state: self.state.clone(), config, + on_event: Rc::new(on_event), row: None, layout: None, size: None, @@ -235,54 +292,72 @@ where .finish() } - /// Resolves navigation from the component's private settled layout. - pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { + /// Resolves a keyboard-navigation target from the latest completed layout. + /// + /// Visible selections navigate through the complete order and wrap. + /// Off-page selections enter the visible page from its nearest edge. + /// Returns `None` before the first layout or when no visible target exists. + pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { let state = self.state.borrow(); - let layout = state.settled_layout.as_ref()?; - if layout.order.is_empty() { + let navigation = state.settled_navigation.as_ref()?; + if navigation.order.is_empty() { return None; } - let selected_index = layout + let selected_index = navigation .selected_key .as_ref() - .and_then(|selected| layout.order.iter().position(|key| key == selected)); - let selected_is_visible = layout.selected_key.as_ref().is_some_and(|selected| { - layout.visible_secondary_keys.contains(selected) - || layout.order.first() == Some(selected) + .and_then(|selected| navigation.order.iter().position(|key| key == selected)); + let selected_is_visible = navigation.selected_key.as_ref().is_some_and(|selected| { + navigation.visible_secondary_keys.contains(selected) + || navigation.main_tab_key.as_ref() == Some(selected) }); if selected_is_visible { let selected_index = selected_index?; let target_index = match direction { TuiTabBarNavigationDirection::Previous => selected_index .checked_sub(1) - .unwrap_or(layout.order.len() - 1), - TuiTabBarNavigationDirection::Next => (selected_index + 1) % layout.order.len(), + .unwrap_or(navigation.order.len() - 1), + TuiTabBarNavigationDirection::Next => (selected_index + 1) % navigation.order.len(), }; - return layout.order.get(target_index).cloned(); + return navigation.order.get(target_index).cloned(); } match direction { - TuiTabBarNavigationDirection::Previous => layout.visible_secondary_keys.last().cloned(), - TuiTabBarNavigationDirection::Next => layout.visible_secondary_keys.first().cloned(), + TuiTabBarNavigationDirection::Previous => { + navigation.visible_secondary_keys.last().cloned() + } + TuiTabBarNavigationDirection::Next => { + navigation.visible_secondary_keys.first().cloned() + } } } } -struct TabBarElement { - state: Rc>>, - config: TuiTabBarConfig, +// ----------------------------------------------------------------------------- +// Per-frame element construction +// ----------------------------------------------------------------------------- + +/// Per-frame element that turns a config plus an assigned width into a row. +struct TabBarElement { + /// Retained state used for mouse handles and settled navigation publication. + state: Rc>, + /// Caller-owned semantic inputs for this frame. + config: TuiTabBarConfig, + /// Semantic event callback shared by all clickable children. + on_event: Rc, + /// Composed row built during layout and delegated to afterward. row: Option, - layout: Option>, + /// Width-derived result kept until `after_layout` publishes navigation. + layout: Option, + /// Full assigned component size from the latest layout. size: Option, + /// Screen-space origin from the latest paint. origin: Option, } -impl TabBarElement -where - K: Clone + Eq + Hash + 'static, -{ +impl TabBarElement { /// Resolves a tab's caller-supplied style from selection and focus. - fn tab_style(&self, key: &K) -> TuiStyle { - if self.config.selected_key.as_ref() != Some(key) { + fn tab_style(&self, key: &str) -> TuiStyle { + if self.config.selected_key.as_deref() != Some(key) { self.config.styles.tab } else if self.config.focused { self.config.styles.selected_focused @@ -291,45 +366,91 @@ where } } - /// Builds one clickable tab from its settled label and retained mouse state. - fn tab_element(&self, tab: &RenderedTab) -> Box { + /// Builds one clickable tab from a settled label and measured leading child. + /// + /// Text receives the tab's selected/focused style, hover bolds only the + /// label, and an outer container fills selected background behind arbitrary + /// caller-supplied leading content. + fn render_tab( + &self, + tab: &RenderedTab, + leading_element: Option>, + ) -> Box { let padding = " ".repeat(usize::from(self.config.tab_padding_columns)); - let mut spans = vec![(padding.clone(), TuiStyle::default())]; - if let Some(leading) = &tab.tab.leading { - spans.push((leading.text.clone(), leading.style)); - if !tab.label.is_empty() { - spans.push((" ".to_string(), TuiStyle::default())); - } - } - spans.push((tab.label.clone(), TuiStyle::default())); - spans.push((padding, TuiStyle::default())); let state = self .state .borrow() .mouse_states - .get(&tab.tab.key) + .get(&tab.key) .cloned() .expect("tab mouse state was initialized before layout"); - let key = tab.tab.key.clone(); - let on_event = self.config.on_event.clone(); - TuiHoverable::new( - state, - TuiText::from_spans(spans) - .with_style(self.tab_style(&tab.tab.key)) + let is_hovered = state.lock().unwrap().is_hovered(); + let tab_style = self.tab_style(&tab.key); + let label_style = if is_hovered { + tab_style.add_modifier(Modifier::BOLD) + } else { + tab_style + }; + let mut row = TuiFlex::row().child( + TuiText::new(padding.clone()) + .with_style(tab_style) .truncate() .finish(), - ) - .on_click(move |event_ctx, app| { - on_event(TuiTabBarEvent::SelectTab(key.clone()), event_ctx, app); - }) - .finish() + ); + if let Some(leading_element) = leading_element { + row = row.child(leading_element); + if !tab.label.is_empty() { + row = row.child(TuiText::new(" ").with_style(tab_style).truncate().finish()); + } + } + row = row + .child( + TuiText::new(tab.label.clone()) + .with_style(label_style) + .truncate() + .finish(), + ) + .child( + TuiText::new(padding) + .with_style(tab_style) + .truncate() + .finish(), + ); + let key = tab.key.clone(); + let on_event = self.on_event.clone(); + let row = row.finish(); + let content = if let Some(background) = tab_style.bg { + TuiContainer::new(row).with_background(background).finish() + } else { + row + }; + TuiHoverable::new(state, content) + .on_click(move |event_ctx, app| { + on_event(TuiTabBarEvent::SelectTab(key.clone()), event_ctx, app); + }) + .finish() + } + + /// Builds and measures a tab's leading child exactly once for this layout. + fn build_leading_element( + tab: &TuiTab, + size: TuiSize, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> (Option>, u16) { + let Some(build_leading_element) = &tab.leading_element else { + return (None, 0); + }; + let mut element = build_leading_element(); + let width = element.layout(TuiConstraint::loose(size), ctx, app).width; + (Some(element), width) } - /// Builds a clickable overflow control for a settled page anchor. - fn overflow_element( + /// Builds a clickable, hover-bold overflow arrow for a settled page anchor. + fn render_overflow( &self, - text: &TuiTabBarText, - anchor: K, + text: &'static str, + anchor: String, is_previous: bool, ) -> Box { let state = { @@ -340,13 +461,15 @@ where state.next_overflow_mouse_state.clone() } }; - let on_event = self.config.on_event.clone(); + let style = if state.lock().unwrap().is_hovered() { + self.config.styles.chrome.add_modifier(Modifier::BOLD) + } else { + self.config.styles.chrome + }; + let on_event = self.on_event.clone(); TuiHoverable::new( state, - TuiText::new(text.text.clone()) - .with_style(text.style) - .truncate() - .finish(), + TuiText::new(text).with_style(style).truncate().finish(), ) .on_click(move |event_ctx, app| { on_event(TuiTabBarEvent::PageChanged(anchor.clone()), event_ctx, app); @@ -354,36 +477,80 @@ where .finish() } - /// Converts settled layout pieces into the row delegated to for paint and events. - fn build_row(&self, layout: &TabBarLayout) -> TuiFlex { + /// Renders the configured spacing between pageable row items. + fn render_gap(&self) -> Box { + TuiText::new(" ".repeat(usize::from(self.config.secondary_gap_columns))) + .with_style(self.config.styles.bar) + .truncate() + .finish() + } + + /// Renders the caller-provided product label at the start of the row. + fn render_leading(&self, leading: &str) -> Box { + TuiText::new(leading) + .with_style(self.config.styles.leading) + .truncate() + .finish() + } + + /// Renders the fixed divider between the main tab and pageable tabs. + fn render_divider(&self) -> Box { + TuiText::new(" | ") + .with_style(self.config.styles.chrome) + .truncate() + .finish() + } + + /// Assembles the fixed visual order from named layout fields. + /// + /// Leading elements are moved from their measurement slots into visible + /// tabs, ensuring the measured instance is the instance that gets painted. + fn build_row( + &self, + layout: &TabBarLayout, + main_leading_element: Option>, + secondary_leading_elements: &mut [Option>], + ) -> TuiFlex { let mut row = TuiFlex::row(); - for piece in &layout.pieces { - let element = match piece { - LayoutPiece::Text(text) => TuiText::new(text.text.clone()) - .with_style(text.style) - .truncate() - .finish(), - LayoutPiece::Gap(columns) => TuiText::new(" ".repeat(usize::from(*columns))) - .with_style(self.config.styles.bar) - .truncate() - .finish(), - LayoutPiece::Tab(tab) => self.tab_element(tab), - LayoutPiece::Overflow { - text, - anchor, - is_previous, - } => self.overflow_element(text, anchor.clone(), *is_previous), - }; - row = row.child(element); + if let Some(leading) = &self.config.leading { + row = row.child(self.render_leading(leading)); + } + if let Some(main_tab) = &layout.main_tab { + row = row.child(self.render_tab(main_tab, main_leading_element)); + if !self.config.tabs.is_empty() { + row = row.child(self.render_divider()); + } + } + if let Some(previous_anchor) = &layout.previous_anchor { + row = row.child(self.render_overflow("←", previous_anchor.clone(), true)); + if !layout.tabs.is_empty() || layout.next_anchor.is_some() { + row = row.child(self.render_gap()); + } + } + for (index, tab) in layout.tabs.iter().enumerate() { + if index > 0 { + row = row.child(self.render_gap()); + } + let leading_element = secondary_leading_elements + .get_mut(tab.source_index) + .and_then(Option::take); + row = row.child(self.render_tab(tab, leading_element)); + } + if let Some(next_anchor) = &layout.next_anchor { + if !layout.tabs.is_empty() { + row = row.child(self.render_gap()); + } + row = row.child(self.render_overflow("→", next_anchor.clone(), false)); } row } } -impl TuiElement for TabBarElement -where - K: Clone + Eq + Hash + 'static, -{ +impl TuiElement for TabBarElement { + /// Measures leading children, computes the visible page, and builds the row. + /// + /// All child construction stays local to this pass. The measured leading + /// element instances are moved into the row before the row is laid out. fn layout( &mut self, constraint: TuiConstraint, @@ -394,8 +561,29 @@ where constraint.constrain_width(constraint.max.width), constraint.constrain_height(u16::from(constraint.max.height > 0)), ); - let layout = tab_bar_layout(&self.config, size.width); - let mut row = self.build_row(&layout); + let (main_leading_element, main_leading_columns) = self + .config + .main_tab + .as_ref() + .map(|tab| Self::build_leading_element(tab, size, ctx, app)) + .unwrap_or((None, 0)); + let (mut secondary_leading_elements, secondary_leading_columns): (Vec<_>, Vec<_>) = self + .config + .tabs + .iter() + .map(|tab| Self::build_leading_element(tab, size, ctx, app)) + .unzip(); + let layout = tab_bar_layout( + &self.config, + main_leading_columns, + &secondary_leading_columns, + size.width, + ); + let mut row = self.build_row( + &layout, + main_leading_element, + &mut secondary_leading_elements, + ); row.layout(TuiConstraint::loose(size), ctx, app); self.row = Some(row); self.layout = Some(layout); @@ -403,13 +591,16 @@ where size } + /// Publishes navigation only after the complete row layout has settled. fn after_layout(&mut self, ctx: &mut TuiLayoutContext, app: &AppContext) { if let Some(row) = &mut self.row { row.after_layout(ctx, app); } - self.state.borrow_mut().settled_layout = self.layout.clone(); + self.state.borrow_mut().settled_navigation = + self.layout.as_ref().map(|layout| layout.navigation.clone()); } + /// Paints the bar background, then delegates content paint to the row. fn render( &mut self, origin: TuiScreenPosition, @@ -440,6 +631,7 @@ where } } + /// Delegates pointer interaction to the row's tab and overflow hoverables. fn dispatch_event( &mut self, event: &TuiEvent, @@ -452,34 +644,50 @@ where } } -/// Settles the complete row, including fixed chrome and one secondary page. -fn tab_bar_layout(config: &TuiTabBarConfig, available_columns: u16) -> TabBarLayout -where - K: Clone + Eq, -{ - let mut pieces = Vec::new(); - let mut fixed_columns = 0u16; - if let Some(leading) = &config.leading { - fixed_columns = fixed_columns.saturating_add(text_width(&leading.text)); - pieces.push(LayoutPiece::Text(leading.clone())); - } - if let Some(main_tab) = &config.main_tab { - let rendered = render_tab(main_tab, config, None); - fixed_columns = fixed_columns.saturating_add(rendered.width); - pieces.push(LayoutPiece::Tab(rendered)); - } - if let Some(divider) = &config.divider { - fixed_columns = fixed_columns.saturating_add(text_width(÷r.text)); - pieces.push(LayoutPiece::Text(divider.clone())); +// ----------------------------------------------------------------------------- +// Pure width and paging calculations +// ----------------------------------------------------------------------------- + +/// Computes the render-ready row and its retained navigation subset. +/// +/// Fixed content is measured first. The remaining columns are handed to the +/// secondary-page packer. Selected-tab reveal and previous-page resolution +/// both use the same deterministic page sequence. +fn tab_bar_layout( + config: &TuiTabBarConfig, + main_leading_columns: u16, + secondary_leading_columns: &[u16], + available_columns: u16, +) -> TabBarLayout { + let main_tab = config + .main_tab + .as_ref() + .map(|tab| layout_tab(tab, 0, main_leading_columns, config, None)); + let mut fixed_columns = config + .leading + .as_deref() + .map(text_width) + .unwrap_or_default(); + if let Some(main_tab) = &main_tab { + fixed_columns = fixed_columns.saturating_add(main_tab.width); + if !config.tabs.is_empty() { + fixed_columns = fixed_columns.saturating_add(text_width(" | ")); + } } let secondary_columns = available_columns.saturating_sub(fixed_columns); - let start = config + let requested_start = config .page_anchor .as_ref() .and_then(|anchor| config.tabs.iter().position(|tab| &tab.key == anchor)) .unwrap_or_default(); - let mut page = page_layout(config, start, secondary_columns); + let pages = deterministic_pages(config, secondary_leading_columns, secondary_columns); + let mut page = page_layout( + config, + secondary_leading_columns, + requested_start, + secondary_columns, + ); if config.reveal_selected { if let Some(selected_index) = config .selected_key @@ -487,43 +695,27 @@ where .and_then(|selected| config.tabs.iter().position(|tab| &tab.key == selected)) { if selected_index < page.start || selected_index >= page.end { - let stable_anchor = - stable_page_anchor_for_index(config, selected_index, secondary_columns); - page = page_layout(config, stable_anchor, secondary_columns); - } - } - } - page.previous_anchor = previous_page_anchor(config, page.start, secondary_columns) - .and_then(|index| config.tabs.get(index).map(|tab| tab.key.clone())); - if page.start > 0 { - if let Some(anchor) = &page.previous_anchor { - pieces.push(LayoutPiece::Overflow { - text: config.previous_overflow.clone(), - anchor: anchor.clone(), - is_previous: true, - }); - if !page.tabs.is_empty() || page.next_anchor.is_some() { - pieces.push(LayoutPiece::Gap(config.secondary_gap_columns)); + if let Some(selected_page) = pages.iter().find(|candidate| { + (selected_index >= candidate.start && selected_index < candidate.end) + || (candidate.tabs.is_empty() && candidate.start == selected_index) + }) { + page = selected_page.clone(); + } } } } - for (index, tab) in page.tabs.iter().enumerate() { - if index > 0 { - pieces.push(LayoutPiece::Gap(config.secondary_gap_columns)); - } - pieces.push(LayoutPiece::Tab(tab.clone())); - } - if let Some(anchor) = &page.next_anchor { - if !page.tabs.is_empty() { - pieces.push(LayoutPiece::Gap(config.secondary_gap_columns)); - } - pieces.push(LayoutPiece::Overflow { - text: config.next_overflow.clone(), - anchor: anchor.clone(), - is_previous: false, - }); - } + let previous_anchor = pages + .iter() + .take_while(|candidate| candidate.start < page.start) + .last() + .and_then(|candidate| config.tabs.get(candidate.start)) + .map(|tab| tab.key.clone()); + let next_anchor = page + .next_start + .and_then(|start| config.tabs.get(start)) + .map(|tab| tab.key.clone()); + let visible_secondary_keys = page.tabs.iter().map(|tab| tab.key.clone()).collect(); let order = config .main_tab .iter() @@ -531,146 +723,121 @@ where .map(|tab| tab.key.clone()) .collect(); TabBarLayout { - pieces, - order, - selected_key: config.selected_key.clone(), - visible_secondary_keys: page.tabs.into_iter().map(|tab| tab.tab.key).collect(), + main_tab, + previous_anchor, + tabs: page.tabs, + next_anchor, + navigation: SettledNavigation { + order, + main_tab_key: config.main_tab.as_ref().map(|tab| tab.key.clone()), + selected_key: config.selected_key.clone(), + visible_secondary_keys, + }, } } +/// Enumerates every secondary page by following strictly advancing starts. +fn deterministic_pages( + config: &TuiTabBarConfig, + leading_columns: &[u16], + available_columns: u16, +) -> Vec { + if config.tabs.is_empty() { + return Vec::new(); + } + let mut pages = Vec::new(); + let mut start = 0; + loop { + let page = page_layout(config, leading_columns, start, available_columns); + let next_start = page.next_start; + pages.push(page); + let Some(next_start) = next_start else { + break; + }; + start = next_start; + } + pages +} + /// Packs one secondary page into the columns left after fixed chrome. -fn page_layout( - config: &TuiTabBarConfig, +/// +/// A full tab is preferred. If it does not fit, the final visible tab may use +/// the remaining label budget. If even its fixed content plus one label cell +/// cannot fit, the page advances without rendering that tab. +fn page_layout( + config: &TuiTabBarConfig, + leading_columns: &[u16], requested_start: usize, available_columns: u16, -) -> PageLayout -where - K: Clone, -{ +) -> PageLayout { let start = requested_start.min(config.tabs.len().saturating_sub(1)); - let show_previous = start > 0; - let previous_columns = if show_previous { - text_width(&config.previous_overflow.text).saturating_add(config.secondary_gap_columns) + let previous_columns = if start > 0 { + text_width("←").saturating_add(config.secondary_gap_columns) } else { 0 }; let mut remaining = available_columns.saturating_sub(previous_columns); - let next_columns = - text_width(&config.next_overflow.text).saturating_add(config.secondary_gap_columns); + let next_columns = text_width("→").saturating_add(config.secondary_gap_columns); let mut rendered_tabs = Vec::new(); let mut end = start; - for (offset, tab) in config.tabs.iter().enumerate().skip(start) { + for (index, tab) in config.tabs.iter().enumerate().skip(start) { let gap = if rendered_tabs.is_empty() { 0 } else { config.secondary_gap_columns }; - let has_later_tabs = offset + 1 < config.tabs.len(); - let reserve_next = if has_later_tabs { next_columns } else { 0 }; + let reserve_next = if index + 1 < config.tabs.len() { + next_columns + } else { + 0 + }; let available_for_tab = remaining.saturating_sub(gap).saturating_sub(reserve_next); - let rendered = render_tab(tab, config, None); + let leading_columns = leading_columns.get(index).copied().unwrap_or_default(); + let rendered = layout_tab(tab, index, leading_columns, config, None); if rendered.width <= available_for_tab { remaining = remaining.saturating_sub(gap.saturating_add(rendered.width)); rendered_tabs.push(rendered); - end = offset + 1; + end = index + 1; continue; } - let fixed = tab_fixed_columns(tab, config.tab_padding_columns); + let fixed = tab_fixed_columns(tab, leading_columns, config.tab_padding_columns); let minimum = fixed.saturating_add(u16::from(!tab.label.is_empty())); if available_for_tab >= minimum { - rendered_tabs.push(render_tab(tab, config, Some(available_for_tab))); - end = offset + 1; + rendered_tabs.push(layout_tab( + tab, + index, + leading_columns, + config, + Some(available_for_tab), + )); + end = index + 1; } break; } - let next_index = if end < config.tabs.len() { - Some(if end > start { - end - } else { - start.saturating_add(1).min(config.tabs.len() - 1) - }) + let candidate = if end > start { + end } else { - None + start.saturating_add(1) }; PageLayout { start, end, tabs: rendered_tabs, - previous_anchor: None, - next_anchor: next_index.and_then(|index| config.tabs.get(index).map(|tab| tab.key.clone())), - } -} - -/// Finds the preceding deterministic page anchor for `current_start`. -fn previous_page_anchor( - config: &TuiTabBarConfig, - current_start: usize, - available_columns: u16, -) -> Option -where - K: Clone, -{ - if current_start == 0 { - return None; - } - let mut anchor = 0usize; - loop { - let page = page_layout(config, anchor, available_columns); - let next = if page.end > anchor { - page.end - } else { - anchor.saturating_add(1) - }; - if next >= current_start { - return Some(anchor); - } - if next <= anchor || next >= config.tabs.len() { - return Some(current_start.saturating_sub(1)); - } - anchor = next; - } -} - -/// Finds the deterministic page anchor whose range contains `target`. -fn stable_page_anchor_for_index( - config: &TuiTabBarConfig, - target: usize, - available_columns: u16, -) -> usize -where - K: Clone, -{ - let mut anchor = 0usize; - while anchor < target { - let page = page_layout(config, anchor, available_columns); - if target < page.end { - return anchor; - } - let next = if page.end > anchor { - page.end - } else { - anchor.saturating_add(1) - }; - if next <= anchor || next >= config.tabs.len() { - break; - } - anchor = next; + next_start: (candidate < config.tabs.len()).then_some(candidate), } - anchor.min(target) } -/// Truncates one tab to its configured or width-derived total column budget. -fn render_tab( - tab: &TuiTab, - config: &TuiTabBarConfig, +/// Produces one render-ready tab description within a total column budget. +fn layout_tab( + tab: &TuiTab, + source_index: usize, + leading_columns: u16, + config: &TuiTabBarConfig, total_columns: Option, -) -> RenderedTab -where - K: Clone, -{ - let fixed_columns = tab_fixed_columns(tab, config.tab_padding_columns); +) -> RenderedTab { + let fixed_columns = tab_fixed_columns(tab, leading_columns, config.tab_padding_columns); let configured_label_columns = config .maximum_label_columns .unwrap_or_else(|| text_width(&tab.label)); @@ -678,51 +845,23 @@ where .map(|columns| columns.saturating_sub(fixed_columns)) .unwrap_or(configured_label_columns) .min(configured_label_columns); - let label = truncate_with_ellipsis(&tab.label, label_columns); + let label = truncate_with_ellipsis(&tab.label, usize::from(label_columns)); RenderedTab { - tab: tab.clone(), + key: tab.key.clone(), + source_index, width: fixed_columns.saturating_add(text_width(&label)), label, } } -/// Columns occupied by tab padding and optional leading text. -fn tab_fixed_columns(tab: &TuiTab, padding_columns: u16) -> u16 { - let leading_columns = tab - .leading - .as_ref() - .map(|leading| text_width(&leading.text)) - .unwrap_or_default(); +/// Returns columns occupied before label text is added. +fn tab_fixed_columns(tab: &TuiTab, leading_columns: u16, padding_columns: u16) -> u16 { padding_columns .saturating_mul(2) .saturating_add(leading_columns) - .saturating_add(u16::from(tab.leading.is_some() && !tab.label.is_empty())) -} - -/// Returns terminal display-cell width, saturating at `u16::MAX`. -fn text_width(text: &str) -> u16 { - u16::try_from(UnicodeWidthStr::width(text)).unwrap_or(u16::MAX) -} - -/// Truncates at grapheme boundaries and keeps as much of `...` as fits. -fn truncate_with_ellipsis(text: &str, maximum_columns: u16) -> String { - if text_width(text) <= maximum_columns { - return text.to_owned(); - } - let ellipsis_columns = text_width(ELLIPSIS).min(maximum_columns); - let prefix_columns = maximum_columns.saturating_sub(ellipsis_columns); - let mut prefix = String::new(); - let mut prefix_width = 0u16; - for grapheme in UnicodeSegmentation::graphemes(text, true) { - let grapheme_width = text_width(grapheme); - if prefix_width.saturating_add(grapheme_width) > prefix_columns { - break; - } - prefix.push_str(grapheme); - prefix_width = prefix_width.saturating_add(grapheme_width); - } - prefix.push_str(&".".repeat(usize::from(ellipsis_columns))); - prefix + .saturating_add(u16::from( + tab.leading_element.is_some() && !tab.label.is_empty(), + )) } #[cfg(test)] diff --git a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs index 4e7ae89b668..fcfc08b476a 100644 --- a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs +++ b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs @@ -1,32 +1,37 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::sync::Arc; use ratatui::style::{Color, Modifier}; use super::{ - page_layout, tab_bar_layout, text_width, truncate_with_ellipsis, TuiTab, TuiTabBar, - TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, TuiTabBarStyles, TuiTabBarText, + page_layout, tab_bar_layout, TuiTab, TuiTabBar, TuiTabBarConfig, TuiTabBarEvent, + TuiTabBarNavigationDirection, TuiTabBarStyles, }; use crate::elements::tui::test_support::dispatch_presented_event; -use crate::elements::tui::{TuiBufferExt, TuiEvent, TuiPoint, TuiRect, TuiStyle}; +use crate::elements::tui::{ + TuiBufferExt, TuiElement, TuiEvent, TuiPoint, TuiRect, TuiStyle, TuiText, +}; use crate::event::ModifiersState; use crate::presenter::tui::TuiPresenter; -use crate::App; +use crate::{App, AppContext}; -type Events = Rc>>>; +type Events = Rc>>; -fn tab(key: u8, label: &str) -> TuiTab { - TuiTab::new(key, label) +fn key(key: u8) -> String { + key.to_string() } -fn config(tabs: Vec>, events: &Events) -> TuiTabBarConfig { - let events = events.clone(); - let mut config = TuiTabBarConfig::new(tabs, move |event, _, _| { - events.borrow_mut().push(event); - }); +fn tab(key: u8, label: &str) -> TuiTab { + TuiTab::new(key.to_string(), label) +} + +fn config(tabs: Vec) -> TuiTabBarConfig { + let mut config = TuiTabBarConfig::new(tabs); config.styles = TuiTabBarStyles { bar: TuiStyle::default().bg(Color::Black), + leading: TuiStyle::default().fg(Color::White), + chrome: TuiStyle::default().fg(Color::White), tab: TuiStyle::default().fg(Color::White), selected_focused: TuiStyle::default() .fg(Color::Black) @@ -37,266 +42,296 @@ fn config(tabs: Vec>, events: &Events) -> TuiTabBarConfig { config } -fn left_mouse_down(x: u16) -> TuiEvent { - TuiEvent::LeftMouseDown { +fn render(bar: &TuiTabBar, config: TuiTabBarConfig, events: &Events) -> Box { + let events = events.clone(); + bar.render(config, move |event, _, _| { + events.borrow_mut().push(event); + }) +} + +fn layout(config: &TuiTabBarConfig, available_columns: u16) -> super::TabBarLayout { + tab_bar_layout(config, 0, &vec![0; config.tabs.len()], available_columns) +} + +fn page( + config: &TuiTabBarConfig, + requested_start: usize, + available_columns: u16, +) -> super::PageLayout { + page_layout( + config, + &vec![0; config.tabs.len()], + requested_start, + available_columns, + ) +} + +fn mouse_moved(x: u16) -> TuiEvent { + TuiEvent::MouseMoved { position: TuiPoint::new(x, 0), modifiers: ModifiersState::default(), - click_count: 1, - is_first_mouse: false, + is_synthetic: false, } } -fn left_mouse_up(x: u16) -> TuiEvent { - TuiEvent::LeftMouseUp { +fn click(presenter: &mut TuiPresenter, x: u16, app: &AppContext) { + let down = TuiEvent::LeftMouseDown { position: TuiPoint::new(x, 0), modifiers: ModifiersState::default(), - } + click_count: 1, + is_first_mouse: false, + }; + let up = TuiEvent::LeftMouseUp { + position: TuiPoint::new(x, 0), + modifiers: ModifiersState::default(), + }; + assert!(dispatch_presented_event(presenter, &down, app).0); + assert!(dispatch_presented_event(presenter, &up, app).0); } #[test] -fn truncates_by_display_columns_without_splitting_graphemes() { - assert_eq!(truncate_with_ellipsis("infrastructure", 8), "infra..."); - assert_eq!(truncate_with_ellipsis("abcdef", 2), ".."); - assert_eq!(truncate_with_ellipsis("界界界界", 7), "界界..."); - assert_eq!(truncate_with_ellipsis("e\u{301}clair", 5), "e\u{301}c..."); - assert_eq!(text_width("界界..."), 7); -} +fn layout_keeps_main_fixed_and_clamps_missing_anchor() { + let mut config = config(vec![tab(2, "alpha"), tab(3, "beta"), tab(4, "gamma")]); + config.leading = Some("Agents:".to_string()); + config.main_tab = Some(tab(1, "main")); + config.page_anchor = Some(key(3)); -#[test] -fn main_tab_is_fixed_while_secondary_tabs_page() { - let events = Rc::new(RefCell::new(Vec::new())); - let mut config = config( - vec![tab(2, "alpha"), tab(3, "beta"), tab(4, "gamma")], - &events, + let anchored = layout(&config, 24); + assert_eq!( + anchored.navigation.order, + vec![key(1), key(2), key(3), key(4)] ); - config.leading = Some(TuiTabBarText::new("Agents:", TuiStyle::default())); - config.main_tab = Some(tab(1, "main")); - config.divider = Some(TuiTabBarText::new("|", TuiStyle::default())); - config.page_anchor = Some(3); - - let layout = tab_bar_layout(&config, 24); - assert_eq!(layout.order, vec![1, 2, 3, 4]); - assert!(layout - .pieces - .iter() - .any(|piece| matches!(piece, super::LayoutPiece::Tab(tab) if tab.tab.key == 1))); - assert!(!layout.visible_secondary_keys.contains(&2)); - assert!(layout.visible_secondary_keys.contains(&3)); -} + assert_eq!( + anchored.main_tab.as_ref().map(|tab| tab.key.clone()), + Some(key(1)) + ); + assert!(!anchored.navigation.visible_secondary_keys.contains(&key(2))); + assert!(anchored.navigation.visible_secondary_keys.contains(&key(3))); -#[test] -fn page_layout_reserves_overflow_and_truncates_the_last_visible_tab() { - let events = Rc::new(RefCell::new(Vec::new())); - let mut config = config( - vec![ - tab(1, "alpha"), - tab(2, "bravo"), - tab(3, "charlie-long"), - tab(4, "delta"), - ], - &events, + config.page_anchor = Some("missing".to_string()); + assert_eq!( + layout(&config, 40).navigation.visible_secondary_keys, + vec![key(2), key(3), key(4)] ); - config.maximum_label_columns = Some(20); - let page = page_layout(&config, 0, 22); - - assert_eq!(page.start, 0); - assert!(page.next_anchor.is_some()); - assert!(!page.tabs.is_empty()); - assert_ne!(page.tabs.last().unwrap().label, "charlie-long"); - let tabs_width: u16 = page.tabs.iter().map(|tab| tab.width).sum(); - assert!(tabs_width <= 22); } #[test] -fn invalid_page_anchor_clamps_to_first_page() { - let events = Rc::new(RefCell::new(Vec::new())); - let mut config = config(vec![tab(1, "one"), tab(2, "two")], &events); - config.page_anchor = Some(99); +fn page_layout_truncates_and_never_points_next_to_itself() { + let mut config = config(vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie-long"), + tab(4, "delta"), + ]); + config.maximum_label_columns = Some(20); + + let first = page(&config, 0, 22); + assert!(first.next_start.is_some()); + assert_ne!(first.tabs.last().unwrap().label, "charlie-long"); + assert!(first.tabs.iter().map(|tab| tab.width).sum::() <= 22); - let layout = tab_bar_layout(&config, 40); - assert_eq!(layout.visible_secondary_keys, vec![1, 2]); + let narrow_last = page(&config, config.tabs.len() - 1, 2); + assert!(narrow_last.tabs.is_empty()); + assert_eq!(narrow_last.next_start, None); } #[test] -fn selected_tab_reveal_preserves_page_until_selection_crosses_boundary() { - let events = Rc::new(RefCell::new(Vec::new())); - let mut config = config( - vec![ - tab(1, "alpha"), - tab(2, "bravo"), - tab(3, "charlie"), - tab(4, "delta"), - ], - &events, - ); - config.reveal_selected = true; - config.selected_key = Some(1); - let first_page = tab_bar_layout(&config, 20); - assert!(first_page.visible_secondary_keys.len() >= 2); +fn selected_reveal_respects_explicit_pages() { + let mut config = config(vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie"), + tab(4, "delta"), + ]); + config.page_anchor = Some(key(3)); + config.selected_key = Some(key(1)); - config.selected_key = Some(2); - let same_page = tab_bar_layout(&config, 20); + let explicit = layout(&config, 20); assert_eq!( - same_page.visible_secondary_keys, first_page.visible_secondary_keys, - "selection within a page must not re-anchor the list" + explicit.navigation.visible_secondary_keys.first(), + Some(&key(3)) ); + assert!(!explicit.navigation.visible_secondary_keys.contains(&key(1))); - let first_hidden_index = page_layout(&config, 0, 20).end; - let first_hidden_key = config.tabs[first_hidden_index].key; - config.selected_key = Some(first_hidden_key); - let next_page = tab_bar_layout(&config, 20); + config.reveal_selected = true; + let revealed = layout(&config, 20); assert_eq!( - next_page.visible_secondary_keys.first(), - Some(&first_hidden_key), - "crossing the boundary reveals the stable next page" + revealed.navigation.visible_secondary_keys.first(), + Some(&key(1)) ); -} -#[test] -fn explicit_page_can_keep_the_selected_tab_off_page() { - let events = Rc::new(RefCell::new(Vec::new())); - let mut config = config( - vec![ - tab(1, "alpha"), - tab(2, "bravo"), - tab(3, "charlie"), - tab(4, "delta"), - ], - &events, + config.selected_key = Some(key(2)); + assert_eq!( + layout(&config, 20).navigation.visible_secondary_keys, + revealed.navigation.visible_secondary_keys ); - config.page_anchor = Some(3); - config.selected_key = Some(1); - config.reveal_selected = false; - - let layout = tab_bar_layout(&config, 20); - assert!(!layout.visible_secondary_keys.contains(&1)); - assert_eq!(layout.visible_secondary_keys.first(), Some(&3)); } #[test] -fn navigation_wraps_when_selected_tab_is_visible() { +fn navigation_wraps_and_enters_the_visible_page() { App::test((), |app| async move { - app.read(|app_ctx| { + app.read(|app| { let events = Rc::new(RefCell::new(Vec::new())); let bar = TuiTabBar::new(); - let mut config = config(vec![tab(2, "two"), tab(3, "three")], &events); - config.main_tab = Some(tab(1, "main")); - config.selected_key = Some(1); let mut presenter = TuiPresenter::new(); - presenter.present_element(bar.render(config), TuiRect::new(0, 0, 40, 1), app_ctx); + let mut wrap_config = config(vec![tab(2, "two"), tab(3, "three")]); + wrap_config.main_tab = Some(tab(1, "main")); + wrap_config.selected_key = Some(key(1)); + presenter.present_element( + render(&bar, wrap_config, &events), + TuiRect::new(0, 0, 40, 1), + app, + ); assert_eq!( bar.navigation_target(TuiTabBarNavigationDirection::Previous), - Some(3) + Some(key(3)) ); assert_eq!( bar.navigation_target(TuiTabBarNavigationDirection::Next), - Some(2) + Some(key(2)) + ); + + let mut page_config = config(vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie"), + tab(4, "delta"), + ]); + page_config.selected_key = Some(key(1)); + page_config.page_anchor = Some(key(3)); + presenter.present_element( + render(&bar, page_config, &events), + TuiRect::new(0, 0, 16, 1), + app, + ); + let visible = bar + .state + .borrow() + .settled_navigation + .as_ref() + .unwrap() + .visible_secondary_keys + .clone(); + assert!(!visible.contains(&key(1))); + assert_eq!( + bar.navigation_target(TuiTabBarNavigationDirection::Previous), + visible.last().cloned() + ); + assert_eq!( + bar.navigation_target(TuiTabBarNavigationDirection::Next), + visible.first().cloned() ); }); }); } #[test] -fn focused_selection_uses_the_caller_supplied_style() { +fn renders_selected_style_and_leading_element_once() { App::test((), |app| async move { - app.read(|app_ctx| { + app.read(|app| { let events = Rc::new(RefCell::new(Vec::new())); let bar = TuiTabBar::new(); - let mut config = config(vec![tab(1, "one"), tab(2, "two")], &events); - config.selected_key = Some(1); + let build_count = Rc::new(Cell::new(0)); + let leading_build_count = build_count.clone(); + let mut config = config(vec![tab(1, "one").with_leading_element(move || { + leading_build_count.set(leading_build_count.get() + 1); + TuiText::new("*") + .with_style(TuiStyle::default().fg(Color::Yellow)) + .finish() + })]); + config.selected_key = Some(key(1)); config.focused = true; - let mut presenter = TuiPresenter::new(); - let frame = - presenter.present_element(bar.render(config), TuiRect::new(0, 0, 20, 1), app_ctx); - let selected = &frame.buffer[(1, 0)]; - assert_eq!(selected.bg, Color::Magenta); - assert!(selected.modifier.contains(Modifier::BOLD)); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render(&bar, config, &events), + TuiRect::new(0, 0, 20, 1), + app, + ); + assert!(frame.buffer.to_lines()[0].contains("* one")); + assert_eq!(frame.buffer[(0, 0)].bg, Color::Magenta); + assert!(frame.buffer[(0, 0)].modifier.contains(Modifier::BOLD)); + assert_eq!(frame.buffer[(1, 0)].fg, Color::Yellow); + assert_eq!(build_count.get(), 1); }); }); } #[test] -fn navigation_uses_visible_boundaries_when_selection_is_off_page() { +fn hover_bolds_tabs_and_overflow_arrows() { App::test((), |app| async move { - app.read(|app_ctx| { + app.read(|app| { let events = Rc::new(RefCell::new(Vec::new())); - let bar = TuiTabBar::new(); - let mut config = config( - vec![ - tab(2, "alpha"), - tab(3, "bravo"), - tab(4, "charlie"), - tab(5, "delta"), - ], - &events, - ); - config.main_tab = Some(tab(1, "main")); - config.selected_key = Some(2); - config.page_anchor = Some(4); let mut presenter = TuiPresenter::new(); - presenter.present_element(bar.render(config), TuiRect::new(0, 0, 24, 1), app_ctx); - let state = bar.state.borrow(); - let visible = &state - .settled_layout - .as_ref() - .unwrap() - .visible_secondary_keys; - assert!(!visible.contains(&2)); - let first = visible.first().copied(); - let last = visible.last().copied(); - drop(state); - assert_eq!( - bar.navigation_target(TuiTabBarNavigationDirection::Previous), - last + let tab_bar = TuiTabBar::new(); + let frame = presenter.present_element( + render(&tab_bar, config(vec![tab(1, "one")]), &events), + TuiRect::new(0, 0, 20, 1), + app, ); - assert_eq!( - bar.navigation_target(TuiTabBarNavigationDirection::Next), - first + assert!(!frame.buffer[(1, 0)].modifier.contains(Modifier::BOLD)); + dispatch_presented_event(&mut presenter, &mouse_moved(1), app); + let frame = presenter.present_element( + render(&tab_bar, config(vec![tab(1, "one")]), &events), + TuiRect::new(0, 0, 20, 1), + app, + ); + assert!(frame.buffer[(1, 0)].modifier.contains(Modifier::BOLD)); + + let overflow_bar = TuiTabBar::new(); + let tabs = || config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]); + let frame = presenter.present_element( + render(&overflow_bar, tabs(), &events), + TuiRect::new(0, 0, 12, 1), + app, + ); + let arrow = frame.buffer.to_lines()[0] + .find('→') + .expect("next overflow is visible") as u16; + assert!(!frame.buffer[(arrow, 0)].modifier.contains(Modifier::BOLD)); + dispatch_presented_event(&mut presenter, &mouse_moved(arrow), app); + let frame = presenter.present_element( + render(&overflow_bar, tabs(), &events), + TuiRect::new(0, 0, 12, 1), + app, ); + assert!(frame.buffer[(arrow, 0)].modifier.contains(Modifier::BOLD)); }); }); } #[test] -fn clicking_tab_emits_only_select_event() { +fn clicks_emit_semantic_events() { App::test((), |app| async move { - app.read(|app_ctx| { + app.read(|app| { let events = Rc::new(RefCell::new(Vec::new())); let bar = TuiTabBar::new(); - let config = config(vec![tab(1, "one"), tab(2, "two")], &events); let mut presenter = TuiPresenter::new(); - let frame = - presenter.present_element(bar.render(config), TuiRect::new(0, 0, 20, 1), app_ctx); - assert!(frame.buffer.to_lines()[0].contains("one")); - assert!(dispatch_presented_event(&mut presenter, &left_mouse_down(2), app_ctx).0); - assert!(dispatch_presented_event(&mut presenter, &left_mouse_up(2), app_ctx).0); - assert_eq!(*events.borrow(), vec![TuiTabBarEvent::SelectTab(1)]); - }); - }); -} - -#[test] -fn clicking_overflow_emits_page_change_without_selection() { - App::test((), |app| async move { - app.read(|app_ctx| { - let events = Rc::new(RefCell::new(Vec::new())); - let bar = TuiTabBar::new(); - let config = config( - vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")], - &events, + presenter.present_element( + render(&bar, config(vec![tab(1, "one"), tab(2, "two")]), &events), + TuiRect::new(0, 0, 20, 1), + app, ); - let mut presenter = TuiPresenter::new(); - let frame = - presenter.present_element(bar.render(config), TuiRect::new(0, 0, 12, 1), app_ctx); - let line = &frame.buffer.to_lines()[0]; - let arrow = line.find('→').expect("next overflow is visible") as u16; + click(&mut presenter, 2, app); + assert_eq!(*events.borrow(), vec![TuiTabBarEvent::SelectTab(key(1))]); - assert!(dispatch_presented_event(&mut presenter, &left_mouse_down(arrow), app_ctx).0); - assert!(dispatch_presented_event(&mut presenter, &left_mouse_up(arrow), app_ctx).0); + events.borrow_mut().clear(); + let frame = presenter.present_element( + render( + &bar, + config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]), + &events, + ), + TuiRect::new(0, 0, 12, 1), + app, + ); + let arrow = frame.buffer.to_lines()[0] + .find('→') + .expect("next overflow is visible") as u16; + click(&mut presenter, arrow, app); assert!(matches!( events.borrow().as_slice(), [TuiTabBarEvent::PageChanged(_)] @@ -306,18 +341,24 @@ fn clicking_overflow_emits_page_change_without_selection() { } #[test] -fn retained_mouse_state_is_reused_and_removed_keys_are_pruned() { +fn retained_mouse_state_is_reused_and_pruned() { let events = Rc::new(RefCell::new(Vec::new())); let bar = TuiTabBar::new(); - let _ = bar.render(config(vec![tab(1, "one"), tab(2, "two")], &events)); - let first_handle = bar.state.borrow().mouse_states.get(&1).cloned().unwrap(); + let _ = render(&bar, config(vec![tab(1, "one"), tab(2, "two")]), &events); + let first_handle = bar + .state + .borrow() + .mouse_states + .get(&key(1)) + .cloned() + .unwrap(); - let _ = bar.render(config(vec![tab(1, "one"), tab(3, "three")], &events)); + let _ = render(&bar, config(vec![tab(1, "one"), tab(3, "three")]), &events); let state = bar.state.borrow(); assert_eq!(state.mouse_states.len(), 2); - assert!(!state.mouse_states.contains_key(&2)); + assert!(!state.mouse_states.contains_key(&key(2))); assert!(Arc::ptr_eq( &first_handle, - state.mouse_states.get(&1).unwrap() + state.mouse_states.get(&key(1)).unwrap() )); } diff --git a/crates/warpui_core/src/elements/tui/text_helpers.rs b/crates/warpui_core/src/elements/tui/text_helpers.rs new file mode 100644 index 00000000000..2d338ec1fb0 --- /dev/null +++ b/crates/warpui_core/src/elements/tui/text_helpers.rs @@ -0,0 +1,34 @@ +//! Terminal-string measurement and truncation helpers shared by TUI elements. + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// Returns terminal display-cell width, saturating at `u16::MAX`. +pub fn text_width(text: &str) -> u16 { + u16::try_from(UnicodeWidthStr::width(text)).unwrap_or(u16::MAX) +} + +/// Truncates terminal text at grapheme boundaries, using as much of `...` as fits. +pub fn truncate_with_ellipsis(text: &str, maximum_columns: usize) -> String { + if usize::from(text_width(text)) <= maximum_columns { + return text.to_owned(); + } + let ellipsis_columns = usize::from(text_width("...")).min(maximum_columns); + let prefix_columns = maximum_columns.saturating_sub(ellipsis_columns); + let mut prefix = String::new(); + let mut prefix_width = 0usize; + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let grapheme_width = usize::from(text_width(grapheme)); + if prefix_width.saturating_add(grapheme_width) > prefix_columns { + break; + } + prefix.push_str(grapheme); + prefix_width = prefix_width.saturating_add(grapheme_width); + } + prefix.push_str(&".".repeat(ellipsis_columns)); + prefix +} + +#[cfg(test)] +#[path = "text_helpers_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/elements/tui/text_helpers_tests.rs b/crates/warpui_core/src/elements/tui/text_helpers_tests.rs new file mode 100644 index 00000000000..c1110980909 --- /dev/null +++ b/crates/warpui_core/src/elements/tui/text_helpers_tests.rs @@ -0,0 +1,10 @@ +use super::{text_width, truncate_with_ellipsis}; + +#[test] +fn truncates_by_display_columns_without_splitting_graphemes() { + assert_eq!(truncate_with_ellipsis("infrastructure", 8), "infra..."); + assert_eq!(truncate_with_ellipsis("abcdef", 2), ".."); + assert_eq!(truncate_with_ellipsis("界界界界", 7), "界界..."); + assert_eq!(truncate_with_ellipsis("e\u{301}clair", 5), "e\u{301}c..."); + assert_eq!(text_width("界界..."), 7); +} diff --git a/specs/code-1822-tui-tab-bar-component/PRODUCT.md b/specs/code-1822-tui-tab-bar-component/PRODUCT.md index 5524da8ef10..8544e5403cf 100644 --- a/specs/code-1822-tui-tab-bar-component/PRODUCT.md +++ b/specs/code-1822-tui-tab-bar-component/PRODUCT.md @@ -26,17 +26,17 @@ The orchestration designs establish the component states; the component itself r 1. A caller can provide: - An optional main tab. - An ordered list of secondary tabs. - - A stable key and label for every tab. - - An optional leading glyph and glyph style for every tab. + - A stable string key and label for every tab. + - An optional caller-rendered leading element for every tab. - The selected tab key, if any. - Whether the bar is focused. - Whether an off-page selected secondary tab should be revealed. - - Focused, unfocused, selected, background, divider, and overflow styles. + - Focused, unfocused, selected, background, leading-label, and chrome styles. - An optional maximum label width in terminal display cells. - The current secondary-page anchor. 2. The component renders exactly one terminal row. It never wraps tabs onto another row. 3. The main tab, when present, is fixed at the leading edge and does not participate in secondary-tab paging. -4. The caller controls any label or divider surrounding the tabs; the component does not hard-code product copy. +4. The caller controls any product label surrounding the tabs. The component uses one consistent divider and previous/next arrows across callers. 5. An empty secondary list is valid. The component renders the supplied main tab and surrounding chrome without overflow controls. ### Ownership @@ -92,3 +92,5 @@ The orchestration designs establish the component states; the component itself r 37. Resolved navigation returns only the target key; the caller decides what selecting that key means. 38. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. 39. Pointer press/release outside a target does not invoke its callback. +40. Hovering a tab bolds its label without changing its selection, page, or focus. +41. Hovering a previous or next overflow control bolds the arrow without changing its behavior. diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md index 147f7558f44..cd1ed4ba3e1 100644 --- a/specs/code-1822-tui-tab-bar-component/TECH.md +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -17,42 +17,41 @@ The component is intentionally domain-neutral. The orchestration integration tha ### Public component contract Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's public types from `crates/warpui_core/src/elements/tui/mod.rs`. -Use a stable generic tab key (`K: Clone + Eq + Hash + 'static`) rather than indices so dynamic reordering cannot retarget callbacks. The public data surface contains: -- `TuiTab`: key, label, and optional leading glyph/style. -- `TuiTabBarStyles`: caller-supplied bar, normal-tab, focused-selected, and unfocused-selected styles. -- `TuiTabBarConfig`: optional main tab, ordered secondary tabs, selected key, focus presentation, page anchor, selected-tab reveal policy, optional maximum label cells, caller-styled fixed/overflow text, and semantic pointer callbacks. +Use a stable string key rather than indices so dynamic reordering cannot retarget callbacks without parameterizing the component and every supporting layout type. The public data surface contains: +- `TuiTab`: string key, label, and an optional factory for caller-rendered leading content. During each layout pass, the component builds the element once, measures it, and moves that same instance into the rendered tab. +- `TuiTabBarStyles`: caller-supplied bar, leading-label, chrome, normal-tab, focused-selected, and unfocused-selected styles. +- `TuiTabBarConfig`: optional product label and main tab, ordered secondary tabs, selected key, focus presentation, page anchor, selected-tab reveal policy, optional maximum label cells, spacing, and styles. Divider and arrow glyphs are component-owned so every caller uses the same row structure. - `TuiTabBarNavigationDirection`: `Previous` or `Next`. -- `TuiTabBar`: the reusable component retained by the caller and updated/rendered from config. +- `TuiTabBar`: the reusable component retained by the caller and updated/rendered from config. The component exposes high-level operations only: -- `render(config) -> Box` -- `navigation_target(direction) -> Option`, which resolves against private settled layout. +- `render(config, on_event) -> Box` +- `navigation_target(direction) -> Option`, which resolves against private settled layout. It does not expose visible indices, visible keys, page boundaries, measured widths, or mouse handles. ### Private retained state -`TuiTabBar` privately retains: -- `HashMap` for currently supplied tabs. +`TuiTabBar` privately retains: +- `HashMap` for currently supplied tabs. - Previous/next overflow mouse handles. -- The latest settled `TabBarLayout`. -- The latest ordered keys and selected key needed to resolve navigation against that layout. +- The latest lightweight `SettledNavigation` containing only ordered keys, explicit main-tab identity, selected key, and visible secondary keys. -Every config update prunes removed keys before rendering. Existing keys reuse their mouse handles. The retained layout is invalidated when ordered tabs, page anchor, maximum width, or settled row width changes. +Every config update prunes removed keys before rendering. Existing keys reuse their mouse handles. Settled navigation is invalidated before the next layout publishes replacement data. -The per-frame element receives an internal shared state reference so `after_layout` can publish the settled `TabBarLayout` back to the component without exposing it to the caller. This handle is private to `tab_bar.rs`; it is not part of the public contract described by PRODUCT (7). +The per-frame element receives an internal shared state reference so `after_layout` can publish settled navigation data back to the component without retaining or exposing the render-only page layout. This handle is private to `tab_bar.rs`; it is not part of the public contract described by PRODUCT (7). ### Layout algorithm -Implement a pure internal layout function that receives the available columns and config-derived tab measurements and returns a `TabBarLayout` containing painted tab slices, truncation widths, overflow visibility, and previous/next anchor keys. +Build and measure caller-provided leading elements through the normal `TuiElement::layout` contract, then pass those widths into a pure layout function. The settled row takes the same measured element instances rather than invoking their factories again. The function returns a `TabBarLayout` with named main-tab, previous-anchor, visible-tab, next-anchor, and navigation fields rather than a generic sequence of layout pieces. The algorithm: -1. Measure fixed caller-supplied leading content, the optional main tab, and divider. +1. Measure fixed caller-supplied leading content, the optional main tab, each tab's caller-rendered leading element, and the component-owned divider. 2. Normalize each label to the optional maximum display-cell width. 3. Resolve the requested page anchor against the ordered secondary keys, clamping a missing anchor. 4. Reserve a previous overflow control when the page does not start at the first secondary tab. 5. Pack secondary tabs from the anchor while reserving a next overflow control whenever later tabs remain. -6. If the final otherwise-visible tab does not fit in full, shrink its label to the remaining display cells while preserving its leading glyph and required next control. +6. If the final otherwise-visible tab does not fit in full, shrink its label to the remaining display cells while preserving its leading content and required next control. 7. Omit a secondary tab rather than produce invalid or negative-width geometry when the row is too narrow. -8. If selected-tab reveal is enabled and the selected secondary tab is outside the settled page, walk the same deterministic page boundaries from the beginning and render the page containing it. +8. Build one deterministic page sequence from the beginning. Use that sequence for previous-page anchors and selected-tab reveal so all page progression shares one strictly advancing rule. Use terminal display-cell width and grapheme-safe truncation. Keep the ellipsis inside the requested width. The component paints from the returned layout rather than independently remeasuring, so rendering, hit testing, overflow callbacks, and navigation share one result. @@ -60,6 +59,7 @@ Use terminal display-cell width and grapheme-safe truncation. Keep the ellipsis Wrap every painted tab and overflow control in `TuiHoverable` with component-owned mouse state: - A tab click invokes `SelectTab(key)`. - A previous/next overflow click invokes `PageChanged(anchor_key)` from the settled layout. +- A hovered tab bolds its label, and a hovered overflow control bolds its arrow. - Neither callback changes focus or application state directly. `navigation_target(Previous | Next)` uses the private settled layout: @@ -71,12 +71,12 @@ The consuming view remains responsible for binding keys, forwarding directions, ## Testing and validation Add `crates/warpui_core/src/elements/tui/tab_bar_tests.rs` using the element render and event-dispatch test harness: -- Optional/absent main tab, empty secondary tabs, and caller-supplied chrome — PRODUCT (1-5). +- Optional/absent main tab, empty secondary tabs, caller-rendered leading elements, and fixed chrome — PRODUCT (1-5). - Private mouse-state reuse and pruning across config changes — PRODUCT (6-11). - Focused/unfocused selected treatments and missing selection — PRODUCT (12-15). - ASCII, wide Unicode, and combining-character measurement; configured and final-tab truncation — PRODUCT (16-22). - Initial, middle, and final pages; anchor clamping; resize; previous/next control visibility; stable selected-tab reveal — PRODUCT (23-32). -- Tab clicks, overflow clicks, hit bounds, cancelled press/release, and focus independence — PRODUCT (33-35, 38-39). +- Tab clicks, overflow clicks, hit bounds, cancelled press/release, focus independence, and hover treatments — PRODUCT (33-35, 38-41). - Visible and off-page previous/next navigation, including complete-order wraparound — PRODUCT (36-37). Run: @@ -89,8 +89,8 @@ Run: Do not split this component across child agents. Its public contract, private retained state, layout result, and event tests are tightly coupled and should land as one coherent PR. Long-running workspace validation can run separately after focused tests pass. ## Risks and mitigations -- **Public geometry leakage:** keep `TabBarLayout` and its shared internal state private to `tab_bar.rs`; expose semantic callbacks only. +- **Public geometry leakage:** keep `TabBarLayout` and `SettledNavigation` private to `tab_bar.rs`; expose semantic callbacks only. - **Layout/event disagreement:** paint and dispatch from one settled layout result. - **Stale navigation after mutation:** invalidate private layout on config changes and resolve callback keys against the latest supplied key set. -- **Unicode width corruption:** centralize display-cell truncation and cover wide/combining cases directly. +- **Unicode width corruption:** keep one grapheme-safe display-cell truncation helper in the TUI text layer and use it from the tab bar and two-column formatter. - **Hover-state churn:** key mouse state by stable tab key and prune only removed keys. From af903e1cfac6569244f4fc5392f1293273751815 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 16:54:10 -0400 Subject: [PATCH 39/40] Update reusable TUI tab bar spec Co-Authored-By: Oz --- specs/code-1822-tui-tab-bar-component/TECH.md | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md index cd1ed4ba3e1..b6dcf9ef738 100644 --- a/specs/code-1822-tui-tab-bar-component/TECH.md +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -9,13 +9,13 @@ The TUI cell-grid library has the layout, styling, retained-geometry, and click - [`crates/warpui_core/src/elements/tui/mod.rs (215-292) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/mod.rs#L215-L292) — `layout` must remain side-effect free, while `after_layout` is the settled post-layout side-effect seam. - [`crates/warpui_core/src/elements/tui/hoverable.rs (40-189) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/hoverable.rs#L40-L189) — `TuiHoverable` requires stable `MouseStateHandle`s across per-frame element reconstruction and limits hit testing to retained painted bounds. - [`crates/warpui_core/src/elements/tui/flex.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/flex.rs) — row composition and child layout. -- [`crates/warpui_core/src/elements/tui/text.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/text.rs) — styled terminal text and existing single-element truncation. +- [`crates/warpui_core/src/elements/tui/text.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/text.rs) — styled terminal text rendering. The component is intentionally domain-neutral. The orchestration integration that first consumes it is specified separately in `specs/code-1822-tui-orchestration-tab-bar/`. ## Proposed changes ### Public component contract -Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's public types from `crates/warpui_core/src/elements/tui/mod.rs`. +Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's public types from `crates/warpui_core/src/elements/tui/mod.rs`. Add `crates/warpui_core/src/elements/tui/text_helpers.rs` for shared terminal display-cell measurement and grapheme-safe ellipsis truncation used by the tab bar and existing TUI column formatting. Use a stable string key rather than indices so dynamic reordering cannot retarget callbacks without parameterizing the component and every supporting layout type. The public data surface contains: - `TuiTab`: string key, label, and an optional factory for caller-rendered leading content. During each layout pass, the component builds the element once, measures it, and moves that same instance into the rendered tab. @@ -70,20 +70,24 @@ Wrap every painted tab and overflow control in `TuiHoverable` with component-own The consuming view remains responsible for binding keys, forwarding directions, and applying the returned semantic target. This keeps keymap and application-selection policy outside the component while keeping width-dependent target resolution inside it. ## Testing and validation -Add `crates/warpui_core/src/elements/tui/tab_bar_tests.rs` using the element render and event-dispatch test harness: -- Optional/absent main tab, empty secondary tabs, caller-rendered leading elements, and fixed chrome — PRODUCT (1-5). -- Private mouse-state reuse and pruning across config changes — PRODUCT (6-11). -- Focused/unfocused selected treatments and missing selection — PRODUCT (12-15). -- ASCII, wide Unicode, and combining-character measurement; configured and final-tab truncation — PRODUCT (16-22). -- Initial, middle, and final pages; anchor clamping; resize; previous/next control visibility; stable selected-tab reveal — PRODUCT (23-32). -- Tab clicks, overflow clicks, hit bounds, cancelled press/release, focus independence, and hover treatments — PRODUCT (33-35, 38-41). -- Visible and off-page previous/next navigation, including complete-order wraparound — PRODUCT (36-37). +Add focused, scenario-based unit coverage: +- fixed-main layout, explicit/missing page anchors, and complete navigation order; +- overflow reservation, final-tab truncation, and strict next-page progress at narrow widths; +- explicit page ownership and selected-tab reveal stability; +- visible wraparound and off-page keyboard navigation without a main tab; +- selected styling and one-build-per-layout caller leading elements; +- tab and overflow hover treatments; +- semantic tab-selection and page-change click events; +- retained mouse-state reuse and removed-key pruning; and +- shared display-cell measurement and grapheme-safe truncation in `text_helpers_tests.rs`. Run: -- `cargo nextest run -p warpui_core --features tui tab_bar` - `cargo test -p warpui_core --features tui tab_bar` +- `cargo test -p warpui_core --features tui text_helpers` +- `cargo test -p warp_tui tui_column_layout` - `./script/format` -- The repository-prescribed Clippy command before submitting the branch. +- `cargo clippy -p warpui_core --features tui --tests -- -D warnings` +- `cargo clippy -p warp_tui --tests -- -D warnings` ## Parallelization Do not split this component across child agents. Its public contract, private retained state, layout result, and event tests are tightly coupled and should land as one coherent PR. Long-running workspace validation can run separately after focused tests pass. @@ -92,5 +96,5 @@ Do not split this component across child agents. Its public contract, private re - **Public geometry leakage:** keep `TabBarLayout` and `SettledNavigation` private to `tab_bar.rs`; expose semantic callbacks only. - **Layout/event disagreement:** paint and dispatch from one settled layout result. - **Stale navigation after mutation:** invalidate private layout on config changes and resolve callback keys against the latest supplied key set. -- **Unicode width corruption:** keep one grapheme-safe display-cell truncation helper in the TUI text layer and use it from the tab bar and two-column formatter. +- **Unicode width corruption:** keep one grapheme-safe display-cell truncation helper in `text_helpers.rs` and use it from the tab bar and two-column formatter. - **Hover-state churn:** key mouse state by stable tab key and prune only removed keys. From 00f818b4fbb17aa578b99f057ed0d36bcc82b6fb Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 22:32:57 -0400 Subject: [PATCH 40/40] rebuild tab bar from generic elements --- crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/tab_bar.rs | 590 ++++++++++++ crates/warp_tui/src/tab_bar_tests.rs | 205 +++++ crates/warpui_core/src/elements/tui/flex.rs | 66 +- .../src/elements/tui/flex_tests.rs | 16 + crates/warpui_core/src/elements/tui/mod.rs | 7 +- .../elements/tui/size_constraint_switch.rs | 126 +++ .../tui/size_constraint_switch_tests.rs | 54 ++ .../warpui_core/src/elements/tui/tab_bar.rs | 869 ------------------ .../src/elements/tui/tab_bar_tests.rs | 364 -------- crates/warpui_core/src/elements/tui/text.rs | 115 ++- .../src/elements/tui/text_tests.rs | 34 + .../PRODUCT.md | 104 +-- specs/code-1822-tui-tab-bar-component/TECH.md | 161 ++-- 14 files changed, 1315 insertions(+), 1397 deletions(-) create mode 100644 crates/warp_tui/src/tab_bar.rs create mode 100644 crates/warp_tui/src/tab_bar_tests.rs create mode 100644 crates/warpui_core/src/elements/tui/size_constraint_switch.rs create mode 100644 crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs delete mode 100644 crates/warpui_core/src/elements/tui/tab_bar.rs delete mode 100644 crates/warpui_core/src/elements/tui/tab_bar_tests.rs diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 8868506c927..9bf423094b5 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -40,6 +40,7 @@ mod resume; mod session_registry; mod skills_menu; mod slash_commands; +pub mod tab_bar; mod terminal_background; mod terminal_block; mod terminal_content_element; diff --git a/crates/warp_tui/src/tab_bar.rs b/crates/warp_tui/src/tab_bar.rs new file mode 100644 index 00000000000..ee42deb91d7 --- /dev/null +++ b/crates/warp_tui/src/tab_bar.rs @@ -0,0 +1,590 @@ +//! Responsive horizontal tabs composed by a retained [`TuiView`]. +//! +//! Width-dependent composition uses [`TuiSizeConstraintSwitch`] to select +//! between rows built from generic flex, text, container, and hoverable +//! elements. +//! The view retains stable mouse handles; callers retain semantic selection, +//! focus, and page-anchor state. + +use std::collections::{HashMap, HashSet}; + +use warpui_core::elements::tui::{ + text_width, Modifier, TuiConstrainedBox, TuiContainer, TuiElement, TuiFlex, TuiHoverable, + TuiParentElement, TuiSizeConstraintCondition, TuiSizeConstraintSwitch, TuiStyle, TuiText, +}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{AppContext, Entity, TuiView, TypedActionView, ViewContext}; +const DIVIDER: &str = "|"; +const DIVIDER_PADDING_LEFT: u16 = 1; +const DIVIDER_PADDING_RIGHT: u16 = 2; + +/// Stable tab data rendered by [`TuiTabBarView`]. +#[derive(Clone)] +pub struct TuiTab { + pub key: String, + pub label: String, + leading: Option, +} + +#[derive(Clone)] +struct TuiTabLeading { + text: String, + style: TuiStyle, +} + +impl TuiTab { + /// Creates a tab with stable identity and no leading text. + pub fn new(key: impl Into, label: impl Into) -> Self { + Self { + key: key.into(), + label: label.into(), + leading: None, + } + } + + /// Adds styled text rendered immediately before the tab label. + pub fn with_leading_text(mut self, text: impl Into, style: TuiStyle) -> Self { + self.leading = Some(TuiTabLeading { + text: text.into(), + style, + }); + self + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct TuiTabBarStyles { + pub bar: TuiStyle, + pub leading: TuiStyle, + pub chrome: TuiStyle, + pub tab: TuiStyle, + pub selected_focused: TuiStyle, + pub selected_unfocused: TuiStyle, +} + +#[derive(Clone)] +pub struct TuiTabBarConfig { + pub leading: Option, + pub main_tab: Option, + pub tabs: Vec, + pub selected_key: Option, + pub focused: bool, + pub page_anchor: Option, + pub reveal_selected: bool, + pub maximum_label_columns: Option, + pub tab_padding_columns: u16, + pub secondary_gap_columns: u16, + pub styles: TuiTabBarStyles, +} + +impl TuiTabBarConfig { + /// Creates a neutral configuration for the supplied secondary tabs. + pub fn new(tabs: Vec) -> Self { + Self { + leading: None, + main_tab: None, + tabs, + selected_key: None, + focused: false, + page_anchor: None, + reveal_selected: false, + maximum_label_columns: None, + tab_padding_columns: 1, + secondary_gap_columns: 1, + styles: TuiTabBarStyles::default(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TuiTabBarEvent { + SelectTab(String), + PageChanged(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiTabBarNavigationDirection { + Previous, + Next, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiTabBarSecondaryEdge { + First, + Last, +} + +#[derive(Clone, Debug)] +#[doc(hidden)] +pub enum TuiTabBarAction { + SelectTab(String), + PageChanged(String), +} + +/// Retained responsive tab-bar view. +pub struct TuiTabBarView { + config: TuiTabBarConfig, + mouse_states: HashMap, + previous_overflow_mouse_state: MouseStateHandle, + next_overflow_mouse_state: MouseStateHandle, +} + +impl TuiTabBarView { + /// Creates a retained view and initializes mouse state for every tab key. + pub fn new(config: TuiTabBarConfig) -> Self { + let mut view = Self { + config, + mouse_states: HashMap::new(), + previous_overflow_mouse_state: MouseStateHandle::default(), + next_overflow_mouse_state: MouseStateHandle::default(), + }; + view.reconcile_mouse_states(); + view + } + + /// Replaces caller-owned semantic inputs while preserving mouse state for live keys. + pub fn set_config(&mut self, config: TuiTabBarConfig, ctx: &mut ViewContext) { + self.config = config; + self.reconcile_mouse_states(); + ctx.notify(); + } + + /// Reuses mouse handles for live keys and drops handles for removed tabs. + fn reconcile_mouse_states(&mut self) { + let live_keys = self + .config + .main_tab + .iter() + .chain(self.config.tabs.iter()) + .map(|tab| tab.key.clone()) + .collect::>(); + self.mouse_states.retain(|key, _| live_keys.contains(key)); + for key in live_keys { + self.mouse_states.entry(key).or_default(); + } + } + + /// Resolves the adjacent tab in semantic order, wrapping at either end. + pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { + let order = self + .config + .main_tab + .iter() + .chain(self.config.tabs.iter()) + .map(|tab| &tab.key) + .collect::>(); + let selected = self.config.selected_key.as_ref()?; + let selected_index = order.iter().position(|key| *key == selected)?; + let target_index = match direction { + TuiTabBarNavigationDirection::Previous => { + selected_index.checked_sub(1).unwrap_or(order.len() - 1) + } + TuiTabBarNavigationDirection::Next => (selected_index + 1) % order.len(), + }; + order.get(target_index).map(|key| (*key).clone()) + } + + /// Returns the stable key at one edge of the secondary-tab collection. + pub fn secondary_edge_target(&self, edge: TuiTabBarSecondaryEdge) -> Option { + match edge { + TuiTabBarSecondaryEdge::First => self.config.tabs.first(), + TuiTabBarSecondaryEdge::Last => self.config.tabs.last(), + } + .map(|tab| tab.key.clone()) + } +} + +impl Entity for TuiTabBarView { + type Event = TuiTabBarEvent; +} + +impl TuiView for TuiTabBarView { + fn ui_name() -> &'static str { + "TuiTabBarView" + } + + fn render(&self, _app: &AppContext) -> Box { + render_tab_bar( + &self.config, + &self.mouse_states, + &self.previous_overflow_mouse_state, + &self.next_overflow_mouse_state, + ) + } +} + +impl TypedActionView for TuiTabBarView { + type Action = TuiTabBarAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiTabBarAction::SelectTab(key) => { + ctx.emit(TuiTabBarEvent::SelectTab(key.clone())); + } + TuiTabBarAction::PageChanged(anchor) => { + ctx.emit(TuiTabBarEvent::PageChanged(anchor.clone())); + } + } + } +} + +/// Builds precomposed page alternatives and selects between them during layout. +fn render_tab_bar( + config: &TuiTabBarConfig, + mouse_states: &HashMap, + previous_overflow_mouse_state: &MouseStateHandle, + next_overflow_mouse_state: &MouseStateHandle, +) -> Box { + let start = effective_page_start(config); + let (default_count, transitions) = visible_count_transitions(config, start); + let conditional_children = transitions + .into_iter() + .map(|(width, visible_count)| { + ( + TuiSizeConstraintCondition::WidthLessThan(width), + render_page( + config, + start, + visible_count, + mouse_states, + previous_overflow_mouse_state, + next_overflow_mouse_state, + ), + ) + }) + .collect::>(); + TuiSizeConstraintSwitch::new( + render_page( + config, + start, + default_count, + mouse_states, + previous_overflow_mouse_state, + next_overflow_mouse_state, + ), + conditional_children, + ) + .finish() +} + +/// Composes one page alternative from generic TUI elements. +fn render_page( + config: &TuiTabBarConfig, + start: usize, + visible_count: usize, + mouse_states: &HashMap, + previous_overflow_mouse_state: &MouseStateHandle, + next_overflow_mouse_state: &MouseStateHandle, +) -> Box { + let mut row = TuiFlex::row(); + if let Some(leading) = &config.leading { + row.add_child( + TuiText::new(leading) + .with_style(config.styles.leading) + .truncate() + .finish(), + ); + } + + if let Some(main_tab) = &config.main_tab { + row.add_child(render_tab(config, main_tab, false, mouse_states)); + if !config.tabs.is_empty() { + row.add_child(render_divider(config.styles.chrome)); + } + } + let visible_end = start.saturating_add(visible_count).min(config.tabs.len()); + let has_previous = start > 0; + let has_next = visible_end < config.tabs.len(); + let mut secondary = TuiFlex::row().with_spacing(config.secondary_gap_columns); + if has_previous { + let previous_start = start.saturating_sub(visible_count.max(1)); + secondary.add_child(render_overflow( + "←", + config.tabs[previous_start].key.clone(), + previous_overflow_mouse_state.clone(), + config.styles.chrome, + )); + } + for (visible_index, tab) in config.tabs[start..visible_end].iter().enumerate() { + let is_last_visible = visible_index + 1 == visible_count; + let tab = render_tab(config, tab, is_last_visible, mouse_states); + if is_last_visible { + secondary = secondary.flex_child( + TuiConstrainedBox::new(tab) + .with_max_cols(natural_tab_width( + &config.tabs[start + visible_index], + config, + )) + .finish(), + ); + } else { + secondary.add_child(tab); + } + } + if has_next { + secondary.add_child(render_overflow( + "→", + config.tabs[visible_end].key.clone(), + next_overflow_mouse_state.clone(), + config.styles.chrome, + )); + } + row = row.flex_child(secondary.finish()); + + let content = row.finish(); + let content = match config.styles.bar.bg { + Some(background) => TuiContainer::new(content) + .with_background(background) + .finish(), + None => content, + }; + TuiFlex::row().flex_child(content).finish() +} + +/// Renders one styled, hoverable tab that dispatches selection by stable key. +fn render_tab( + config: &TuiTabBarConfig, + tab: &TuiTab, + flexible_label: bool, + mouse_states: &HashMap, +) -> Box { + let state = mouse_states + .get(&tab.key) + .cloned() + .expect("tab mouse state is reconciled before render"); + + let tab_style = if config.selected_key.as_deref() != Some(&tab.key) { + config.styles.tab + } else if config.focused { + config.styles.selected_focused + } else { + config.styles.selected_unfocused + }; + + let label_style = if state.lock().unwrap().is_hovered() { + tab_style.add_modifier(Modifier::BOLD) + } else { + tab_style + }; + + let leading_and_label_are_present = tab.leading.is_some() && !tab.label.is_empty(); + let mut content = TuiFlex::row().with_spacing(u16::from(leading_and_label_are_present)); + + if let Some(leading) = &tab.leading { + content.add_child( + TuiText::new(leading.text.clone()) + .with_style(leading.style) + .truncate() + .finish(), + ); + } + + let label = TuiConstrainedBox::new( + TuiText::new(tab.label.clone()) + .with_style(label_style) + .truncate_with_ellipsis() + .finish(), + ) + .with_max_cols(configured_label_width(tab, config)) + .finish(); + if flexible_label { + content = content.flex_child(label); + } else { + content.add_child(label); + } + + let mut container = + TuiContainer::new(content.finish()).with_padding_x(config.tab_padding_columns); + if let Some(background) = tab_style.bg { + container = container.with_background(background); + } + let key = tab.key.clone(); + + TuiHoverable::new(state, container.finish()) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiTabBarAction::SelectTab(key.clone())); + }) + .finish() +} + +/// Renders a hoverable paging arrow that dispatches its destination anchor. +fn render_overflow( + text: &'static str, + anchor: String, + state: MouseStateHandle, + style: TuiStyle, +) -> Box { + let style = if state.lock().unwrap().is_hovered() { + style.add_modifier(Modifier::BOLD) + } else { + style + }; + TuiHoverable::new( + state, + TuiText::new(text).with_style(style).truncate().finish(), + ) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiTabBarAction::PageChanged(anchor.clone())); + }) + .finish() +} + +/// Renders the fixed divider with layout padding rather than embedded spaces. +fn render_divider(style: TuiStyle) -> Box { + TuiContainer::new(TuiText::new(DIVIDER).with_style(style).truncate().finish()) + .with_padding_left(DIVIDER_PADDING_LEFT) + .with_padding_right(DIVIDER_PADDING_RIGHT) + .finish() +} + +/// Resolves the caller's page anchor, optionally anchoring directly to the +/// selected secondary tab when reveal is requested. +fn effective_page_start(config: &TuiTabBarConfig) -> usize { + if config.tabs.is_empty() { + return 0; + } + let requested_start = config + .page_anchor + .as_ref() + .and_then(|anchor| config.tabs.iter().position(|tab| &tab.key == anchor)) + .unwrap_or_default(); + if config.reveal_selected { + return config + .selected_key + .as_ref() + .and_then(|selected| config.tabs.iter().position(|tab| &tab.key == selected)) + .unwrap_or(requested_start); + } + requested_start +} + +/// Returns the default visible count and each narrower-width alternative. +/// +/// A transition is added only when another tab becomes renderable. The switch +/// therefore has one alternative per distinct visible count rather than one +/// child per terminal column. +fn visible_count_transitions(config: &TuiTabBarConfig, start: usize) -> (usize, Vec<(u16, usize)>) { + let remaining = config.tabs.len().saturating_sub(start); + let minimum_widths = (1..=remaining) + .map(|count| (minimum_row_width(config, start, count), count)) + .collect::>(); + let mut boundaries = minimum_widths + .iter() + .map(|(width, _)| *width) + .collect::>(); + boundaries.sort_unstable(); + boundaries.dedup(); + + let visible_at = |width| { + minimum_widths + .iter() + .filter(|(minimum_width, _)| *minimum_width <= width) + .map(|(_, count)| *count) + .max() + .unwrap_or_default() + }; + let mut visible_count = visible_at(0); + let mut transitions = Vec::new(); + for boundary in boundaries { + let next_visible_count = visible_at(boundary); + if next_visible_count == visible_count { + continue; + } + if boundary > 0 { + transitions.push((boundary, visible_count)); + } + visible_count = next_visible_count; + } + (visible_count, transitions) +} + +/// Computes the minimum total row width that can display `visible_count` tabs. +/// +/// All but the final visible tab reserve their natural widths. The final tab +/// reserves only its fixed chrome plus one label cell; generic text ellipsis +/// consumes any additional width assigned by flex. +fn minimum_row_width(config: &TuiTabBarConfig, start: usize, visible_count: usize) -> u16 { + let visible_end = start.saturating_add(visible_count).min(config.tabs.len()); + let has_previous = start > 0; + let has_next = visible_end < config.tabs.len(); + let mut width = fixed_prefix_width(config); + if has_previous { + width = width.saturating_add(text_width("←")); + if visible_count > 0 || has_next { + width = width.saturating_add(config.secondary_gap_columns); + } + } + for (index, tab) in config.tabs[start..visible_end].iter().enumerate() { + if index > 0 { + width = width.saturating_add(config.secondary_gap_columns); + } + width = width.saturating_add(if index + 1 == visible_count { + minimum_tab_width(tab, config) + } else { + natural_tab_width(tab, config) + }); + } + if has_next { + if visible_count > 0 { + width = width.saturating_add(config.secondary_gap_columns); + } + width = width.saturating_add(text_width("→")); + } + width +} + +/// Width occupied before pageable tabs and overflow controls are added. +fn fixed_prefix_width(config: &TuiTabBarConfig) -> u16 { + let mut width = config + .leading + .as_deref() + .map(text_width) + .unwrap_or_default(); + if let Some(main_tab) = &config.main_tab { + width = width.saturating_add(natural_tab_width(main_tab, config)); + if !config.tabs.is_empty() { + width = width + .saturating_add(text_width(DIVIDER)) + .saturating_add(DIVIDER_PADDING_LEFT) + .saturating_add(DIVIDER_PADDING_RIGHT); + } + } + width +} + +/// Maximum display-cell width assigned to a tab label. +fn configured_label_width(tab: &TuiTab, config: &TuiTabBarConfig) -> u16 { + config + .maximum_label_columns + .unwrap_or_else(|| text_width(&tab.label)) + .min(text_width(&tab.label)) +} + +/// Natural width of a tab after applying its configured label cap. +fn natural_tab_width(tab: &TuiTab, config: &TuiTabBarConfig) -> u16 { + tab_fixed_columns(tab, config.tab_padding_columns) + .saturating_add(configured_label_width(tab, config)) +} + +/// Minimum width that preserves fixed tab content and one label cell. +fn minimum_tab_width(tab: &TuiTab, config: &TuiTabBarConfig) -> u16 { + tab_fixed_columns(tab, config.tab_padding_columns) + .saturating_add(u16::from(!tab.label.is_empty())) +} + +/// Counts non-label cells: horizontal padding, leading text, and its separator. +fn tab_fixed_columns(tab: &TuiTab, padding_columns: u16) -> u16 { + let leading_columns = tab + .leading + .as_ref() + .map(|leading| text_width(&leading.text)) + .unwrap_or_default(); + padding_columns + .saturating_mul(2) + .saturating_add(leading_columns) + .saturating_add(u16::from(tab.leading.is_some() && !tab.label.is_empty())) +} + +#[cfg(test)] +#[path = "tab_bar_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/tab_bar_tests.rs b/crates/warp_tui/src/tab_bar_tests.rs new file mode 100644 index 00000000000..aab956821b5 --- /dev/null +++ b/crates/warp_tui/src/tab_bar_tests.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use warpui_core::elements::tui::{Color, Modifier, TuiBufferExt, TuiRect, TuiStyle}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, AppContext, TuiView}; + +use super::{ + effective_page_start, minimum_row_width, visible_count_transitions, TuiTab, TuiTabBarConfig, + TuiTabBarNavigationDirection, TuiTabBarSecondaryEdge, TuiTabBarStyles, TuiTabBarView, +}; + +fn key(key: u8) -> String { + key.to_string() +} + +fn tab(key: u8, label: &str) -> TuiTab { + TuiTab::new(key.to_string(), label) +} + +fn config(tabs: Vec) -> TuiTabBarConfig { + let mut config = TuiTabBarConfig::new(tabs); + config.styles = TuiTabBarStyles { + bar: TuiStyle::default().bg(Color::Black), + leading: TuiStyle::default().fg(Color::White), + chrome: TuiStyle::default().fg(Color::White), + tab: TuiStyle::default().fg(Color::White), + selected_focused: TuiStyle::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + selected_unfocused: TuiStyle::default().add_modifier(Modifier::BOLD), + }; + config +} + +fn render( + view: &TuiTabBarView, + width: u16, + app: &AppContext, +) -> warpui_core::presenter::tui::TuiFrame { + TuiPresenter::new().present_element(view.render(app), TuiRect::new(0, 0, width, 1), app) +} + +#[test] +fn page_anchor_and_selected_reveal_choose_the_first_visible_tab() { + let mut config = config(vec![tab(2, "alpha"), tab(3, "bravo"), tab(4, "charlie")]); + config.page_anchor = Some(key(3)); + assert_eq!(effective_page_start(&config), 1); + + config.selected_key = Some(key(2)); + config.reveal_selected = true; + assert_eq!(effective_page_start(&config), 0); +} + +#[test] +fn width_transitions_strictly_increase_the_visible_count() { + let config = config(vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie-long"), + ]); + let (default_count, transitions) = visible_count_transitions(&config, 0); + + assert_eq!(default_count, config.tabs.len()); + assert!(transitions + .windows(2) + .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1)); +} + +#[test] +fn narrow_page_ellipsizes_its_final_tab_and_preserves_next_control() { + App::test((), |app| async move { + app.read(|app| { + let config = config(vec![ + tab(1, "infrastructure"), + tab(2, "bravo"), + tab(3, "charlie"), + ]); + let width = minimum_row_width(&config, 0, 1).saturating_add(2); + let line = render(&TuiTabBarView::new(config), width, app) + .buffer + .to_lines() + .remove(0); + + assert!(line.contains("...")); + assert!(line.contains('→')); + assert!(!line.contains("bravo")); + }); + }); +} + +#[test] +fn explicit_page_anchor_hides_earlier_tabs_and_shows_previous_control() { + App::test((), |app| async move { + app.read(|app| { + let mut config = config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]); + config.page_anchor = Some(key(2)); + let line = render(&TuiTabBarView::new(config), 40, app) + .buffer + .to_lines() + .remove(0); + + assert!(line.contains('←')); + assert!(!line.contains("alpha")); + assert!(line.contains("bravo")); + }); + }); +} + +#[test] +fn overflow_controls_match_the_page_edges() { + App::test((), |app| async move { + app.read(|app| { + let tabs = || config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]); + + let start_config = tabs(); + let start_width = minimum_row_width(&start_config, 0, 1); + let start = render(&TuiTabBarView::new(start_config), start_width, app) + .buffer + .to_lines() + .remove(0); + assert!(!start.contains('←')); + assert!(start.contains('→')); + + let mut middle_config = tabs(); + middle_config.page_anchor = Some(key(2)); + let middle_width = minimum_row_width(&middle_config, 1, 1); + let middle = render(&TuiTabBarView::new(middle_config), middle_width, app) + .buffer + .to_lines() + .remove(0); + assert!(middle.contains('←')); + assert!(middle.contains('→')); + + let mut end_config = tabs(); + end_config.page_anchor = Some(key(3)); + let end = render(&TuiTabBarView::new(end_config), 40, app) + .buffer + .to_lines() + .remove(0); + assert!(end.contains('←')); + assert!(!end.contains('→')); + }); + }); +} + +#[test] +fn view_navigation_uses_semantic_order() { + let mut config = config(vec![tab(2, "two"), tab(3, "three")]); + config.main_tab = Some(tab(1, "main")); + config.selected_key = Some(key(1)); + let view = TuiTabBarView::new(config); + + assert_eq!( + view.navigation_target(TuiTabBarNavigationDirection::Previous), + Some(key(3)) + ); + assert_eq!( + view.navigation_target(TuiTabBarNavigationDirection::Next), + Some(key(2)) + ); + assert_eq!( + view.secondary_edge_target(TuiTabBarSecondaryEdge::First), + Some(key(2)) + ); + assert_eq!( + view.secondary_edge_target(TuiTabBarSecondaryEdge::Last), + Some(key(3)) + ); +} + +#[test] +fn render_composes_selected_and_leading_styles() { + App::test((), |app| async move { + app.read(|app| { + let mut config = config(vec![ + tab(1, "one").with_leading_text("*", TuiStyle::default().fg(Color::Yellow)) + ]); + config.selected_key = Some(key(1)); + config.focused = true; + let view = TuiTabBarView::new(config); + let frame = render(&view, 20, app); + + assert!(frame.buffer.to_lines()[0].contains("* one")); + assert_eq!(frame.buffer[(0, 0)].bg, Color::Magenta); + assert_eq!(frame.buffer[(1, 0)].fg, Color::Yellow); + assert!(frame.buffer[(3, 0)].modifier.contains(Modifier::BOLD)); + }); + }); +} + +#[test] +fn config_updates_reuse_live_mouse_state_and_prune_removed_tabs() { + let mut view = TuiTabBarView::new(config(vec![tab(1, "one"), tab(2, "two")])); + let first_handle = view.mouse_states.get(&key(1)).cloned().unwrap(); + view.config = config(vec![tab(1, "one"), tab(3, "three")]); + view.reconcile_mouse_states(); + + assert_eq!(view.mouse_states.len(), 2); + assert!(!view.mouse_states.contains_key(&key(2))); + assert!(Arc::ptr_eq( + &first_handle, + view.mouse_states.get(&key(1)).unwrap() + )); +} diff --git a/crates/warpui_core/src/elements/tui/flex.rs b/crates/warpui_core/src/elements/tui/flex.rs index e5fc69d089c..c7b0574ba3d 100644 --- a/crates/warpui_core/src/elements/tui/flex.rs +++ b/crates/warpui_core/src/elements/tui/flex.rs @@ -7,6 +7,9 @@ //! an explicit [`Axis`]) and append boxed children (see [`TuiElement::finish`]) //! with [`child`](TuiFlex::child) (fixed main-axis extent) or //! [`flex_child`](TuiFlex::flex_child) (fills leftover main-axis extent). The +//! [`with_spacing`](TuiFlex::with_spacing) builder inserts a fixed number of +//! blank cells between adjacent children. +//! The //! [`TuiParentElement`](super::TuiParentElement) trait's `with_child` / //! `with_children` / `add_child` / `add_children` also work and add fixed //! children. @@ -26,9 +29,9 @@ //! offered cross extent (e.g. a full-width background banner). //! //! Along the main axis, each fixed child is laid out against the remaining -//! extent (loose) and takes its natural size; children are packed without gaps -//! from the start, and children that fall past the available extent are -//! clipped. +//! extent (loose) and takes its natural size; children are packed from the +//! start with the configured spacing, and children that fall past the +//! available extent are clipped. //! //! A child added with [`flex_child`](TuiFlex::flex_child) instead *fills* the //! main-axis extent left over after the fixed children, so content can be @@ -59,6 +62,8 @@ pub struct TuiFlex { /// Where children land along the cross axis (see /// [`with_cross_axis_alignment`](Self::with_cross_axis_alignment)). cross_axis_alignment: CrossAxisAlignment, + /// Blank cells inserted between adjacent children along the main axis. + spacing: u16, /// Sizes returned by each child's `layout()` call; populated during layout /// so `render`, `cursor_position`, and `dispatch_event` have consistent slot /// information. @@ -73,6 +78,7 @@ impl TuiFlex { axis, children: Vec::new(), cross_axis_alignment: CrossAxisAlignment::Start, + spacing: 0, child_sizes: Vec::new(), size: None, origin: None, @@ -99,6 +105,12 @@ impl TuiFlex { self } + /// Inserts `spacing` blank cells between adjacent children. + pub fn with_spacing(mut self, spacing: u16) -> Self { + self.spacing = spacing; + self + } + /// Appends a child (boxed via [`TuiElement::finish`]) that fills the /// main-axis extent left over after the fixed children (shared evenly when /// there are several flex children). @@ -161,15 +173,23 @@ impl TuiFlex { axis: Axis, area: TuiRect, child_sizes: &[TuiSize], + spacing: u16, ) -> impl Iterator + '_ { - child_sizes.iter().scan(area, move |remaining, size| { - if remaining.is_empty() { - return None; - } - let (slot, rest) = Self::split_main(axis, *remaining, Self::main_extent(axis, *size)); - *remaining = rest; - Some(slot) - }) + child_sizes + .iter() + .enumerate() + .scan(area, move |remaining, (index, size)| { + if remaining.is_empty() { + return None; + } + let (slot, mut rest) = + Self::split_main(axis, *remaining, Self::main_extent(axis, *size)); + if index + 1 < child_sizes.len() { + (_, rest) = Self::split_main(axis, rest, spacing); + } + *remaining = rest; + Some(slot) + }) } /// Clamps a main-axis extent into the constraint's main-axis bounds. @@ -285,7 +305,10 @@ impl TuiElement for TuiFlex { // No flex children: give each child the remaining main-axis extent // (loose) and sum the actual extents. let mut total_main: u16 = 0; - for child in &mut self.children { + for (index, child) in self.children.iter_mut().enumerate() { + if index > 0 { + total_main = total_main.saturating_add(self.spacing); + } let remaining = offered_main.saturating_sub(total_main); let child_constraint = TuiConstraint::new( Self::size_of(axis, 0, cross_min), @@ -308,7 +331,9 @@ impl TuiElement for TuiFlex { // Flex children: two passes. // Pass 1 — lay out fixed children to measure their total main-axis extent. let mut fixed_sizes: Vec> = Vec::with_capacity(self.children.len()); - let mut total_fixed: u16 = 0; + let mut total_fixed = self + .spacing + .saturating_mul(self.children.len().saturating_sub(1) as u16); for child in &mut self.children { if child.flex { fixed_sizes.push(None); @@ -378,11 +403,16 @@ impl TuiElement for TuiFlex { let area = TuiRect::new(0, 0, size.width, size.height); let axis = self.axis; let alignment = self.cross_axis_alignment; - for ((child, size), slot) in self - .children - .iter_mut() - .zip(&self.child_sizes) - .zip(Self::child_slots(axis, area, &self.child_sizes)) + for ((child, size), slot) in + self.children + .iter_mut() + .zip(&self.child_sizes) + .zip(Self::child_slots( + axis, + area, + &self.child_sizes, + self.spacing, + )) { let rect = Self::child_rect_for(axis, alignment, slot, *size); child.element.render( diff --git a/crates/warpui_core/src/elements/tui/flex_tests.rs b/crates/warpui_core/src/elements/tui/flex_tests.rs index f37672ce62d..b0979eb2048 100644 --- a/crates/warpui_core/src/elements/tui/flex_tests.rs +++ b/crates/warpui_core/src/elements/tui/flex_tests.rs @@ -25,6 +25,22 @@ fn layout_at(element: &mut dyn TuiElement, size: TuiSize, app_ctx: &crate::AppCo element.layout(TuiConstraint::loose(size), &mut ctx, app_ctx) } +#[test] +fn row_inserts_configured_spacing_between_children() { + App::test((), |app| async move { + app.read(|app_ctx| { + let mut row = TuiFlex::row() + .with_spacing(2) + .child(TuiText::new("AA").truncate().finish()) + .child(TuiText::new("BB").truncate().finish()); + + let size = layout_at(&mut row, TuiSize::new(10, 1), app_ctx); + assert_eq!(size, TuiSize::new(6, 1)); + assert_eq!(render_to_lines(row, TuiSize::new(6, 1)), vec!["AA BB"]); + }); + }); +} + // -- column-axis tests -- #[test] diff --git a/crates/warpui_core/src/elements/tui/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index dd40ac90abc..226e1d437d8 100644 --- a/crates/warpui_core/src/elements/tui/mod.rs +++ b/crates/warpui_core/src/elements/tui/mod.rs @@ -45,7 +45,7 @@ mod scene; mod scrollable; mod selectable; mod shimmering_text; -mod tab_bar; +mod size_constraint_switch; mod text; mod text_helpers; mod viewported_list; @@ -77,10 +77,7 @@ pub use selectable::{ TuiSelectionHandle, TuiSelectionSpan, }; pub use shimmering_text::TuiShimmeringText; -pub use tab_bar::{ - TuiTab, TuiTabBar, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, - TuiTabBarStyles, -}; +pub use size_constraint_switch::{TuiSizeConstraintCondition, TuiSizeConstraintSwitch}; pub use text::TuiText; pub use text_helpers::{text_width, truncate_with_ellipsis}; pub use viewported_list::{ diff --git a/crates/warpui_core/src/elements/tui/size_constraint_switch.rs b/crates/warpui_core/src/elements/tui/size_constraint_switch.rs new file mode 100644 index 00000000000..959f6c4396c --- /dev/null +++ b/crates/warpui_core/src/elements/tui/size_constraint_switch.rs @@ -0,0 +1,126 @@ +//! [`TuiSizeConstraintSwitch`] selects one prebuilt child from the current +//! [`TuiConstraint`]. +//! +//! This mirrors the GUI `SizeConstraintSwitch`: conditions are checked in +//! order during layout, and every later lifecycle pass delegates to the child +//! selected by that layout. + +use super::{ + TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, + TuiPaintSurface, TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, +}; +use crate::AppContext; + +/// A condition that selects a child from a [`TuiSizeConstraintSwitch`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiSizeConstraintCondition { + WidthLessThan(u16), + HeightLessThan(u16), + SizeSmallerThan(TuiSize), +} + +impl TuiSizeConstraintCondition { + fn matches(self, constraint: TuiConstraint) -> bool { + match self { + Self::WidthLessThan(width) => constraint.max.width < width, + Self::HeightLessThan(height) => constraint.max.height < height, + Self::SizeSmallerThan(size) => { + constraint.max.width < size.width && constraint.max.height < size.height + } + } + } +} + +/// Selects a child according to the size constraint supplied during layout. +pub struct TuiSizeConstraintSwitch { + default_child: Box, + children: Vec<(TuiSizeConstraintCondition, Box)>, + active_child_index: Option, + cached_constraint: Option, +} + +impl TuiSizeConstraintSwitch { + /// Creates a switch whose first matching conditional child wins. + pub fn new( + default_child: Box, + children: impl Into)>>, + ) -> Self { + Self { + default_child, + children: children.into(), + active_child_index: None, + cached_constraint: None, + } + } + + fn active_child(&self) -> &dyn TuiElement { + self.active_child_index + .and_then(|index| self.children.get(index)) + .map(|(_, child)| child.as_ref()) + .unwrap_or(self.default_child.as_ref()) + } + + fn active_child_mut(&mut self) -> &mut dyn TuiElement { + self.active_child_index + .and_then(|index| self.children.get_mut(index)) + .map(|(_, child)| child.as_mut()) + .unwrap_or(self.default_child.as_mut()) + } +} + +impl TuiElement for TuiSizeConstraintSwitch { + fn layout( + &mut self, + constraint: TuiConstraint, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> TuiSize { + if self.cached_constraint != Some(constraint) { + self.active_child_index = self + .children + .iter() + .position(|(condition, _)| condition.matches(constraint)); + self.cached_constraint = Some(constraint); + } + self.active_child_mut().layout(constraint, ctx, app) + } + + fn after_layout(&mut self, ctx: &mut TuiLayoutContext, app: &AppContext) { + self.active_child_mut().after_layout(ctx, app); + } + + fn render( + &mut self, + origin: TuiScreenPosition, + surface: &mut TuiPaintSurface<'_>, + ctx: &mut TuiPaintContext, + ) { + self.active_child_mut().render(origin, surface, ctx); + } + + fn size(&self) -> Option { + self.active_child().size() + } + + fn origin(&self) -> Option { + self.active_child().origin() + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + self.active_child_mut().present(ctx); + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + event_ctx: &mut TuiEventContext<'_>, + app: &AppContext, + ) -> bool { + self.active_child_mut() + .dispatch_event(event, event_ctx, app) + } +} + +#[cfg(test)] +#[path = "size_constraint_switch_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs b/crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs new file mode 100644 index 00000000000..4d220d89f2f --- /dev/null +++ b/crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs @@ -0,0 +1,54 @@ +use super::{TuiSizeConstraintCondition, TuiSizeConstraintSwitch}; +use crate::elements::tui::test_support::render_to_lines; +use crate::elements::tui::{TuiElement, TuiSize, TuiText}; + +#[test] +fn renders_the_default_child_when_no_condition_matches() { + let switch = TuiSizeConstraintSwitch::new( + TuiText::new("default").truncate().finish(), + vec![( + TuiSizeConstraintCondition::WidthLessThan(8), + TuiText::new("narrow").truncate().finish(), + )], + ); + + assert_eq!( + render_to_lines(switch, TuiSize::new(12, 1)), + vec!["default "] + ); +} + +#[test] +fn renders_the_first_matching_child() { + let switch = TuiSizeConstraintSwitch::new( + TuiText::new("default").truncate().finish(), + vec![ + ( + TuiSizeConstraintCondition::WidthLessThan(8), + TuiText::new("narrow").truncate().finish(), + ), + ( + TuiSizeConstraintCondition::HeightLessThan(2), + TuiText::new("short").truncate().finish(), + ), + ], + ); + + assert_eq!(render_to_lines(switch, TuiSize::new(6, 1)), vec!["narrow"]); +} + +#[test] +fn supports_combined_size_conditions() { + let switch = TuiSizeConstraintSwitch::new( + TuiText::new("default").truncate().finish(), + vec![( + TuiSizeConstraintCondition::SizeSmallerThan(TuiSize::new(10, 3)), + TuiText::new("small").truncate().finish(), + )], + ); + + assert_eq!( + render_to_lines(switch, TuiSize::new(9, 2)), + vec!["small ", " "] + ); +} diff --git a/crates/warpui_core/src/elements/tui/tab_bar.rs b/crates/warpui_core/src/elements/tui/tab_bar.rs deleted file mode 100644 index cd32f909550..00000000000 --- a/crates/warpui_core/src/elements/tui/tab_bar.rs +++ /dev/null @@ -1,869 +0,0 @@ -//! Retained horizontal TUI tab bar with an optional fixed main tab and -//! width-aware paging for secondary tabs. -//! -//! # Ownership -//! -//! The caller owns semantic application state: the ordered tabs, selected key, -//! focus state, and current page anchor. [`TuiTabBar`] owns only UI state that -//! must survive element-tree reconstruction: mouse handles and the navigation -//! data produced by the latest completed layout. -//! -//! # Per-frame flow -//! -//! 1. The caller passes [`TuiTabBarConfig`] and an event callback to -//! [`TuiTabBar::render`]. -//! 2. The returned [`TabBarElement`] waits for `layout` to provide the actual -//! row width. It builds each caller-supplied leading element once, measures -//! it, and keeps that same instance for the rendered row. -//! 3. [`tab_bar_layout`] performs the pure width calculation. It chooses the -//! visible secondary page, truncates labels, and computes previous/next page -//! anchors. -//! 4. [`TabBarElement::build_row`] assembles the fixed visual order: caller -//! label, main tab, divider, previous control, visible tabs, and next -//! control. -//! 5. `after_layout` publishes only [`SettledNavigation`] back to the retained -//! component. Callers can then request previous/next keyboard targets -//! without gaining access to private widths or visible ranges. -//! -//! Pointer handlers emit [`TuiTabBarEvent`] values only. They never mutate the -//! caller's selection, focus, page anchor, or tab collection. - -use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; -use std::rc::Rc; - -use ratatui::style::Modifier; - -use super::{ - text_width, truncate_with_ellipsis, TuiConstraint, TuiContainer, TuiElement, TuiEvent, - TuiEventContext, TuiFlex, TuiHoverable, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, - TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, TuiText, -}; -use crate::elements::MouseStateHandle; -use crate::AppContext; - -// ----------------------------------------------------------------------------- -// Public API -// ----------------------------------------------------------------------------- - -/// One stable tab supplied by a tab-bar owner. -#[derive(Clone)] -pub struct TuiTab { - /// Stable identity used for retained mouse state, callbacks, and paging. - pub key: String, - /// Human-readable text displayed after any leading element. - pub label: String, - /// Rebuilds caller-owned visual content for each TUI layout pass. - leading_element: Option Box>>, -} - -impl TuiTab { - /// Creates a tab without leading content. - pub fn new(key: impl Into, label: impl Into) -> Self { - Self { - key: key.into(), - label: label.into(), - leading_element: None, - } - } - - /// Adds arbitrary caller-rendered content before the tab label. - /// - /// The component invokes this factory once per layout pass. It measures the - /// returned element and moves that same instance into the visible tab. - pub fn with_leading_element( - mut self, - build_element: impl Fn() -> Box + 'static, - ) -> Self { - self.leading_element = Some(Rc::new(build_element)); - self - } -} - -/// Caller-supplied styles for a tab-bar row. -#[derive(Clone, Copy, Debug, Default)] -pub struct TuiTabBarStyles { - /// Background style applied across the component's full assigned row. - pub bar: TuiStyle, - /// Style for the caller-provided product label before the tabs. - pub leading: TuiStyle, - /// Style for the fixed divider and previous/next arrows. - pub chrome: TuiStyle, - /// Style for an unselected tab. - pub tab: TuiStyle, - /// Style for the selected tab while the bar owns keyboard focus. - pub selected_focused: TuiStyle, - /// Style for the selected tab while another surface owns keyboard focus. - pub selected_unfocused: TuiStyle, -} - -/// Semantic events emitted by pointer interaction with the tab bar. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TuiTabBarEvent { - /// The user clicked a visible tab. - SelectTab(String), - /// The user clicked an overflow arrow targeting another page anchor. - PageChanged(String), -} - -/// Direction for width-aware tab navigation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TuiTabBarNavigationDirection { - /// Resolve the tab before the current selection. - Previous, - /// Resolve the tab after the current selection. - Next, -} -/// Callback shape shared by every clickable tab and overflow control. -/// Stored behind `Rc` so each child handler observes the same callback. -type EventHandler = dyn for<'a> Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext); - -/// Per-render input for [`TuiTabBar`]. -pub struct TuiTabBarConfig { - /// Optional product label rendered before the main tab. - pub leading: Option, - /// Optional fixed tab that never participates in secondary paging. - pub main_tab: Option, - /// Ordered secondary tabs packed into the remaining row width. - pub tabs: Vec, - /// Key whose tab receives the selected style. - pub selected_key: Option, - /// Whether the selected tab uses the focused or unfocused selected style. - pub focused: bool, - /// Secondary key from which the requested page begins. - pub page_anchor: Option, - /// Whether layout should replace an off-page anchor with the selected tab's page. - pub reveal_selected: bool, - /// Maximum display-cell width of each label, including its ellipsis. - pub maximum_label_columns: Option, - /// Blank display cells placed on each side of every tab. - pub tab_padding_columns: u16, - /// Blank display cells separating secondary tabs and overflow arrows. - pub secondary_gap_columns: u16, - /// Caller-supplied semantic styles for the complete row. - pub styles: TuiTabBarStyles, -} - -impl TuiTabBarConfig { - /// Creates a tab-bar configuration with neutral chrome defaults. - pub fn new(tabs: Vec) -> Self { - Self { - leading: None, - main_tab: None, - tabs, - selected_key: None, - focused: false, - page_anchor: None, - reveal_selected: false, - maximum_label_columns: None, - tab_padding_columns: 1, - secondary_gap_columns: 1, - styles: TuiTabBarStyles::default(), - } - } -} - -// ----------------------------------------------------------------------------- -// Private layout model -// ----------------------------------------------------------------------------- - -/// Render-ready description of one tab after label truncation. -/// -/// `source_index` reconnects a visible secondary tab to the leading element -/// that was built and measured before the pure layout calculation. -#[derive(Clone)] -struct RenderedTab { - /// Stable callback and mouse-state identity. - key: String, - /// Index into the original secondary-tab and leading-element vectors. - source_index: usize, - /// Label after configured and width-derived truncation. - label: String, - /// Total display cells occupied by padding, leading content, gap, and label. - width: u16, -} - -/// One deterministic secondary page packed from `start`. -#[derive(Clone)] -struct PageLayout { - /// Inclusive index of the first secondary tab considered for this page. - start: usize, - /// Exclusive index after the last tab that actually fit. - end: usize, - /// Visible tabs in paint order. - tabs: Vec, - /// Strictly later index that begins the next page, when one exists. - next_start: Option, -} - -/// Navigation-only data retained after the render-specific layout is discarded. -#[derive(Clone)] -struct SettledNavigation { - /// Complete navigation order: optional main tab followed by all secondaries. - order: Vec, - /// Explicit main-tab identity; never inferred from `order.first()`. - main_tab_key: Option, - /// Selection used by the layout that produced this state. - selected_key: Option, - /// Secondary keys that were actually painted. - visible_secondary_keys: Vec, -} - -/// Complete output of the pure width calculation for one frame. -#[derive(Clone)] -struct TabBarLayout { - /// Fixed main tab after label truncation. - main_tab: Option, - /// Anchor emitted by the previous overflow control. - previous_anchor: Option, - /// Visible secondary tabs in paint order. - tabs: Vec, - /// Anchor emitted by the next overflow control. - next_anchor: Option, - /// Lightweight subset published to the retained component after layout. - navigation: SettledNavigation, -} -// ----------------------------------------------------------------------------- -// Retained component state -// ----------------------------------------------------------------------------- - -/// UI-only state that survives per-frame element reconstruction. -/// Application selection and page state deliberately do not live here. -#[derive(Default)] -struct TuiTabBarState { - /// Stable pointer state for every currently supplied tab key. - mouse_states: HashMap, - /// Pointer state for the previous overflow arrow. - previous_overflow_mouse_state: MouseStateHandle, - /// Pointer state for the next overflow arrow. - next_overflow_mouse_state: MouseStateHandle, - /// Navigation data from the latest completed layout. - settled_navigation: Option, -} - -/// Retained owner for mouse state and settled keyboard navigation. -/// -/// Views keep one instance of this type and call [`Self::render`] each frame. -/// The returned element owns all width-dependent rendering for that frame. -#[derive(Default)] -pub struct TuiTabBar { - /// Shared bridge between the retained owner and the per-frame element. - state: Rc>, -} - -impl TuiTabBar { - /// Creates an empty retained tab-bar component. - pub fn new() -> Self { - Self::default() - } - - /// Builds a per-frame element from caller-owned semantic state. - /// - /// This reconciles the retained mouse-state map with the supplied keys, - /// invalidates stale navigation, and moves the config into a - /// [`TabBarElement`] that will resolve paging during layout. - pub fn render(&self, config: TuiTabBarConfig, on_event: F) -> Box - where - F: for<'a> Fn(TuiTabBarEvent, &mut TuiEventContext<'a>, &AppContext) + 'static, - { - let live_keys: HashSet<_> = config - .main_tab - .iter() - .chain(config.tabs.iter()) - .map(|tab| tab.key.clone()) - .collect(); - { - let mut state = self.state.borrow_mut(); - state.mouse_states.retain(|key, _| live_keys.contains(key)); - for key in live_keys { - state.mouse_states.entry(key).or_default(); - } - state.settled_navigation = None; - } - TabBarElement { - state: self.state.clone(), - config, - on_event: Rc::new(on_event), - row: None, - layout: None, - size: None, - origin: None, - } - .finish() - } - - /// Resolves a keyboard-navigation target from the latest completed layout. - /// - /// Visible selections navigate through the complete order and wrap. - /// Off-page selections enter the visible page from its nearest edge. - /// Returns `None` before the first layout or when no visible target exists. - pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { - let state = self.state.borrow(); - let navigation = state.settled_navigation.as_ref()?; - if navigation.order.is_empty() { - return None; - } - let selected_index = navigation - .selected_key - .as_ref() - .and_then(|selected| navigation.order.iter().position(|key| key == selected)); - let selected_is_visible = navigation.selected_key.as_ref().is_some_and(|selected| { - navigation.visible_secondary_keys.contains(selected) - || navigation.main_tab_key.as_ref() == Some(selected) - }); - if selected_is_visible { - let selected_index = selected_index?; - let target_index = match direction { - TuiTabBarNavigationDirection::Previous => selected_index - .checked_sub(1) - .unwrap_or(navigation.order.len() - 1), - TuiTabBarNavigationDirection::Next => (selected_index + 1) % navigation.order.len(), - }; - return navigation.order.get(target_index).cloned(); - } - match direction { - TuiTabBarNavigationDirection::Previous => { - navigation.visible_secondary_keys.last().cloned() - } - TuiTabBarNavigationDirection::Next => { - navigation.visible_secondary_keys.first().cloned() - } - } - } -} - -// ----------------------------------------------------------------------------- -// Per-frame element construction -// ----------------------------------------------------------------------------- - -/// Per-frame element that turns a config plus an assigned width into a row. -struct TabBarElement { - /// Retained state used for mouse handles and settled navigation publication. - state: Rc>, - /// Caller-owned semantic inputs for this frame. - config: TuiTabBarConfig, - /// Semantic event callback shared by all clickable children. - on_event: Rc, - /// Composed row built during layout and delegated to afterward. - row: Option, - /// Width-derived result kept until `after_layout` publishes navigation. - layout: Option, - /// Full assigned component size from the latest layout. - size: Option, - /// Screen-space origin from the latest paint. - origin: Option, -} - -impl TabBarElement { - /// Resolves a tab's caller-supplied style from selection and focus. - fn tab_style(&self, key: &str) -> TuiStyle { - if self.config.selected_key.as_deref() != Some(key) { - self.config.styles.tab - } else if self.config.focused { - self.config.styles.selected_focused - } else { - self.config.styles.selected_unfocused - } - } - - /// Builds one clickable tab from a settled label and measured leading child. - /// - /// Text receives the tab's selected/focused style, hover bolds only the - /// label, and an outer container fills selected background behind arbitrary - /// caller-supplied leading content. - fn render_tab( - &self, - tab: &RenderedTab, - leading_element: Option>, - ) -> Box { - let padding = " ".repeat(usize::from(self.config.tab_padding_columns)); - let state = self - .state - .borrow() - .mouse_states - .get(&tab.key) - .cloned() - .expect("tab mouse state was initialized before layout"); - let is_hovered = state.lock().unwrap().is_hovered(); - let tab_style = self.tab_style(&tab.key); - let label_style = if is_hovered { - tab_style.add_modifier(Modifier::BOLD) - } else { - tab_style - }; - let mut row = TuiFlex::row().child( - TuiText::new(padding.clone()) - .with_style(tab_style) - .truncate() - .finish(), - ); - if let Some(leading_element) = leading_element { - row = row.child(leading_element); - if !tab.label.is_empty() { - row = row.child(TuiText::new(" ").with_style(tab_style).truncate().finish()); - } - } - row = row - .child( - TuiText::new(tab.label.clone()) - .with_style(label_style) - .truncate() - .finish(), - ) - .child( - TuiText::new(padding) - .with_style(tab_style) - .truncate() - .finish(), - ); - let key = tab.key.clone(); - let on_event = self.on_event.clone(); - let row = row.finish(); - let content = if let Some(background) = tab_style.bg { - TuiContainer::new(row).with_background(background).finish() - } else { - row - }; - TuiHoverable::new(state, content) - .on_click(move |event_ctx, app| { - on_event(TuiTabBarEvent::SelectTab(key.clone()), event_ctx, app); - }) - .finish() - } - - /// Builds and measures a tab's leading child exactly once for this layout. - fn build_leading_element( - tab: &TuiTab, - size: TuiSize, - ctx: &mut TuiLayoutContext, - app: &AppContext, - ) -> (Option>, u16) { - let Some(build_leading_element) = &tab.leading_element else { - return (None, 0); - }; - let mut element = build_leading_element(); - let width = element.layout(TuiConstraint::loose(size), ctx, app).width; - (Some(element), width) - } - - /// Builds a clickable, hover-bold overflow arrow for a settled page anchor. - fn render_overflow( - &self, - text: &'static str, - anchor: String, - is_previous: bool, - ) -> Box { - let state = { - let state = self.state.borrow(); - if is_previous { - state.previous_overflow_mouse_state.clone() - } else { - state.next_overflow_mouse_state.clone() - } - }; - let style = if state.lock().unwrap().is_hovered() { - self.config.styles.chrome.add_modifier(Modifier::BOLD) - } else { - self.config.styles.chrome - }; - let on_event = self.on_event.clone(); - TuiHoverable::new( - state, - TuiText::new(text).with_style(style).truncate().finish(), - ) - .on_click(move |event_ctx, app| { - on_event(TuiTabBarEvent::PageChanged(anchor.clone()), event_ctx, app); - }) - .finish() - } - - /// Renders the configured spacing between pageable row items. - fn render_gap(&self) -> Box { - TuiText::new(" ".repeat(usize::from(self.config.secondary_gap_columns))) - .with_style(self.config.styles.bar) - .truncate() - .finish() - } - - /// Renders the caller-provided product label at the start of the row. - fn render_leading(&self, leading: &str) -> Box { - TuiText::new(leading) - .with_style(self.config.styles.leading) - .truncate() - .finish() - } - - /// Renders the fixed divider between the main tab and pageable tabs. - fn render_divider(&self) -> Box { - TuiText::new(" | ") - .with_style(self.config.styles.chrome) - .truncate() - .finish() - } - - /// Assembles the fixed visual order from named layout fields. - /// - /// Leading elements are moved from their measurement slots into visible - /// tabs, ensuring the measured instance is the instance that gets painted. - fn build_row( - &self, - layout: &TabBarLayout, - main_leading_element: Option>, - secondary_leading_elements: &mut [Option>], - ) -> TuiFlex { - let mut row = TuiFlex::row(); - if let Some(leading) = &self.config.leading { - row = row.child(self.render_leading(leading)); - } - if let Some(main_tab) = &layout.main_tab { - row = row.child(self.render_tab(main_tab, main_leading_element)); - if !self.config.tabs.is_empty() { - row = row.child(self.render_divider()); - } - } - if let Some(previous_anchor) = &layout.previous_anchor { - row = row.child(self.render_overflow("←", previous_anchor.clone(), true)); - if !layout.tabs.is_empty() || layout.next_anchor.is_some() { - row = row.child(self.render_gap()); - } - } - for (index, tab) in layout.tabs.iter().enumerate() { - if index > 0 { - row = row.child(self.render_gap()); - } - let leading_element = secondary_leading_elements - .get_mut(tab.source_index) - .and_then(Option::take); - row = row.child(self.render_tab(tab, leading_element)); - } - if let Some(next_anchor) = &layout.next_anchor { - if !layout.tabs.is_empty() { - row = row.child(self.render_gap()); - } - row = row.child(self.render_overflow("→", next_anchor.clone(), false)); - } - row - } -} - -impl TuiElement for TabBarElement { - /// Measures leading children, computes the visible page, and builds the row. - /// - /// All child construction stays local to this pass. The measured leading - /// element instances are moved into the row before the row is laid out. - fn layout( - &mut self, - constraint: TuiConstraint, - ctx: &mut TuiLayoutContext, - app: &AppContext, - ) -> TuiSize { - let size = TuiSize::new( - constraint.constrain_width(constraint.max.width), - constraint.constrain_height(u16::from(constraint.max.height > 0)), - ); - let (main_leading_element, main_leading_columns) = self - .config - .main_tab - .as_ref() - .map(|tab| Self::build_leading_element(tab, size, ctx, app)) - .unwrap_or((None, 0)); - let (mut secondary_leading_elements, secondary_leading_columns): (Vec<_>, Vec<_>) = self - .config - .tabs - .iter() - .map(|tab| Self::build_leading_element(tab, size, ctx, app)) - .unzip(); - let layout = tab_bar_layout( - &self.config, - main_leading_columns, - &secondary_leading_columns, - size.width, - ); - let mut row = self.build_row( - &layout, - main_leading_element, - &mut secondary_leading_elements, - ); - row.layout(TuiConstraint::loose(size), ctx, app); - self.row = Some(row); - self.layout = Some(layout); - self.size = Some(size); - size - } - - /// Publishes navigation only after the complete row layout has settled. - fn after_layout(&mut self, ctx: &mut TuiLayoutContext, app: &AppContext) { - if let Some(row) = &mut self.row { - row.after_layout(ctx, app); - } - self.state.borrow_mut().settled_navigation = - self.layout.as_ref().map(|layout| layout.navigation.clone()); - } - - /// Paints the bar background, then delegates content paint to the row. - fn render( - &mut self, - origin: TuiScreenPosition, - surface: &mut TuiPaintSurface<'_>, - ctx: &mut TuiPaintContext, - ) { - self.origin = Some(ctx.scene_point(origin)); - let Some(size) = self.size else { - return; - }; - surface.set_style(origin, size, self.config.styles.bar); - if let Some(row) = &mut self.row { - row.render(origin, surface, ctx); - } - } - - fn size(&self) -> Option { - self.size - } - - fn origin(&self) -> Option { - self.origin - } - - fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { - if let Some(row) = &mut self.row { - row.present(ctx); - } - } - - /// Delegates pointer interaction to the row's tab and overflow hoverables. - fn dispatch_event( - &mut self, - event: &TuiEvent, - event_ctx: &mut TuiEventContext<'_>, - app: &AppContext, - ) -> bool { - self.row - .as_mut() - .is_some_and(|row| row.dispatch_event(event, event_ctx, app)) - } -} - -// ----------------------------------------------------------------------------- -// Pure width and paging calculations -// ----------------------------------------------------------------------------- - -/// Computes the render-ready row and its retained navigation subset. -/// -/// Fixed content is measured first. The remaining columns are handed to the -/// secondary-page packer. Selected-tab reveal and previous-page resolution -/// both use the same deterministic page sequence. -fn tab_bar_layout( - config: &TuiTabBarConfig, - main_leading_columns: u16, - secondary_leading_columns: &[u16], - available_columns: u16, -) -> TabBarLayout { - let main_tab = config - .main_tab - .as_ref() - .map(|tab| layout_tab(tab, 0, main_leading_columns, config, None)); - let mut fixed_columns = config - .leading - .as_deref() - .map(text_width) - .unwrap_or_default(); - if let Some(main_tab) = &main_tab { - fixed_columns = fixed_columns.saturating_add(main_tab.width); - if !config.tabs.is_empty() { - fixed_columns = fixed_columns.saturating_add(text_width(" | ")); - } - } - - let secondary_columns = available_columns.saturating_sub(fixed_columns); - let requested_start = config - .page_anchor - .as_ref() - .and_then(|anchor| config.tabs.iter().position(|tab| &tab.key == anchor)) - .unwrap_or_default(); - let pages = deterministic_pages(config, secondary_leading_columns, secondary_columns); - let mut page = page_layout( - config, - secondary_leading_columns, - requested_start, - secondary_columns, - ); - if config.reveal_selected { - if let Some(selected_index) = config - .selected_key - .as_ref() - .and_then(|selected| config.tabs.iter().position(|tab| &tab.key == selected)) - { - if selected_index < page.start || selected_index >= page.end { - if let Some(selected_page) = pages.iter().find(|candidate| { - (selected_index >= candidate.start && selected_index < candidate.end) - || (candidate.tabs.is_empty() && candidate.start == selected_index) - }) { - page = selected_page.clone(); - } - } - } - } - - let previous_anchor = pages - .iter() - .take_while(|candidate| candidate.start < page.start) - .last() - .and_then(|candidate| config.tabs.get(candidate.start)) - .map(|tab| tab.key.clone()); - let next_anchor = page - .next_start - .and_then(|start| config.tabs.get(start)) - .map(|tab| tab.key.clone()); - let visible_secondary_keys = page.tabs.iter().map(|tab| tab.key.clone()).collect(); - let order = config - .main_tab - .iter() - .chain(config.tabs.iter()) - .map(|tab| tab.key.clone()) - .collect(); - TabBarLayout { - main_tab, - previous_anchor, - tabs: page.tabs, - next_anchor, - navigation: SettledNavigation { - order, - main_tab_key: config.main_tab.as_ref().map(|tab| tab.key.clone()), - selected_key: config.selected_key.clone(), - visible_secondary_keys, - }, - } -} - -/// Enumerates every secondary page by following strictly advancing starts. -fn deterministic_pages( - config: &TuiTabBarConfig, - leading_columns: &[u16], - available_columns: u16, -) -> Vec { - if config.tabs.is_empty() { - return Vec::new(); - } - let mut pages = Vec::new(); - let mut start = 0; - loop { - let page = page_layout(config, leading_columns, start, available_columns); - let next_start = page.next_start; - pages.push(page); - let Some(next_start) = next_start else { - break; - }; - start = next_start; - } - pages -} - -/// Packs one secondary page into the columns left after fixed chrome. -/// -/// A full tab is preferred. If it does not fit, the final visible tab may use -/// the remaining label budget. If even its fixed content plus one label cell -/// cannot fit, the page advances without rendering that tab. -fn page_layout( - config: &TuiTabBarConfig, - leading_columns: &[u16], - requested_start: usize, - available_columns: u16, -) -> PageLayout { - let start = requested_start.min(config.tabs.len().saturating_sub(1)); - let previous_columns = if start > 0 { - text_width("←").saturating_add(config.secondary_gap_columns) - } else { - 0 - }; - let mut remaining = available_columns.saturating_sub(previous_columns); - let next_columns = text_width("→").saturating_add(config.secondary_gap_columns); - let mut rendered_tabs = Vec::new(); - let mut end = start; - - for (index, tab) in config.tabs.iter().enumerate().skip(start) { - let gap = if rendered_tabs.is_empty() { - 0 - } else { - config.secondary_gap_columns - }; - let reserve_next = if index + 1 < config.tabs.len() { - next_columns - } else { - 0 - }; - let available_for_tab = remaining.saturating_sub(gap).saturating_sub(reserve_next); - let leading_columns = leading_columns.get(index).copied().unwrap_or_default(); - let rendered = layout_tab(tab, index, leading_columns, config, None); - if rendered.width <= available_for_tab { - remaining = remaining.saturating_sub(gap.saturating_add(rendered.width)); - rendered_tabs.push(rendered); - end = index + 1; - continue; - } - let fixed = tab_fixed_columns(tab, leading_columns, config.tab_padding_columns); - let minimum = fixed.saturating_add(u16::from(!tab.label.is_empty())); - if available_for_tab >= minimum { - rendered_tabs.push(layout_tab( - tab, - index, - leading_columns, - config, - Some(available_for_tab), - )); - end = index + 1; - } - break; - } - - let candidate = if end > start { - end - } else { - start.saturating_add(1) - }; - PageLayout { - start, - end, - tabs: rendered_tabs, - next_start: (candidate < config.tabs.len()).then_some(candidate), - } -} - -/// Produces one render-ready tab description within a total column budget. -fn layout_tab( - tab: &TuiTab, - source_index: usize, - leading_columns: u16, - config: &TuiTabBarConfig, - total_columns: Option, -) -> RenderedTab { - let fixed_columns = tab_fixed_columns(tab, leading_columns, config.tab_padding_columns); - let configured_label_columns = config - .maximum_label_columns - .unwrap_or_else(|| text_width(&tab.label)); - let label_columns = total_columns - .map(|columns| columns.saturating_sub(fixed_columns)) - .unwrap_or(configured_label_columns) - .min(configured_label_columns); - let label = truncate_with_ellipsis(&tab.label, usize::from(label_columns)); - RenderedTab { - key: tab.key.clone(), - source_index, - width: fixed_columns.saturating_add(text_width(&label)), - label, - } -} - -/// Returns columns occupied before label text is added. -fn tab_fixed_columns(tab: &TuiTab, leading_columns: u16, padding_columns: u16) -> u16 { - padding_columns - .saturating_mul(2) - .saturating_add(leading_columns) - .saturating_add(u16::from( - tab.leading_element.is_some() && !tab.label.is_empty(), - )) -} - -#[cfg(test)] -#[path = "tab_bar_tests.rs"] -mod tests; diff --git a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs b/crates/warpui_core/src/elements/tui/tab_bar_tests.rs deleted file mode 100644 index fcfc08b476a..00000000000 --- a/crates/warpui_core/src/elements/tui/tab_bar_tests.rs +++ /dev/null @@ -1,364 +0,0 @@ -use std::cell::{Cell, RefCell}; -use std::rc::Rc; -use std::sync::Arc; - -use ratatui::style::{Color, Modifier}; - -use super::{ - page_layout, tab_bar_layout, TuiTab, TuiTabBar, TuiTabBarConfig, TuiTabBarEvent, - TuiTabBarNavigationDirection, TuiTabBarStyles, -}; -use crate::elements::tui::test_support::dispatch_presented_event; -use crate::elements::tui::{ - TuiBufferExt, TuiElement, TuiEvent, TuiPoint, TuiRect, TuiStyle, TuiText, -}; -use crate::event::ModifiersState; -use crate::presenter::tui::TuiPresenter; -use crate::{App, AppContext}; - -type Events = Rc>>; - -fn key(key: u8) -> String { - key.to_string() -} - -fn tab(key: u8, label: &str) -> TuiTab { - TuiTab::new(key.to_string(), label) -} - -fn config(tabs: Vec) -> TuiTabBarConfig { - let mut config = TuiTabBarConfig::new(tabs); - config.styles = TuiTabBarStyles { - bar: TuiStyle::default().bg(Color::Black), - leading: TuiStyle::default().fg(Color::White), - chrome: TuiStyle::default().fg(Color::White), - tab: TuiStyle::default().fg(Color::White), - selected_focused: TuiStyle::default() - .fg(Color::Black) - .bg(Color::Magenta) - .add_modifier(Modifier::BOLD), - selected_unfocused: TuiStyle::default().add_modifier(Modifier::BOLD), - }; - config -} - -fn render(bar: &TuiTabBar, config: TuiTabBarConfig, events: &Events) -> Box { - let events = events.clone(); - bar.render(config, move |event, _, _| { - events.borrow_mut().push(event); - }) -} - -fn layout(config: &TuiTabBarConfig, available_columns: u16) -> super::TabBarLayout { - tab_bar_layout(config, 0, &vec![0; config.tabs.len()], available_columns) -} - -fn page( - config: &TuiTabBarConfig, - requested_start: usize, - available_columns: u16, -) -> super::PageLayout { - page_layout( - config, - &vec![0; config.tabs.len()], - requested_start, - available_columns, - ) -} - -fn mouse_moved(x: u16) -> TuiEvent { - TuiEvent::MouseMoved { - position: TuiPoint::new(x, 0), - modifiers: ModifiersState::default(), - is_synthetic: false, - } -} - -fn click(presenter: &mut TuiPresenter, x: u16, app: &AppContext) { - let down = TuiEvent::LeftMouseDown { - position: TuiPoint::new(x, 0), - modifiers: ModifiersState::default(), - click_count: 1, - is_first_mouse: false, - }; - let up = TuiEvent::LeftMouseUp { - position: TuiPoint::new(x, 0), - modifiers: ModifiersState::default(), - }; - assert!(dispatch_presented_event(presenter, &down, app).0); - assert!(dispatch_presented_event(presenter, &up, app).0); -} - -#[test] -fn layout_keeps_main_fixed_and_clamps_missing_anchor() { - let mut config = config(vec![tab(2, "alpha"), tab(3, "beta"), tab(4, "gamma")]); - config.leading = Some("Agents:".to_string()); - config.main_tab = Some(tab(1, "main")); - config.page_anchor = Some(key(3)); - - let anchored = layout(&config, 24); - assert_eq!( - anchored.navigation.order, - vec![key(1), key(2), key(3), key(4)] - ); - assert_eq!( - anchored.main_tab.as_ref().map(|tab| tab.key.clone()), - Some(key(1)) - ); - assert!(!anchored.navigation.visible_secondary_keys.contains(&key(2))); - assert!(anchored.navigation.visible_secondary_keys.contains(&key(3))); - - config.page_anchor = Some("missing".to_string()); - assert_eq!( - layout(&config, 40).navigation.visible_secondary_keys, - vec![key(2), key(3), key(4)] - ); -} - -#[test] -fn page_layout_truncates_and_never_points_next_to_itself() { - let mut config = config(vec![ - tab(1, "alpha"), - tab(2, "bravo"), - tab(3, "charlie-long"), - tab(4, "delta"), - ]); - config.maximum_label_columns = Some(20); - - let first = page(&config, 0, 22); - assert!(first.next_start.is_some()); - assert_ne!(first.tabs.last().unwrap().label, "charlie-long"); - assert!(first.tabs.iter().map(|tab| tab.width).sum::() <= 22); - - let narrow_last = page(&config, config.tabs.len() - 1, 2); - assert!(narrow_last.tabs.is_empty()); - assert_eq!(narrow_last.next_start, None); -} - -#[test] -fn selected_reveal_respects_explicit_pages() { - let mut config = config(vec![ - tab(1, "alpha"), - tab(2, "bravo"), - tab(3, "charlie"), - tab(4, "delta"), - ]); - config.page_anchor = Some(key(3)); - config.selected_key = Some(key(1)); - - let explicit = layout(&config, 20); - assert_eq!( - explicit.navigation.visible_secondary_keys.first(), - Some(&key(3)) - ); - assert!(!explicit.navigation.visible_secondary_keys.contains(&key(1))); - - config.reveal_selected = true; - let revealed = layout(&config, 20); - assert_eq!( - revealed.navigation.visible_secondary_keys.first(), - Some(&key(1)) - ); - - config.selected_key = Some(key(2)); - assert_eq!( - layout(&config, 20).navigation.visible_secondary_keys, - revealed.navigation.visible_secondary_keys - ); -} - -#[test] -fn navigation_wraps_and_enters_the_visible_page() { - App::test((), |app| async move { - app.read(|app| { - let events = Rc::new(RefCell::new(Vec::new())); - let bar = TuiTabBar::new(); - let mut presenter = TuiPresenter::new(); - - let mut wrap_config = config(vec![tab(2, "two"), tab(3, "three")]); - wrap_config.main_tab = Some(tab(1, "main")); - wrap_config.selected_key = Some(key(1)); - presenter.present_element( - render(&bar, wrap_config, &events), - TuiRect::new(0, 0, 40, 1), - app, - ); - assert_eq!( - bar.navigation_target(TuiTabBarNavigationDirection::Previous), - Some(key(3)) - ); - assert_eq!( - bar.navigation_target(TuiTabBarNavigationDirection::Next), - Some(key(2)) - ); - - let mut page_config = config(vec![ - tab(1, "alpha"), - tab(2, "bravo"), - tab(3, "charlie"), - tab(4, "delta"), - ]); - page_config.selected_key = Some(key(1)); - page_config.page_anchor = Some(key(3)); - presenter.present_element( - render(&bar, page_config, &events), - TuiRect::new(0, 0, 16, 1), - app, - ); - let visible = bar - .state - .borrow() - .settled_navigation - .as_ref() - .unwrap() - .visible_secondary_keys - .clone(); - assert!(!visible.contains(&key(1))); - assert_eq!( - bar.navigation_target(TuiTabBarNavigationDirection::Previous), - visible.last().cloned() - ); - assert_eq!( - bar.navigation_target(TuiTabBarNavigationDirection::Next), - visible.first().cloned() - ); - }); - }); -} - -#[test] -fn renders_selected_style_and_leading_element_once() { - App::test((), |app| async move { - app.read(|app| { - let events = Rc::new(RefCell::new(Vec::new())); - let bar = TuiTabBar::new(); - let build_count = Rc::new(Cell::new(0)); - let leading_build_count = build_count.clone(); - let mut config = config(vec![tab(1, "one").with_leading_element(move || { - leading_build_count.set(leading_build_count.get() + 1); - TuiText::new("*") - .with_style(TuiStyle::default().fg(Color::Yellow)) - .finish() - })]); - config.selected_key = Some(key(1)); - config.focused = true; - - let mut presenter = TuiPresenter::new(); - let frame = presenter.present_element( - render(&bar, config, &events), - TuiRect::new(0, 0, 20, 1), - app, - ); - assert!(frame.buffer.to_lines()[0].contains("* one")); - assert_eq!(frame.buffer[(0, 0)].bg, Color::Magenta); - assert!(frame.buffer[(0, 0)].modifier.contains(Modifier::BOLD)); - assert_eq!(frame.buffer[(1, 0)].fg, Color::Yellow); - assert_eq!(build_count.get(), 1); - }); - }); -} - -#[test] -fn hover_bolds_tabs_and_overflow_arrows() { - App::test((), |app| async move { - app.read(|app| { - let events = Rc::new(RefCell::new(Vec::new())); - let mut presenter = TuiPresenter::new(); - - let tab_bar = TuiTabBar::new(); - let frame = presenter.present_element( - render(&tab_bar, config(vec![tab(1, "one")]), &events), - TuiRect::new(0, 0, 20, 1), - app, - ); - assert!(!frame.buffer[(1, 0)].modifier.contains(Modifier::BOLD)); - dispatch_presented_event(&mut presenter, &mouse_moved(1), app); - let frame = presenter.present_element( - render(&tab_bar, config(vec![tab(1, "one")]), &events), - TuiRect::new(0, 0, 20, 1), - app, - ); - assert!(frame.buffer[(1, 0)].modifier.contains(Modifier::BOLD)); - - let overflow_bar = TuiTabBar::new(); - let tabs = || config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]); - let frame = presenter.present_element( - render(&overflow_bar, tabs(), &events), - TuiRect::new(0, 0, 12, 1), - app, - ); - let arrow = frame.buffer.to_lines()[0] - .find('→') - .expect("next overflow is visible") as u16; - assert!(!frame.buffer[(arrow, 0)].modifier.contains(Modifier::BOLD)); - dispatch_presented_event(&mut presenter, &mouse_moved(arrow), app); - let frame = presenter.present_element( - render(&overflow_bar, tabs(), &events), - TuiRect::new(0, 0, 12, 1), - app, - ); - assert!(frame.buffer[(arrow, 0)].modifier.contains(Modifier::BOLD)); - }); - }); -} - -#[test] -fn clicks_emit_semantic_events() { - App::test((), |app| async move { - app.read(|app| { - let events = Rc::new(RefCell::new(Vec::new())); - let bar = TuiTabBar::new(); - let mut presenter = TuiPresenter::new(); - - presenter.present_element( - render(&bar, config(vec![tab(1, "one"), tab(2, "two")]), &events), - TuiRect::new(0, 0, 20, 1), - app, - ); - click(&mut presenter, 2, app); - assert_eq!(*events.borrow(), vec![TuiTabBarEvent::SelectTab(key(1))]); - - events.borrow_mut().clear(); - let frame = presenter.present_element( - render( - &bar, - config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]), - &events, - ), - TuiRect::new(0, 0, 12, 1), - app, - ); - let arrow = frame.buffer.to_lines()[0] - .find('→') - .expect("next overflow is visible") as u16; - click(&mut presenter, arrow, app); - assert!(matches!( - events.borrow().as_slice(), - [TuiTabBarEvent::PageChanged(_)] - )); - }); - }); -} - -#[test] -fn retained_mouse_state_is_reused_and_pruned() { - let events = Rc::new(RefCell::new(Vec::new())); - let bar = TuiTabBar::new(); - let _ = render(&bar, config(vec![tab(1, "one"), tab(2, "two")]), &events); - let first_handle = bar - .state - .borrow() - .mouse_states - .get(&key(1)) - .cloned() - .unwrap(); - - let _ = render(&bar, config(vec![tab(1, "one"), tab(3, "three")]), &events); - let state = bar.state.borrow(); - assert_eq!(state.mouse_states.len(), 2); - assert!(!state.mouse_states.contains_key(&key(2))); - assert!(Arc::ptr_eq( - &first_handle, - state.mouse_states.get(&key(1)).unwrap() - )); -} diff --git a/crates/warpui_core/src/elements/tui/text.rs b/crates/warpui_core/src/elements/tui/text.rs index 1408e1a9799..e8708e5f492 100644 --- a/crates/warpui_core/src/elements/tui/text.rs +++ b/crates/warpui_core/src/elements/tui/text.rs @@ -9,6 +9,8 @@ //! every glyph; span styles patch over it. //! - [`truncate`](TuiText::truncate) switches from the default word-wrapping //! policy to single-row-per-hard-line truncation. +//! - [`truncate_with_ellipsis`](TuiText::truncate_with_ellipsis) also replaces +//! clipped trailing content with as much of `...` as fits. //! //! # Layout policy //! Wrapping and measurement defer to `Paragraph`, so layout, render, and @@ -17,6 +19,8 @@ //! (`Wrap { trim: false }`); a word wider than the row is broken at grapheme //! boundaries. //! - **Truncate**: each hard line becomes one row, clipped to the width. +//! - **Ellipsis**: each hard line remains one row and is truncated at grapheme +//! boundaries with `...` inside the assigned width. //! //! Height is `Paragraph::line_count` and the natural width is //! `Paragraph::line_width`; both are column-accurate for wide (CJK) glyphs, so a @@ -27,13 +31,21 @@ use std::mem; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Paragraph, Wrap}; +use unicode_segmentation::UnicodeSegmentation; use super::{ - TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiScreenPoint, - TuiScreenPosition, TuiSize, TuiStyle, + text_width, TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, }; use crate::AppContext; +#[derive(Clone, Copy, Default)] +enum TuiTextOverflow { + #[default] + Clip, + Ellipsis, +} + pub struct TuiText { /// Styled runs that concatenate into the full text. Runs may contain hard /// newlines, which split rows exactly as they would in a single run. @@ -41,6 +53,7 @@ pub struct TuiText { /// Base style beneath every span; span styles patch over it. style: TuiStyle, wrap: bool, + overflow: TuiTextOverflow, size: Option, origin: Option, } @@ -59,6 +72,7 @@ impl TuiText { spans: spans.into_iter().collect(), style: TuiStyle::default(), wrap: true, + overflow: TuiTextOverflow::default(), size: None, origin: None, } @@ -74,6 +88,13 @@ impl TuiText { self.wrap = false; self } + /// Truncates each hard line at grapheme boundaries and appends `...` + /// inside the width supplied during layout. + pub fn truncate_with_ellipsis(mut self) -> Self { + self.wrap = false; + self.overflow = TuiTextOverflow::Ellipsis; + self + } /// The number of terminal rows this text occupies when laid out at `width` /// columns. Matches what `layout` would return as the height component. @@ -81,7 +102,7 @@ impl TuiText { if self.is_empty() { return 0; } - u16::try_from(self.paragraph().line_count(width)).unwrap_or(u16::MAX) + u16::try_from(self.paragraph(width).line_count(width)).unwrap_or(u16::MAX) } /// Whether this element holds no text at all (and so occupies no rows). @@ -114,9 +135,89 @@ impl TuiText { Text::from(lines) } + /// Rebuilds hard lines with trailing content replaced by a styled + /// ellipsis. Allocating here keeps the normal wrapping/clipping path + /// borrowing its original spans. + fn ellipsized_text(&self, maximum_columns: u16) -> Text<'static> { + let mut source_lines = Vec::>::new(); + let mut current_line = Vec::new(); + for (content, style) in &self.spans { + let mut parts = content.split('\n'); + if let Some(first) = parts.next() { + if !first.is_empty() { + current_line.push((first.to_owned(), *style)); + } + } + for part in parts { + source_lines.push(mem::take(&mut current_line)); + if !part.is_empty() { + current_line.push((part.to_owned(), *style)); + } + } + } + source_lines.push(current_line); + + let maximum_columns = usize::from(maximum_columns); + let ellipsis_columns = usize::from(text_width("...")).min(maximum_columns); + let prefix_columns = maximum_columns.saturating_sub(ellipsis_columns); + let lines = source_lines + .into_iter() + .map(|runs| { + let line_columns = runs + .iter() + .map(|(text, _)| usize::from(text_width(text))) + .sum::(); + if line_columns <= maximum_columns { + return Line::from( + runs.into_iter() + .map(|(text, style)| Span::styled(text, style)) + .collect::>(), + ); + } + + let mut spans = Vec::new(); + let mut used_columns = 0usize; + let mut ellipsis_style = runs.first().map(|(_, style)| *style).unwrap_or_default(); + 'runs: for (text, style) in runs { + let mut prefix = String::new(); + for grapheme in UnicodeSegmentation::graphemes(text.as_str(), true) { + let grapheme_columns = usize::from(text_width(grapheme)); + if used_columns.saturating_add(grapheme_columns) > prefix_columns { + ellipsis_style = style; + if !prefix.is_empty() { + spans.push(Span::styled(prefix, style)); + } + break 'runs; + } + prefix.push_str(grapheme); + used_columns = used_columns.saturating_add(grapheme_columns); + } + if !prefix.is_empty() { + spans.push(Span::styled(prefix, style)); + } + ellipsis_style = style; + } + if ellipsis_columns > 0 { + spans.push(Span::styled(".".repeat(ellipsis_columns), ellipsis_style)); + } + Line::from(spans) + }) + .collect::>(); + Text::from(lines) + } + + fn text_for_width(&self, width: u16) -> Text<'_> { + match (self.wrap, self.overflow) { + (false, TuiTextOverflow::Ellipsis) => self.ellipsized_text(width), + (true | false, TuiTextOverflow::Clip) | (true, TuiTextOverflow::Ellipsis) => { + self.text() + } + } + } + /// The ratatui `Paragraph` backing this element's measure and paint. - fn paragraph(&self) -> Paragraph<'_> { - let paragraph = Paragraph::new(self.text()).style(self.style); + fn paragraph(&self, width: u16) -> Paragraph<'_> { + let paragraph = Paragraph::new(self.text_for_width(width)).style(self.style); if self.wrap { paragraph.wrap(Wrap { trim: false }) } else { @@ -135,7 +236,7 @@ impl TuiElement for TuiText { let size = if self.is_empty() { constraint.clamp(TuiSize::ZERO) } else { - let paragraph = self.paragraph(); + let paragraph = self.paragraph(constraint.max.width); let height = u16::try_from(paragraph.line_count(constraint.max.width)).unwrap_or(u16::MAX); let content_width = u16::try_from(paragraph.line_width()).unwrap_or(u16::MAX); @@ -161,7 +262,7 @@ impl TuiElement for TuiText { if size.width == 0 || size.height == 0 { return; } - surface.render_widget(self.paragraph(), origin, size); + surface.render_widget(self.paragraph(size.width), origin, size); } fn size(&self) -> Option { diff --git a/crates/warpui_core/src/elements/tui/text_tests.rs b/crates/warpui_core/src/elements/tui/text_tests.rs index 8f226463b08..4af013ea3f3 100644 --- a/crates/warpui_core/src/elements/tui/text_tests.rs +++ b/crates/warpui_core/src/elements/tui/text_tests.rs @@ -13,6 +13,40 @@ fn renders_a_single_short_line() { vec!["hello "], ); } +#[test] +fn ellipsis_stays_inside_the_assigned_width() { + assert_eq!( + render_to_lines( + TuiText::new("infrastructure").truncate_with_ellipsis(), + TuiSize::new(8, 1), + ), + vec!["infra..."], + ); + assert_eq!( + render_to_lines( + TuiText::new("abcdef").truncate_with_ellipsis(), + TuiSize::new(2, 1), + ), + vec![".."], + ); +} + +#[test] +fn ellipsis_preserves_graphemes_and_span_style() { + let yellow = Style::default().fg(Color::Yellow); + let buffer = render_to_frame( + TuiText::from_spans([ + ("e\u{301}cl".to_owned(), yellow), + ("air".to_owned(), Style::default()), + ]) + .truncate_with_ellipsis(), + TuiSize::new(5, 1), + ) + .buffer; + + assert_eq!(buffer.to_lines(), vec!["e\u{301}c..."]); + assert_eq!(buffer[(2, 0)].fg, Color::Yellow); +} #[test] fn layout_reports_content_width_and_row_count() { diff --git a/specs/code-1822-tui-tab-bar-component/PRODUCT.md b/specs/code-1822-tui-tab-bar-component/PRODUCT.md index 8544e5403cf..b3c268106d6 100644 --- a/specs/code-1822-tui-tab-bar-component/PRODUCT.md +++ b/specs/code-1822-tui-tab-bar-component/PRODUCT.md @@ -1,96 +1,88 @@ -# PRODUCT: Reusable TUI Tab-Bar Component +# PRODUCT: Reusable TUI Tab-Bar View Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) ## Summary -The TUI element library gains a reusable horizontal tab-bar component that renders an optional main tab and a pageable list of secondary tabs. The component owns width-dependent layout and interaction geometry while callers own application selection, focus, and page state through semantic inputs and callbacks. +The TUI gains a reusable horizontal tab-bar view that renders an optional main tab and a pageable list of secondary tabs. The view owns retained interaction state and responsive presentation while callers own application selection, focus, and page state. ## Figma -The orchestration designs establish the component states; the component itself remains domain-neutral: +The orchestration designs establish the view states; the view remains domain-neutral: - Unfocused tab bar: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-20498&m=dev - Focused tab bar: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-19947&m=dev - Truncated final tab and overflow: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=881-21464&m=dev ## Goals -- Give TUI surfaces one reusable component for horizontal tabs, truncation, overflow paging, and pointer interaction. -- Keep application-specific selection and page state outside the component. -- Keep width-derived visible ranges and page boundaries private to the component. +- Give TUI surfaces a reusable view for horizontal tabs, truncation, overflow paging, keyboard targets, and pointer interaction. +- Express tab-bar presentation with the generic TUI element vocabulary. +- Keep application-specific selection, focus, and page state outside the view. +- Match the GUI's size-constraint switch pattern for responsive element-tree variants. ## Non-goals +- Adding a tab-specific element to the TUI element library. - Knowing about orchestration, conversations, sessions, agents, or Warp-specific navigation. - Owning application selection, keyboard focus, or persisted page anchors. -- Choosing colors, icons, labels, maximum widths, or keybindings for a caller. +- Choosing colors, labels, maximum widths, or keybindings for a caller. - Rendering context menus, close buttons, drag reordering, or pinned-tab controls. ## Behavior -### Inputs and output +### Inputs and rendering 1. A caller can provide: - An optional main tab. - An ordered list of secondary tabs. - A stable string key and label for every tab. - - An optional caller-rendered leading element for every tab. + - Optional styled leading text for every tab. - The selected tab key, if any. - Whether the bar is focused. + - The current secondary-page anchor. - Whether an off-page selected secondary tab should be revealed. - Focused, unfocused, selected, background, leading-label, and chrome styles. - An optional maximum label width in terminal display cells. - - The current secondary-page anchor. -2. The component renders exactly one terminal row. It never wraps tabs onto another row. -3. The main tab, when present, is fixed at the leading edge and does not participate in secondary-tab paging. -4. The caller controls any product label surrounding the tabs. The component uses one consistent divider and previous/next arrows across callers. -5. An empty secondary list is valid. The component renders the supplied main tab and surrounding chrome without overflow controls. +2. The view renders exactly one terminal row and never wraps tabs. +3. The view builds complete row alternatives from generic flex, text, container, and hoverable elements, then uses a size-constraint switch to select the alternative for the assigned width. +4. The main tab, when present, stays at the leading edge and does not participate in secondary paging. +5. The caller controls any product label surrounding the tabs. The view supplies one consistent divider and previous/next arrows. +6. An empty secondary list is valid. ### Ownership -6. The component privately owns: - - Stable mouse state for tabs and overflow controls. - - Width-dependent tab packing. - - The settled visible secondary range. - - Previous and next page boundaries. - - Hit-test geometry. -7. The caller cannot read or mutate the settled visible range or page-boundary geometry. -8. The component does not mutate application selection, page state, focus, models, or caller-owned collections. -9. The component communicates only semantic outcomes: - - `SelectTab(key)` when a visible tab is clicked. - - `PageChanged(anchor_key)` when an overflow control chooses another page. - - A target tab key when the caller requests previous or next keyboard navigation. -10. Rebuilding or resizing the element does not recreate mouse state for tab keys that remain present. -11. Removed tab keys release their retained component state and cannot remain clickable. +7. The view privately owns stable mouse state for tabs and overflow controls. +8. Re-rendering or resizing does not recreate mouse state for tab keys that remain present. +9. Removed tab keys release their retained mouse state and cannot remain clickable. +10. The view does not mutate application selection, focus, models, or caller-owned tab collections. +11. Pointer interaction emits semantic view events: + - `SelectTab(key)` when a visible tab is clicked. + - `PageChanged(anchor_key)` when an overflow control chooses another page. ### Selection and focus presentation -12. The selected key determines which tab uses the selected treatment. The component has no pending selection distinct from the caller's selected key. +12. The selected key determines which tab uses the selected treatment. 13. Focused and unfocused selected treatments are independently caller-configurable. -14. Focus changes affect presentation only. Focusing the component does not select a different tab or change the page. -15. If the selected key is absent, the component renders no selected tab and continues to lay out and dispatch interactions normally. +14. Focus changes affect presentation only. +15. If the selected key is absent, the view renders no selected tab and continues to lay out and dispatch interactions normally. ### Label width and truncation 16. Width calculations use terminal display cells rather than Unicode scalar count or byte length. 17. When a maximum label width is supplied, every label is constrained to that many display cells, including the ellipsis. 18. A label exceeding its maximum is truncated with `...`. -19. A label within its maximum is rendered in full. -20. Wide and combining Unicode characters never split into invalid text or corrupt following cell alignment. -21. The last visible secondary tab may be truncated below its configured maximum when required to preserve an applicable overflow control. -22. At narrow widths, fixed leading content and applicable overflow controls take priority over secondary-label content. The component never paints outside its assigned row. +19. Wide and combining Unicode characters never split into invalid text or corrupt following cell alignment. +20. The last visible secondary tab may be truncated below its configured maximum to preserve an applicable overflow control. +21. At narrow widths, fixed leading content and overflow controls take priority over secondary-label content. +22. The view never paints outside its assigned row. ### Paging -23. The component packs secondary tabs beginning at the caller's page anchor. -24. A next overflow control appears when later secondary tabs are hidden. -25. A previous overflow control appears when earlier secondary tabs are hidden. -26. A control with no page in its direction is not actionable. -27. Activating an overflow control emits `PageChanged` with the anchor computed from the component's settled layout. -28. Paging does not emit `SelectTab`. -29. Paging does not change focus. -30. When the caller supplies a new page anchor, the next layout settles the visible range from that anchor and clamps an unavailable anchor to a valid page. -31. Resizing recomputes visible tabs and page boundaries from the same supplied order and anchor. -32. When selected-tab reveal is enabled and the selected secondary tab is off-page, the component shows the deterministic page containing it. Selection changes within the current page do not shift that page. +23. Responsive composition uses the row width supplied by the layout constraint. +24. Secondary tabs are packed beginning at the caller's page anchor. +25. A next overflow control appears only when later secondary tabs are hidden. +26. A previous overflow control appears only when earlier secondary tabs are hidden. +27. Activating an overflow control emits `PageChanged` with the computed page anchor. +28. Paging does not emit `SelectTab` or change focus. +29. A missing page anchor falls back to the first secondary page. +30. Resizing recomputes visible tabs and page boundaries from the same supplied order and anchor. +31. When selected-tab reveal is enabled, the selected secondary tab becomes the page anchor. ### Navigation and pointer behavior -33. Activating a visible tab emits `SelectTab` for that tab's stable key. -34. A tab remains clickable regardless of the bar's focused presentation. -35. Activating a tab never changes focus by itself. -36. The component can resolve previous and next navigation from its private settled layout: - - When the selected tab is visible, navigation uses the complete supplied order and wraps at both ends. - - When the selected tab is off-page, previous resolves to the last visible secondary tab and next resolves to the first visible secondary tab. -37. Resolved navigation returns only the target key; the caller decides what selecting that key means. -38. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. -39. Pointer press/release outside a target does not invoke its callback. -40. Hovering a tab bolds its label without changing its selection, page, or focus. -41. Hovering a previous or next overflow control bolds the arrow without changing its behavior. +32. Previous and next keyboard targets follow the complete semantic order of the main tab followed by secondary tabs and wrap at both ends. +33. First/last-secondary target lookup excludes the main tab. +34. Target lookup returns only a stable key; the caller decides what selecting it means. +35. A tab remains clickable regardless of focused presentation. +36. Activating a tab never changes focus by itself. +37. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. +38. Hovering a tab bolds its label without changing selection, page, or focus. +39. Hovering an overflow control bolds the arrow without changing its behavior. diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md index b6dcf9ef738..c0ea035c491 100644 --- a/specs/code-1822-tui-tab-bar-component/TECH.md +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -1,100 +1,105 @@ -# TECH: Reusable TUI Tab-Bar Component +# TECH: Reusable TUI Tab-Bar View Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) Product: [specs/code-1822-tui-tab-bar-component/PRODUCT.md](./PRODUCT.md) -Inspected commit: `caa826c2ef395faee32c87c19c533a44ef88d81b` ## Context -The TUI cell-grid library has the layout, styling, retained-geometry, and click primitives needed for a horizontal tab bar, but no component that owns tab packing or paging: -- [`crates/warpui_core/src/elements/tui/mod.rs (31-74) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/mod.rs#L31-L74) — exports the TUI element vocabulary and `TuiElement`. -- [`crates/warpui_core/src/elements/tui/mod.rs (215-292) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/mod.rs#L215-L292) — `layout` must remain side-effect free, while `after_layout` is the settled post-layout side-effect seam. -- [`crates/warpui_core/src/elements/tui/hoverable.rs (40-189) @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/hoverable.rs#L40-L189) — `TuiHoverable` requires stable `MouseStateHandle`s across per-frame element reconstruction and limits hit testing to retained painted bounds. -- [`crates/warpui_core/src/elements/tui/flex.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/flex.rs) — row composition and child layout. -- [`crates/warpui_core/src/elements/tui/text.rs @ caa826c2`](https://github.com/warpdotdev/warp/blob/caa826c2ef395faee32c87c19c533a44ef88d81b/crates/warpui_core/src/elements/tui/text.rs) — styled terminal text rendering. +The TUI separates retained views from per-frame elements: +- `crates/warpui_core/src/core/view/tui.rs (19-75)` defines `TuiView`, whose `render` method produces an element tree from retained state. +- `crates/warpui_core/src/elements/gui/size_constraint_switch.rs (45-153)` is the GUI responsive-layout precedent: it selects a normal child from the current layout constraint and delegates subsequent lifecycle passes to that child. +- `crates/warpui_core/src/elements/tui/mod.rs (205-292)` defines the TUI element lifecycle. +- `crates/warpui_core/src/elements/tui/flex.rs (263-424)`, `container.rs`, `text.rs`, and `hoverable.rs` provide the generic composition, styling, text, and pointer primitives needed by a tab bar. -The component is intentionally domain-neutral. The orchestration integration that first consumes it is specified separately in `specs/code-1822-tui-orchestration-tab-bar/`. +The reusable tab abstraction is a retained view in the `warp_tui` front-end, not a tab-specific element. The core element library supplies the same discrete size-constraint switch pattern as the GUI. -## Proposed changes -### Public component contract -Add `crates/warpui_core/src/elements/tui/tab_bar.rs` and export the component's public types from `crates/warpui_core/src/elements/tui/mod.rs`. Add `crates/warpui_core/src/elements/tui/text_helpers.rs` for shared terminal display-cell measurement and grapheme-safe ellipsis truncation used by the tab bar and existing TUI column formatting. +## Implementation +### GUI-parity size switching +`crates/warpui_core/src/elements/tui/size_constraint_switch.rs` adds `TuiSizeConstraintSwitch` and `TuiSizeConstraintCondition`. -Use a stable string key rather than indices so dynamic reordering cannot retarget callbacks without parameterizing the component and every supporting layout type. The public data surface contains: -- `TuiTab`: string key, label, and an optional factory for caller-rendered leading content. During each layout pass, the component builds the element once, measures it, and moves that same instance into the rendered tab. -- `TuiTabBarStyles`: caller-supplied bar, leading-label, chrome, normal-tab, focused-selected, and unfocused-selected styles. -- `TuiTabBarConfig`: optional product label and main tab, ordered secondary tabs, selected key, focus presentation, page anchor, selected-tab reveal policy, optional maximum label cells, spacing, and styles. Divider and arrow glyphs are component-owned so every caller uses the same row structure. -- `TuiTabBarNavigationDirection`: `Previous` or `Next`. -- `TuiTabBar`: the reusable component retained by the caller and updated/rendered from config. - -The component exposes high-level operations only: -- `render(config, on_event) -> Box` -- `navigation_target(direction) -> Option`, which resolves against private settled layout. - -It does not expose visible indices, visible keys, page boundaries, measured widths, or mouse handles. - -### Private retained state -`TuiTabBar` privately retains: -- `HashMap` for currently supplied tabs. -- Previous/next overflow mouse handles. -- The latest lightweight `SettledNavigation` containing only ordered keys, explicit main-tab identity, selected key, and visible secondary keys. - -Every config update prunes removed keys before rendering. Existing keys reuse their mouse handles. Settled navigation is invalidated before the next layout publishes replacement data. +Like the GUI `SizeConstraintSwitch`, it accepts a default prebuilt child plus ordered conditional children. During layout it selects the first child whose width, height, or combined-size condition matches. Every later lifecycle pass delegates to that same selected child. -The per-frame element receives an internal shared state reference so `after_layout` can publish settled navigation data back to the component without retaining or exposing the render-only page layout. This handle is private to `tab_bar.rs`; it is not part of the public contract described by PRODUCT (7). +The switch contains no tab, paging, or application semantics. It is exported from `crates/warpui_core/src/elements/tui/mod.rs`. -### Layout algorithm -Build and measure caller-provided leading elements through the normal `TuiElement::layout` contract, then pass those widths into a pure layout function. The settled row takes the same measured element instances rather than invoking their factories again. The function returns a `TabBarLayout` with named main-tab, previous-anchor, visible-tab, next-anchor, and navigation fields rather than a generic sequence of layout pieces. +### Generic text ellipsis +`crates/warpui_core/src/elements/tui/text.rs` adds `TuiText::truncate_with_ellipsis`. The text element truncates inside its assigned display-cell width, preserves grapheme boundaries and span styles, and uses as much of `...` as fits. Tab rendering therefore does not construct pre-truncated strings. -The algorithm: -1. Measure fixed caller-supplied leading content, the optional main tab, each tab's caller-rendered leading element, and the component-owned divider. -2. Normalize each label to the optional maximum display-cell width. -3. Resolve the requested page anchor against the ordered secondary keys, clamping a missing anchor. -4. Reserve a previous overflow control when the page does not start at the first secondary tab. -5. Pack secondary tabs from the anchor while reserving a next overflow control whenever later tabs remain. -6. If the final otherwise-visible tab does not fit in full, shrink its label to the remaining display cells while preserving its leading content and required next control. -7. Omit a secondary tab rather than produce invalid or negative-width geometry when the row is too narrow. -8. Build one deterministic page sequence from the beginning. Use that sequence for previous-page anchors and selected-tab reveal so all page progression shares one strictly advancing rule. +### Retained tab-bar view +`crates/warp_tui/src/tab_bar.rs` defines: +- `TuiTab`: stable string key, label, and optional styled leading text. +- `TuiTabBarStyles`: caller-supplied bar, leading-label, chrome, normal-tab, focused-selected, and unfocused-selected styles. +- `TuiTabBarConfig`: optional product label and main tab, ordered secondary tabs, selected key, focus presentation, page anchor, selected-tab reveal policy, optional maximum label cells, spacing, and styles. +- `TuiTabBarEvent`: semantic `SelectTab` and `PageChanged` outcomes. +- `TuiTabBarNavigationDirection` and `TuiTabBarSecondaryEdge`: semantic keyboard target requests. +- `TuiTabBarView`: retained view state and responsive rendering. + +The view is registered as a typed-action TUI view. Click handlers on generic `TuiHoverable` elements dispatch private component actions; `TuiTabBarView::handle_action` converts those actions into public view events for its owner. + +### State ownership +`TuiTabBarView` retains: +- `HashMap` for currently supplied tab keys. +- One mouse handle for each overflow arrow. +- The latest caller-supplied `TuiTabBarConfig`. + +`set_config` replaces semantic inputs, prunes removed mouse handles, creates handles for new keys, and notifies the view. Application selection, focus, and page anchors remain caller-owned. + +### Responsive row composition +`TuiTabBarView::render` prebuilds one row alternative for each distinct visible-tab count. `TuiSizeConstraintSwitch` selects the row during layout. Each row is composed only from: +- `TuiFlex` for row ordering; +- `TuiFlex::with_spacing` for gaps between leading text and labels, tabs, and overflow controls; +- `TuiText` for labels, divider, and arrows; +- `TuiConstrainedBox` for configured maximum label and tab widths; +- `TuiContainer` for tab and divider padding plus backgrounds; +- `TuiHoverable` for hover and click behavior. + +The static threshold calculation: +1. Measures known text and padding in terminal display cells. +2. Reserves the optional caller label, fixed main tab, and divider. +3. Resolves the requested secondary page anchor, falling back to the first page. +4. Computes the minimum row width for each possible visible-tab count. +5. Reserves a previous control only when the page starts after the first secondary tab. +6. Reserves a next control only when the page ends before the last secondary tab. +7. Gives the final visible tab the remaining flex width; `TuiText` applies ellipsis within that slot. + +Next-page anchors begin after the final visible tab. Previous-page anchors move backward by the current visible count. This preserves page-sized navigation without exposing layout geometry to the caller. + +`crates/warpui_core/src/elements/tui/text_helpers.rs` continues to provide shared display-cell measurement and string truncation for non-element formatting such as `crates/warp_tui/src/tui_column_layout.rs`. + +### Navigation +Keyboard target methods depend only on semantic tab order: +- Previous/next navigation uses the optional main tab followed by all secondary tabs and wraps. +- First/last-secondary navigation reads the edges of the secondary list. + +The caller applies returned keys, updates its authoritative selection/page models, and resynchronizes the view config. -Use terminal display-cell width and grapheme-safe truncation. Keep the ellipsis inside the requested width. The component paints from the returned layout rather than independently remeasuring, so rendering, hit testing, overflow callbacks, and navigation share one result. +## Testing and validation +`crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs` covers default, ordered width/height, and combined-size selection. -### Semantic interaction dispatch -Wrap every painted tab and overflow control in `TuiHoverable` with component-owned mouse state: -- A tab click invokes `SelectTab(key)`. -- A previous/next overflow click invokes `PageChanged(anchor_key)` from the settled layout. -- A hovered tab bolds its label, and a hovered overflow control bolds its arrow. -- Neither callback changes focus or application state directly. +`crates/warpui_core/src/elements/tui/text_tests.rs` covers constraint-aware ellipsis, grapheme preservation, and span styling. -`navigation_target(Previous | Next)` uses the private settled layout: -- If the selected key is visible, resolve the adjacent key from the complete supplied sequence of main tab followed by secondary tabs and wrap. -- If the selected key is off-page, resolve `Previous` to the last visible secondary key and `Next` to the first visible secondary key. -- If no target exists, return `None`. +`crates/warp_tui/src/tab_bar_tests.rs` covers: +- page anchors and selected-tab reveal; +- strictly increasing visible-count thresholds; +- narrow-width ellipsis and next-control preservation; +- start, middle, and end overflow-control visibility; +- semantic navigation and secondary edges; +- selected and leading-text styles rendered through generic elements; and +- retained mouse-state reuse and removed-key pruning. -The consuming view remains responsible for binding keys, forwarding directions, and applying the returned semantic target. This keeps keymap and application-selection policy outside the component while keeping width-dependent target resolution inside it. +`crates/warpui_core/src/elements/tui/text_helpers_tests.rs` covers display-cell measurement and grapheme-safe truncation. -## Testing and validation -Add focused, scenario-based unit coverage: -- fixed-main layout, explicit/missing page anchors, and complete navigation order; -- overflow reservation, final-tab truncation, and strict next-page progress at narrow widths; -- explicit page ownership and selected-tab reveal stability; -- visible wraparound and off-page keyboard navigation without a main tab; -- selected styling and one-build-per-layout caller leading elements; -- tab and overflow hover treatments; -- semantic tab-selection and page-change click events; -- retained mouse-state reuse and removed-key pruning; and -- shared display-cell measurement and grapheme-safe truncation in `text_helpers_tests.rs`. - -Run: -- `cargo test -p warpui_core --features tui tab_bar` +Validation commands: +- `cargo test -p warpui_core --features tui size_constraint_switch` +- `cargo test -p warpui_core --features tui ellipsis` +- `cargo test -p warp_tui tab_bar` - `cargo test -p warpui_core --features tui text_helpers` - `cargo test -p warp_tui tui_column_layout` - `./script/format` - `cargo clippy -p warpui_core --features tui --tests -- -D warnings` - `cargo clippy -p warp_tui --tests -- -D warnings` -## Parallelization -Do not split this component across child agents. Its public contract, private retained state, layout result, and event tests are tightly coupled and should land as one coherent PR. Long-running workspace validation can run separately after focused tests pass. - ## Risks and mitigations -- **Public geometry leakage:** keep `TabBarLayout` and `SettledNavigation` private to `tab_bar.rs`; expose semantic callbacks only. -- **Layout/event disagreement:** paint and dispatch from one settled layout result. -- **Stale navigation after mutation:** invalidate private layout on config changes and resolve callback keys against the latest supplied key set. -- **Unicode width corruption:** keep one grapheme-safe display-cell truncation helper in `text_helpers.rs` and use it from the tab bar and two-column formatter. -- **Hover-state churn:** key mouse state by stable tab key and prune only removed keys. +- **Responsive policy leaking into the element library:** the switch knows only about conditions and child lifecycle delegation, matching the GUI primitive. +- **Layout/event disagreement:** each prebuilt row owns its matching visible tabs and overflow callbacks; the switch delegates all passes to one selected row. +- **Variant growth:** alternatives are created only when the visible-tab count changes, not for every terminal column. +- **Stale pointer state:** `set_config` keys mouse handles by stable tab identity and prunes removed keys. +- **Unicode width corruption:** `TuiText` measures terminal display width and truncates only at grapheme boundaries. +- **Application state divergence:** the view emits semantic events and never mutates caller-owned selection, focus, or page models.