Skip to content
8 changes: 4 additions & 4 deletions app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::{
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::ai::blocklist::orchestration_topology::{
aggregated_orchestrator_status, descendant_conversation_ids_in_pill_order,
descendant_conversation_ids_in_spawn_order,
aggregated_orchestrator_status, descendant_conversation_ids_in_spawn_order,
descendant_conversations_in_pill_order,
};
use crate::ai::blocklist::telemetry::{
BlocklistOrchestrationTelemetryEvent, PillBarActionKind, PillBarInteractionEvent,
Expand Down Expand Up @@ -613,9 +613,9 @@ impl OrchestrationPillBar {

// Use the shared canonical pill ordering so the visible row and
// keyboard navigation cannot drift.
let children: Vec<_> = descendant_conversation_ids_in_pill_order(history, orchestrator_id)
let children: Vec<_> = descendant_conversations_in_pill_order(history, orchestrator_id)
.into_iter()
.filter_map(|id| history.conversation(&id))
.filter_map(|descendant| history.conversation(&descendant.conversation_id))
.collect();

// Nothing to show if the orchestrator has no children yet.
Expand Down
4 changes: 4 additions & 0 deletions app/src/ai/blocklist/history_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,10 @@ impl BlocklistAIHistoryModel {
}
conversation.set_pinned(pinned);
conversation.write_updated_conversation_state(ctx);
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationMetadata {
terminal_surface_id: self.terminal_surface_id_for_conversation(&conversation_id),
conversation_id,
});
}

/// Sets a live conversation's server token, updates the reverse index, and
Expand Down
53 changes: 47 additions & 6 deletions app/src/ai/blocklist/orchestration_topology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//! orchestration pill bar so other surfaces (e.g. keyboard navigation and
//! the agent-mode usage footer's credit rollup) can walk and order the same
//! tree without duplicating the logic.
#[cfg(feature = "tui")]
use std::collections::HashSet;

use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::blocklist::BlocklistAIHistoryModel;
Expand Down Expand Up @@ -100,6 +102,29 @@ pub fn resolve_orchestration_participant(
}
}

/// Returns the topmost loaded conversation in an orchestration tree.
///
/// Conversations without descendants are not orchestration roots. Malformed
/// parent cycles and missing ancestors fail closed.
#[cfg(feature = "tui")]
pub fn orchestration_root_conversation_id(
history: &BlocklistAIHistoryModel,
conversation_id: AIConversationId,
) -> Option<AIConversationId> {
history.conversation(&conversation_id)?;
let mut current = conversation_id;
let mut visited = HashSet::new();
while visited.insert(current) {
let conversation = history.conversation(&current)?;
let Some(parent) = history.resolved_parent_conversation_id_for_conversation(conversation)
else {
return (!history.child_conversation_ids_of(&current).is_empty()).then_some(current);
};
current = parent;
}
None
}

const DONE_STATUS_KEY: u8 = 3;

fn pill_status_sort_key(status: Option<&ConversationStatus>) -> u8 {
Expand Down Expand Up @@ -181,6 +206,13 @@ pub fn has_local_orchestrated_children(
})
}

/// One descendant in canonical orchestration pill order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OrderedOrchestrationDescendant {
pub conversation_id: AIConversationId,
pub spawn_index: usize,
}

/// Returns descendants in the canonical orchestration pill order:
/// 1) pinned children
/// 2) unpinned children
Expand All @@ -189,10 +221,10 @@ pub fn has_local_orchestrated_children(
/// This is the single ordering source used by both the pill bar and keyboard
/// navigation. Callers should preserve the returned order rather than sorting
/// the conversations again.
pub fn descendant_conversation_ids_in_pill_order(
pub fn descendant_conversations_in_pill_order(
history: &BlocklistAIHistoryModel,
parent_id: AIConversationId,
) -> Vec<AIConversationId> {
) -> Vec<OrderedOrchestrationDescendant> {
let mut descendants = descendant_conversation_ids_in_spawn_order(history, parent_id)
.into_iter()
.enumerate()
Expand Down Expand Up @@ -223,7 +255,12 @@ pub fn descendant_conversation_ids_in_pill_order(
);
descendants
.into_iter()
.map(|(_, _, _, _, conversation_id)| conversation_id)
.map(
|(_, _, _, spawn_index, conversation_id)| OrderedOrchestrationDescendant {
conversation_id,
spawn_index,
},
)
.collect()
}

Expand All @@ -243,12 +280,16 @@ pub fn adjacent_orchestration_child_conversation_id(
let orchestration_root_id = history
.resolved_parent_conversation_id_for_conversation(active_conversation)
.unwrap_or(active_conversation_id);
let descendant_ids = descendant_conversation_ids_in_pill_order(history, orchestration_root_id);
if descendant_ids.is_empty() {
let descendants = descendant_conversations_in_pill_order(history, orchestration_root_id);
if descendants.is_empty() {
return None;
}
let conversation_ids = std::iter::once(orchestration_root_id)
.chain(descendant_ids)
.chain(
descendants
.into_iter()
.map(|descendant| descendant.conversation_id),
)
.collect::<Vec<_>>();

let active_index = conversation_ids
Expand Down
6 changes: 4 additions & 2 deletions app/src/tui_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ 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,
descendant_conversation_ids_in_spawn_order, descendant_conversations_in_pill_order,
orchestration_root_conversation_id, orchestrator_agent_id_for_conversation,
resolve_orchestration_participant, OrchestrationParticipantKind,
OrderedOrchestrationDescendant, ResolvedOrchestrationParticipant,
};
pub use crate::ai::blocklist::view_util::format_credits;
#[cfg(not(target_family = "wasm"))]
Expand Down
44 changes: 43 additions & 1 deletion crates/warp_tui/src/input/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
//! See `specs/tui-input-view/TECH.md` for the full keybinding table.

use std::ops::Range;
use std::rc::Rc;

use string_offset::CharOffset;
use warp::editor::{CodeEditorModel, CodeEditorModelEvent};
Expand Down Expand Up @@ -115,6 +116,8 @@ pub enum TuiInputViewEvent {
AcceptedModel(LLMId),
/// The user selected an action from the MCP menu.
AcceptedMcp(TuiMcpAction),
/// Shift+Up should move focus from the first visual row to the region above.
MoveFocusUp,
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -170,6 +173,8 @@ pub struct TuiInputView {
/// construction always provides this; isolated input tests omit it.
transcript: Option<ViewHandle<TuiTranscriptView>>,
keyboard_enhancement_supported: bool,
/// Consults the owner live before Shift+Up leaves the first visual row.
can_move_focus_up: Rc<dyn Fn(&AppContext) -> bool>,
}

impl Entity for TuiInputView {
Expand All @@ -196,6 +201,7 @@ impl TuiInputView {
suggestions_mode: ModelHandle<TuiInputSuggestionsModeModel>,
inline_menus: Vec<TuiInlineMenu>,
transcript: ViewHandle<TuiTranscriptView>,
can_move_focus_up: impl Fn(&AppContext) -> bool + 'static,
ctx: &mut ViewContext<Self>,
) -> Self {
Self::new_internal(
Expand All @@ -204,6 +210,7 @@ impl TuiInputView {
suggestions_mode,
inline_menus,
Some(transcript),
can_move_focus_up,
ctx,
)
}
Expand All @@ -214,9 +221,18 @@ impl TuiInputView {
input_mode: ModelHandle<BlocklistAIInputModel>,
suggestions_mode: ModelHandle<TuiInputSuggestionsModeModel>,
inline_menus: Vec<TuiInlineMenu>,
can_move_focus_up: impl Fn(&AppContext) -> bool + 'static,
ctx: &mut ViewContext<Self>,
) -> Self {
Self::new_internal(model, input_mode, suggestions_mode, inline_menus, None, ctx)
Self::new_internal(
model,
input_mode,
suggestions_mode,
inline_menus,
None,
can_move_focus_up,
ctx,
)
}

fn new_internal(
Expand All @@ -225,6 +241,7 @@ impl TuiInputView {
suggestions_mode: ModelHandle<TuiInputSuggestionsModeModel>,
inline_menus: Vec<TuiInlineMenu>,
transcript: Option<ViewHandle<TuiTranscriptView>>,
can_move_focus_up: impl Fn(&AppContext) -> bool + 'static,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(&model, |_, _, event, ctx| {
Expand All @@ -247,6 +264,7 @@ impl TuiInputView {
focused: false,
transcript,
keyboard_enhancement_supported: false,
can_move_focus_up: Rc::new(can_move_focus_up),
}
}

Expand Down Expand Up @@ -463,6 +481,10 @@ impl TypedActionView for TuiInputView {
TuiEditorInteractionOutcome::FollowCursor
}
TuiInputAction::EditorCommand(command) => {
if matches!(*command, TuiEditorCommand::SelectUp) && self.can_focus_above(ctx) {
ctx.emit(TuiInputViewEvent::MoveFocusUp);
return;
}
// 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
Expand Down Expand Up @@ -562,6 +584,26 @@ impl TuiInputView {
self.cursor_offset(ctx).as_usize() <= 1 && self.selection_range(ctx).is_none()
}

/// Whether Shift+Up should leave the input instead of extending selection.
fn can_focus_above(&self, ctx: &AppContext) -> bool {
if !(self.can_move_focus_up)(ctx) || self.selection_range(ctx).is_some() {
return false;
}

let model = self.model.as_ref(ctx);
let render = model.render_state().as_ref(ctx);
let Some(char_cell) = render.char_cell() else {
return false;
};

let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1));
let hidden = char_cell.hidden_line_ranges(ctx);
char_cell
.display_lattice(&hidden)
.offset_to_display_point(cursor_offset)
.is_some_and(|point| point.row == 0)
}

// ── Scroll ─────────────────────────────────────────────────────────────
//
// The scroll offset and its clamping/follow policy live on the char-cell
Expand Down
Loading
Loading