From a7c155e5c32fcc811c9b7261750138385b2bea12 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Jun 2026 13:41:39 +0900 Subject: [PATCH] feat: add workspace panel composer targets --- crates/tui/src/multi_pod.rs | 319 ++++++++++++++++++++++++++++-- crates/tui/src/workspace_panel.rs | 53 +++++ 2 files changed, 358 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 61a9ad7b..da595883 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -2,8 +2,11 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use client::ticket_role::{TicketRole, TicketRoleLaunchContext}; -use client::{PodRuntimeCommand, SpawnConfig, launch_ticket_role_pod, spawn_pod}; +use client::ticket_role::{ + TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchResult, + launch_ticket_role_pod, +}; +use client::{PodRuntimeCommand, SpawnConfig, spawn_pod}; use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read}; use pod_store::FsPodStore; use protocol::stream::{JsonLineReader, JsonLineWriter}; @@ -25,9 +28,9 @@ use crate::pod_list::{ read_stored_pod_infos, }; use crate::workspace_panel::{ - ActionPriority, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, - OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, PanelRowKey, - TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic, + ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, + OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, + PanelRowKey, TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic, build_workspace_panel, decide_orchestrator_lifecycle, orchestrator_pod_presence, ticket_config_availability, workspace_orchestrator_pod_name, }; @@ -141,6 +144,16 @@ pub(crate) async fn run( app.reload_or_notice().await; next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } + MultiPodAction::LaunchIntake(request) => { + pending_reload.abort(); + terminal.draw(|f| draw(f, app))?; + let result = + launch_ticket_role_pod(request.context, request.runtime_command, |_| {}) + .await; + app.finish_intake_launch(result); + app.reload_or_notice().await; + next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + } }, TermEvent::Paste(text) => app.input.insert_paste(text), TermEvent::Resize(_, _) => {} @@ -242,13 +255,21 @@ pub(crate) struct DirectSendRequest { segments: Vec, } +#[derive(Debug)] +pub(crate) struct IntakeLaunchRequest { + context: TicketRoleLaunchContext, + runtime_command: PodRuntimeCommand, +} + pub(crate) struct MultiPodApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, pub(crate) input: InputBuffer, selected_row: Option, + composer_target: ComposerTarget, notice: Option, sending: bool, + runtime_command: PodRuntimeCommand, } impl MultiPodApp { @@ -258,7 +279,9 @@ impl MultiPodApp { ) -> Result { let snapshot = load_multi_pod_snapshot( selected_name, - OrchestratorLifecycleMode::Ensure { runtime_command }, + OrchestratorLifecycleMode::Ensure { + runtime_command: runtime_command.clone(), + }, ) .await?; let mut app = Self { @@ -266,10 +289,13 @@ impl MultiPodApp { panel: snapshot.panel, input: InputBuffer::new(), selected_row: None, + composer_target: ComposerTarget::Companion, notice: None, sending: false, + runtime_command, }; app.ensure_selection_visible(); + app.ensure_composer_target_available(); Ok(app) } @@ -321,6 +347,7 @@ impl MultiPodApp { self.panel = snapshot.panel; self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some()); self.ensure_selection_visible(); + self.ensure_composer_target_available(); } fn selected_panel_row(&self) -> Option<&PanelRow> { @@ -451,6 +478,34 @@ impl MultiPodApp { self.selected_row = Some(key); } + fn ensure_composer_target_available(&mut self) { + if !self.panel.composer.is_available(self.composer_target) { + self.composer_target = ComposerTarget::Companion; + } + } + + pub(crate) fn cycle_composer_target(&mut self) { + let targets = &self.panel.composer.available_targets; + if targets.len() <= 1 { + self.composer_target = ComposerTarget::Companion; + self.notice = Some( + "Ticket Intake target is unavailable without usable Ticket config.".to_string(), + ); + return; + } + let current = targets + .iter() + .position(|target| *target == self.composer_target) + .unwrap_or(0); + let next = targets[(current + 1) % targets.len()]; + self.composer_target = next; + self.notice = Some(format!("Composer target: {}", next.label())); + } + + pub(crate) fn composer_target(&self) -> ComposerTarget { + self.composer_target + } + pub(crate) fn prepare_open(&mut self) -> Option { let (pod_name, socket_override) = { let entry = match self.selected_pod_entry() { @@ -544,6 +599,56 @@ impl MultiPodApp { } } + pub(crate) fn prepare_intake_launch(&mut self) -> Option { + if !self + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + self.composer_target = ComposerTarget::Companion; + self.notice = Some( + "Ticket Intake target is unavailable without usable Ticket config.".to_string(), + ); + return None; + } + let body = Segment::flatten_to_text(&self.input.submit_segments()); + if body.trim().is_empty() { + self.notice = Some("Ticket Intake input is empty; type a request first.".to_string()); + return None; + } + let mut context = + TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); + context.user_instruction = Some(body); + self.sending = true; + self.notice = Some("Launching Ticket Intake…".to_string()); + Some(IntakeLaunchRequest { + context, + runtime_command: self.runtime_command.clone(), + }) + } + + pub(crate) fn finish_intake_launch( + &mut self, + result: Result, + ) { + self.sending = false; + match result { + Ok(result) => { + let pod_name = result.plan.pod_name; + self.input.clear(); + self.notice = Some(bounded_panel_diagnostic(format!( + "Launched Ticket Intake Pod {pod_name}." + ))); + } + Err(error) => { + self.notice = Some(format!( + "Intake launch failed; composer kept: {}", + bounded_panel_diagnostic(error.to_string()) + )); + } + } + } + fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); @@ -567,12 +672,20 @@ impl MultiPodApp { self.select_next(); MultiPodAction::None } + KeyCode::Char('t') if ctrl => { + self.cycle_composer_target(); + MultiPodAction::None + } KeyCode::Char('o') if !ctrl && !alt => MultiPodAction::Open, KeyCode::Char('r') if !ctrl && !alt => MultiPodAction::Refresh, KeyCode::Enter if alt => { self.input.insert_newline(); MultiPodAction::None } + KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake => self + .prepare_intake_launch() + .map(MultiPodAction::LaunchIntake) + .unwrap_or(MultiPodAction::None), KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open, KeyCode::Enter => self .prepare_send() @@ -617,6 +730,7 @@ enum MultiPodAction { Open, Refresh, Send(DirectSendRequest), + LaunchIntake(IntakeLaunchRequest), } #[derive(Debug, Clone)] @@ -1222,7 +1336,13 @@ fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) - } fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let guidance = if app.panel.header.ticket_configured { + let guidance = if app + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + " Ticket actions are display-only · Enter uses composer target · Ctrl+T target · o open/attach · r refresh" + } else if app.panel.header.ticket_configured { " Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh" } else { " Pod-centric view · Enter sends to selected idle Pod · o open/attach · r refresh" @@ -1485,7 +1605,7 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) { } fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let line = if let Some(row) = app + let mut line = if let Some(row) = app .selected_panel_row() .filter(|row| row.is_ticket_action()) { @@ -1544,7 +1664,21 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { )), } }; - frame.render_widget(Paragraph::new(line), area); + let mut prefix = vec![ + Span::styled("composer ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(match app.composer_target() { + ComposerTarget::Companion => Color::Green, + ComposerTarget::TicketIntake => Color::Magenta, + }) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(Color::DarkGray)), + ]; + prefix.append(&mut line.spans); + frame.render_widget(Paragraph::new(Line::from(prefix)), area); } fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { @@ -1566,19 +1700,40 @@ fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: R } fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let left = if app.sending { + let left = if app.sending && app.composer_target() == ComposerTarget::TicketIntake { + "launching Ticket Intake…".to_string() + } else if app.sending { "sending…".to_string() } else if let Some(notice) = app.notice.as_deref() { notice.to_string() } else if let Some(reason) = app.selected_send_disabled_reason() { reason } else { - "idle live target: Enter sends directly without opening conversation".to_string() + match app.composer_target() { + ComposerTarget::Companion => { + "idle live target: Enter sends directly without opening conversation".to_string() + } + ComposerTarget::TicketIntake => { + "Ticket Intake target: Enter launches Intake with composer text".to_string() + } + } }; - let right = "↑/↓ select Enter send o open r refresh Esc quit"; + let right = if app + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + "↑/↓ select Enter use target Ctrl+T target o open r refresh Esc quit" + } else { + "↑/↓ select Enter send o open r refresh Esc quit" + }; + let left_width = area + .width + .saturating_sub(right.width() as u16) + .saturating_sub(2) as usize; frame.render_widget( Paragraph::new(Line::from(Span::styled( - truncate_with_ellipsis(&left, area.width.saturating_sub(42) as usize), + truncate_with_ellipsis(&left, left_width), Style::default().fg(Color::DarkGray), ))), area, @@ -2193,6 +2348,126 @@ mod tests { assert!(app.notice.as_deref().unwrap().contains("Sending to idle")); } + #[test] + fn multi_composer_target_switch_preserves_typed_text() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("draft intake request"); + + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + assert!(matches!( + app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)), + MultiPodAction::None + )); + + assert!(matches!( + app.composer_target(), + ComposerTarget::TicketIntake + )); + assert_eq!(input_text(&app), "draft intake request"); + assert!(app.notice.as_deref().unwrap().contains("Ticket Intake")); + } + + #[test] + fn multi_no_ticket_workspace_exposes_only_companion_target() { + let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("draft message"); + + app.cycle_composer_target(); + + assert_eq!( + app.panel.composer.available_targets, + vec![ComposerTarget::Companion] + ); + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + assert_eq!(input_text(&app), "draft message"); + assert!(app.notice.as_deref().unwrap().contains("unavailable")); + } + + #[test] + fn multi_ticket_intake_rejects_empty_input() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str(" \n\t"); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + MultiPodAction::None + )); + + assert!(matches!( + app.composer_target(), + ComposerTarget::TicketIntake + )); + assert!(!app.sending); + assert_eq!(input_text(&app), " \n\t"); + assert!(app.notice.as_deref().unwrap().contains("input is empty")); + } + + #[test] + fn multi_ticket_intake_enter_builds_launch_request_not_direct_send() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str("please intake this work"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + MultiPodAction::LaunchIntake(request) => request, + MultiPodAction::Send(_) => panic!("Ticket Intake target must not direct-send"), + _ => panic!("Ticket Intake target should launch Intake"), + }; + + assert_eq!(request.context.role, TicketRole::Intake); + assert_eq!( + request.context.user_instruction.as_deref(), + Some("please intake this work") + ); + assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi")); + assert!(app.sending); + assert!(app.notice.as_deref().unwrap().contains("Launching")); + assert_eq!(input_text(&app), "please intake this work"); + } + + #[test] + fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str("please intake this work"); + app.sending = true; + + app.finish_intake_launch(Ok(TicketRoleLaunchResult { + plan: client::ticket_role::TicketRoleLaunchPlan { + workspace_root: PathBuf::from("/tmp/workspace"), + role: TicketRole::Intake, + pod_name: "intake-pod".to_string(), + profile: "builtin:default".to_string(), + workflow: "ticket-intake-workflow".to_string(), + launch_prompt_ref: None, + run_segments: vec![], + }, + ready: client::SpawnReady { + pod_name: "intake-pod".to_string(), + socket_path: PathBuf::from("/tmp/intake.sock"), + }, + })); + + assert!(!app.sending); + assert_eq!(input_text(&app), ""); + assert!(app.notice.as_deref().unwrap().contains("intake-pod")); + } + + #[test] + fn multi_ticket_intake_finish_failure_keeps_composer() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str("please keep this"); + app.sending = true; + + app.finish_intake_launch(Err(TicketRoleLaunchError::EmptyPodName)); + + assert!(!app.sending); + assert_eq!(input_text(&app), "please keep this"); + assert!(app.notice.as_deref().unwrap().contains("composer kept")); + } + #[test] fn multi_empty_enter_on_non_openable_row_matches_o_diagnostic() { let mut enter_app = test_app(vec![unreachable_live_info("unreachable")]); @@ -2223,6 +2498,15 @@ mod tests { )) } + fn ticket_enabled_app(live: Vec) -> MultiPodApp { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); + app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), + panel, + ) + } + fn app_with_list(list: PodList) -> MultiPodApp { app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test"))) } @@ -2233,10 +2517,13 @@ mod tests { panel, input: InputBuffer::new(), selected_row: None, + composer_target: ComposerTarget::Companion, notice: None, sending: false, + runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), }; app.ensure_selection_visible(); + app.ensure_composer_target_available(); app } @@ -2320,6 +2607,10 @@ mod tests { } fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) + modified_key(code, KeyModifiers::NONE) + } + + fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) } } diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index e7a8c09e..c10a20ef 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -13,6 +13,7 @@ use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; pub(crate) struct WorkspacePanelViewModel { pub(crate) header: WorkspacePanelHeader, pub(crate) rows: Vec, + pub(crate) composer: WorkspacePanelComposer, } impl WorkspacePanelViewModel { @@ -31,6 +32,7 @@ impl WorkspacePanelViewModel { diagnostics: Vec::new(), }, rows: Vec::new(), + composer: WorkspacePanelComposer::companion_only(), } } @@ -48,6 +50,44 @@ pub(crate) struct WorkspacePanelHeader { pub(crate) diagnostics: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ComposerTarget { + Companion, + TicketIntake, +} + +impl ComposerTarget { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Companion => "Companion", + Self::TicketIntake => "Ticket Intake", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspacePanelComposer { + pub(crate) available_targets: Vec, +} + +impl WorkspacePanelComposer { + pub(crate) fn companion_only() -> Self { + Self { + available_targets: vec![ComposerTarget::Companion], + } + } + + pub(crate) fn ticket_enabled() -> Self { + Self { + available_targets: vec![ComposerTarget::Companion, ComposerTarget::TicketIntake], + } + } + + pub(crate) fn is_available(&self, target: ComposerTarget) -> bool { + self.available_targets.contains(&target) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct OrchestratorPanelState { pub(crate) pod_name: String, @@ -386,6 +426,7 @@ pub(crate) fn build_workspace_panel( TicketConfigAvailability::Absent => {} TicketConfigAvailability::Usable => { model.header.ticket_configured = true; + model.composer = WorkspacePanelComposer::ticket_enabled(); match TicketConfig::load_workspace(workspace_root) { Ok(config) => { model.header.ticket_root = config.backend_root().to_path_buf(); @@ -938,6 +979,10 @@ mod tests { let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); assert!(model.header.diagnostics.is_empty()); + assert_eq!( + model.composer.available_targets, + vec![ComposerTarget::Companion] + ); assert_eq!(model.rows.len(), 1); assert_eq!(model.rows[0].key, PanelRowKey::Pod("idle".to_string())); assert!(model.rows[0].ticket.is_none()); @@ -957,6 +1002,10 @@ mod tests { }); let model = build_workspace_panel(temp.path(), &empty_pods()); + assert_eq!( + model.composer.available_targets, + vec![ComposerTarget::Companion, ComposerTarget::TicketIntake] + ); let rows = model .rows .iter() @@ -1164,6 +1213,10 @@ mod tests { let model = build_workspace_panel(temp.path(), &empty_pods()); assert!(model.header.ticket_configured); + assert_eq!( + model.composer.available_targets, + vec![ComposerTarget::Companion] + ); assert!(model.header.diagnostics.iter().any(|diagnostic| { diagnostic.contains("Ticket config is unusable") && diagnostic.contains("not a regular file")