From e125ebb37435e9cc4946daf7a1984ee9100c8d0d Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Jun 2026 04:57:10 +0900 Subject: [PATCH] feat: add TUI ticket role commands --- crates/client/src/ticket_role.rs | 3 +- crates/tui/src/app.rs | 10 +- crates/tui/src/command.rs | 205 +++++++++++++++++++++++++++++++ crates/tui/src/single_pod.rs | 170 ++++++++++++++++++++++--- 4 files changed, 371 insertions(+), 17 deletions(-) diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index 111656ea..a7afc59e 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -10,7 +10,8 @@ use std::time::Duration; use protocol::{ErrorCode, Event, InvokeKind, Method, Segment}; use thiserror::Error; -use ticket::config::{TicketConfig, TicketConfigError, TicketRole}; +pub use ticket::config::TicketRole; +use ticket::config::{TicketConfig, TicketConfigError}; use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, spawn_pod}; diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 9e105e3a..045e049e 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -11,7 +11,8 @@ use crate::block::{ }; use crate::cache::FileCache; use crate::command::{ - CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry, + CommandAction, CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, + CommandRegistry, }; use crate::input::InputBuffer; use crate::scroll::Scroll; @@ -246,6 +247,7 @@ pub struct App { pub input_mode: CommandInputMode, pub command_registry: CommandRegistry, command_completion_selected: Option, + pending_command_action: Option, pub quit: bool, /// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// records the instant; a second press within the timeout exits the @@ -314,6 +316,7 @@ impl App { input_mode: CommandInputMode::Composer, command_registry: CommandRegistry::default(), command_completion_selected: None, + pending_command_action: None, quit: false, quit_confirm: None, blocks: Vec::new(), @@ -1626,9 +1629,14 @@ impl App { self.rewind_picker = None; self.rewind_request_pending = true; } + self.pending_command_action = result.action; result.method } + pub fn take_pending_command_action(&mut self) -> Option { + self.pending_command_action.take() + } + fn push_command_diagnostic(&mut self, message: impl Into) { self.blocks.push(Block::Alert { level: AlertLevel::Warn, diff --git a/crates/tui/src/command.rs b/crates/tui/src/command.rs index 80564fd3..df75d102 100644 --- a/crates/tui/src/command.rs +++ b/crates/tui/src/command.rs @@ -1,3 +1,4 @@ +use client::ticket_role::TicketRole; use protocol::Method; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -49,9 +50,22 @@ pub struct CommandEnvironment { pub paused: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandAction { + TicketRole(TicketRoleCommand), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRoleCommand { + pub role: TicketRole, + pub ticket: Option, + pub instruction: Option, +} + #[derive(Debug, Clone)] pub struct CommandExecution { pub method: Option, + pub action: Option, pub diagnostics: Vec, pub exit_command_mode: bool, pub clear_input: bool, @@ -61,6 +75,7 @@ impl CommandExecution { pub fn diagnostic(message: impl Into) -> Self { Self { method: None, + action: None, diagnostics: vec![CommandDiagnostic::new(message)], exit_command_mode: false, clear_input: false, @@ -70,6 +85,17 @@ impl CommandExecution { pub fn notice(message: impl Into) -> Self { Self { method: None, + action: None, + diagnostics: vec![CommandDiagnostic::new(message)], + exit_command_mode: true, + clear_input: true, + } + } + + pub fn local_action(message: impl Into, action: CommandAction) -> Self { + Self { + method: None, + action: Some(action), diagnostics: vec![CommandDiagnostic::new(message)], exit_command_mode: true, clear_input: true, @@ -165,6 +191,15 @@ impl CommandRegistry { can_execute: peer_available, executor: peer_command, }); + registry.register(CommandSpec { + name: "ticket", + aliases: &[], + usage: "ticket ...", + description: "Launch a fixed Ticket role Pod using .yoi/ticket.config.toml.", + argument_parser: ticket_args, + can_execute: always_available, + executor: ticket_command, + }); registry } @@ -322,6 +357,34 @@ fn peer_args(raw: &str) -> Result { } } +fn ticket_args(raw: &str) -> Result { + let args = CommandArgs::parse_whitespace(raw); + let Some(action) = args.argv().first().map(String::as_str) else { + return Err(CommandDiagnostic::new( + "Invalid arguments. Usage: ticket ...", + )); + }; + match action { + "intake" => Ok(args), + "route" | "investigate" | "implement" | "review" if args.argv().len() >= 2 => Ok(args), + "route" => Err(CommandDiagnostic::new( + "Invalid arguments. Usage: ticket route [instruction...]", + )), + "investigate" => Err(CommandDiagnostic::new( + "Invalid arguments. Usage: ticket investigate [instruction...]", + )), + "implement" => Err(CommandDiagnostic::new( + "Invalid arguments. Usage: ticket implement [instruction...]", + )), + "review" => Err(CommandDiagnostic::new( + "Invalid arguments. Usage: ticket review [instruction...]", + )), + _ => Err(CommandDiagnostic::new(format!( + "Unknown ticket action: {action}. Usage: ticket ..." + ))), + } +} + fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { if !environment.connected { return Err(CommandDiagnostic::new( @@ -410,6 +473,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.args.raw(); CommandExecution { method: Some(Method::Compact), + action: None, diagnostics: vec![CommandDiagnostic::new("compact requested")], exit_command_mode: true, clear_input: true, @@ -422,6 +486,7 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution { let _ = invocation.args.raw(); CommandExecution { method: Some(Method::ListRewindTargets), + action: None, diagnostics: vec![CommandDiagnostic::new("rewind picker requested")], exit_command_mode: true, clear_input: true, @@ -434,6 +499,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution { let name = invocation.args.argv()[0].clone(); CommandExecution { method: Some(Method::RegisterPeer { name: name.clone() }), + action: None, diagnostics: vec![CommandDiagnostic::new(format!( "peer metadata registration requested with `{name}`" ))], @@ -442,6 +508,76 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution { } } +fn ticket_command(invocation: CommandInvocation<'_>) -> CommandExecution { + let _ = invocation.command; + let _ = invocation.environment; + let Some((action, rest)) = split_first_word(invocation.args.raw()) else { + return CommandExecution::diagnostic( + "Invalid arguments. Usage: ticket ...", + ); + }; + + let Some(role) = ticket_role_for_action(action) else { + return CommandExecution::diagnostic(format!( + "Unknown ticket action: {action}. Usage: ticket ..." + )); + }; + + let (ticket, instruction) = if action == "intake" { + (None, non_empty_string(rest)) + } else { + let Some((ticket, rest)) = split_first_word(rest) else { + return CommandExecution::diagnostic(format!( + "Invalid arguments. Usage: ticket {action} [instruction...]" + )); + }; + (Some(ticket.to_owned()), non_empty_string(rest)) + }; + + CommandExecution::local_action( + format!("ticket {action} launch requested"), + CommandAction::TicketRole(TicketRoleCommand { + role, + ticket, + instruction, + }), + ) +} + +fn ticket_role_for_action(action: &str) -> Option { + match action { + "intake" => Some(TicketRole::Intake), + "route" => Some(TicketRole::Orchestrator), + "investigate" => Some(TicketRole::Investigator), + "implement" => Some(TicketRole::Coder), + "review" => Some(TicketRole::Reviewer), + _ => None, + } +} + +fn split_first_word(raw: &str) -> Option<(&str, &str)> { + let trimmed = raw.trim_start(); + if trimmed.is_empty() { + return None; + } + match trimmed.find(char::is_whitespace) { + Some(idx) => { + let (word, rest) = trimmed.split_at(idx); + Some((word, rest.trim_start())) + } + None => Some((trimmed, "")), + } +} + +fn non_empty_string(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -578,6 +714,75 @@ mod tests { } } + #[test] + fn ticket_intake_command_returns_local_ticket_action() { + let registry = CommandRegistry::builtins(); + let result = registry.dispatch("ticket intake add role shortcuts", &env()); + assert!(result.method.is_none()); + assert!(result.exit_command_mode); + assert!(result.clear_input); + assert!(result.diagnostics[0].message.contains("ticket intake")); + assert!(matches!( + result.action, + Some(CommandAction::TicketRole(TicketRoleCommand { + role: TicketRole::Intake, + ticket: None, + instruction: Some(ref instruction), + })) if instruction == "add role shortcuts" + )); + } + + #[test] + fn ticket_role_commands_map_to_fixed_roles() { + let registry = CommandRegistry::builtins(); + for (command, role) in [ + ("route", TicketRole::Orchestrator), + ("investigate", TicketRole::Investigator), + ("implement", TicketRole::Coder), + ("review", TicketRole::Reviewer), + ] { + let result = + registry.dispatch(&format!("ticket {command} abc-123 extra context"), &env()); + assert!(result.method.is_none()); + assert!(matches!( + result.action, + Some(CommandAction::TicketRole(TicketRoleCommand { + role: actual_role, + ticket: Some(ref ticket), + instruction: Some(ref instruction), + })) if actual_role == role && ticket == "abc-123" && instruction == "extra context" + )); + } + } + + #[test] + fn ticket_non_intake_requires_ticket_reference() { + let registry = CommandRegistry::builtins(); + let result = registry.dispatch("ticket implement", &env()); + assert!(result.method.is_none()); + assert!(result.action.is_none()); + assert!(!result.exit_command_mode); + assert!( + result.diagnostics[0] + .message + .contains("ticket implement ") + ); + } + + #[test] + fn ticket_unknown_action_is_local_diagnostic() { + let registry = CommandRegistry::builtins(); + let result = registry.dispatch("ticket close abc-123", &env()); + assert!(result.method.is_none()); + assert!(result.action.is_none()); + assert!(!result.exit_command_mode); + assert!( + result.diagnostics[0] + .message + .contains("Unknown ticket action") + ); + } + #[test] fn peer_help_mentions_metadata_registration() { let registry = CommandRegistry::builtins(); diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index e9924d1e..5f3a4dc6 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -20,9 +20,14 @@ use ratatui::backend::CrosstermBackend; use session_store::SegmentId; use tokio::sync::mpsc; -use client::{PodClient, PodRuntimeCommand}; +use client::ticket_role::TicketRef; +use client::{ + PodClient, PodRuntimeCommand, TicketRoleLaunchContext, TicketRoleLaunchError, + launch_ticket_role_pod, +}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; +use crate::command::{CommandAction, TicketRoleCommand}; use crate::picker::PickerOutcome; use crate::spawn::{SpawnOutcome, SpawnReady}; use crate::{multi_pod, picker, spawn, ui}; @@ -48,17 +53,17 @@ pub(crate) async fn run_pod_name( ) -> Result<(), Box> { if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { let mut terminal = enter_fullscreen()?; - run_connected_pod(&mut terminal, pod_name, client).await?; + run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?; return Ok(()); } - let ready = match spawn::run_pod_name(pod_name, runtime_command).await? { + let ready = match spawn::run_pod_name(pod_name, runtime_command.clone()).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; let mut terminal = enter_fullscreen()?; terminal.clear()?; - let result = run_ready_pod(&mut terminal, ready).await; + let result = run_ready_pod(&mut terminal, ready, runtime_command).await; let _ = leave_fullscreen(&mut terminal); result } @@ -67,10 +72,11 @@ async fn run_connected_pod( terminal: &mut FullscreenTerminal, pod_name: String, client: PodClient, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let mut app = App::new(pod_name); app.connected = true; - run_loop(terminal, &mut app, client).await + run_loop(terminal, &mut app, client, runtime_command).await } async fn run_pod_name_nested( @@ -84,11 +90,12 @@ async fn run_pod_name_nested( } = request; if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await { - return run_connected_pod(terminal, pod_name, client).await; + return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await; } - let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command).await?; - run_ready_pod(terminal, ready).await + let ready = + spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command.clone()).await?; + run_ready_pod(terminal, ready, runtime_command).await } async fn spawn_pod_name_from_fullscreen( @@ -131,12 +138,13 @@ impl std::error::Error for NestedOpenCancelled {} async fn run_ready_pod( terminal: &mut FullscreenTerminal, ready: SpawnReady, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let SpawnReady { pod_name, socket_path, } = ready; - run(terminal, pod_name, &socket_path).await + run(terminal, pod_name, &socket_path, runtime_command).await } async fn connect_live_pod( @@ -215,7 +223,7 @@ pub(crate) async fn run_spawn( profile: Option, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let ready = match spawn::run(resume_from, profile, runtime_command).await? { + let ready = match spawn::run(resume_from, profile, runtime_command.clone()).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; @@ -226,7 +234,7 @@ pub(crate) async fn run_spawn( } = ready; let mut terminal = enter_fullscreen()?; - let result = run(&mut terminal, pod_name, &socket_path).await; + let result = run(&mut terminal, pod_name, &socket_path, runtime_command).await; // Leave alt-screen explicitly before `main`'s terminal restore path. let _ = execute!( @@ -268,6 +276,7 @@ async fn run( terminal: &mut FullscreenTerminal, pod_name: String, socket_path: &std::path::Path, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let mut app = App::new(pod_name); @@ -276,7 +285,7 @@ async fn run( app.connected = true; // The Pod sends `Event::Snapshot` automatically on connect; // no explicit method call is required to fetch history. - run_loop(terminal, &mut app, client).await?; + run_loop(terminal, &mut app, client, runtime_command).await?; } Err(e) => { app.push_error(format!( @@ -295,6 +304,7 @@ type TerminalEventResult = io::Result; const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50); const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64; const POD_EVENT_DRAIN_LIMIT: usize = 32; +const TICKET_ROLE_NOTICE_DURATION: Duration = Duration::from_secs(5); struct TerminalEventReader { stop: Arc, @@ -380,13 +390,14 @@ async fn drain_terminal_events( app: &mut App, client: &mut PodClient, term_rx: &mut mpsc::UnboundedReceiver, + runtime_command: &PodRuntimeCommand, ) -> Result> { let mut handled = false; for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { match term_rx.try_recv() { Ok(event) => { handled = true; - handle_terminal_event(app, client, event?).await?; + handle_terminal_event(app, client, event?, runtime_command).await?; if app.quit { break; } @@ -426,6 +437,7 @@ async fn run_loop( terminal: &mut Terminal>, app: &mut App, mut client: PodClient, + runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; @@ -436,7 +448,8 @@ async fn run_loop( break; } - let handled_term_event = drain_terminal_events(app, &mut client, &mut term_rx).await?; + let handled_term_event = + drain_terminal_events(app, &mut client, &mut term_rx, &runtime_command).await?; if app.quit { break; } @@ -448,7 +461,7 @@ async fn run_loop( match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { LoopInput::Terminal(term_event) => { - handle_terminal_event(app, &mut client, term_event?).await?; + handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?; } LoopInput::Pod(event) => match event { Some(ev) => { @@ -474,11 +487,14 @@ async fn handle_terminal_event( app: &mut App, client: &mut PodClient, event: TermEvent, + runtime_command: &PodRuntimeCommand, ) -> Result<(), Box> { match event { TermEvent::Key(key) => { if let Some(method) = handle_key(app, key) { client.send(&method).await?; + } else if let Some(action) = app.take_pending_command_action() { + handle_command_action(app, action, runtime_command).await; } } TermEvent::Mouse(mouse) => { @@ -527,6 +543,96 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent) { } } +async fn handle_command_action( + app: &mut App, + action: CommandAction, + runtime_command: &PodRuntimeCommand, +) { + match action { + CommandAction::TicketRole(command) => { + handle_ticket_role_command(app, command, runtime_command).await; + } + } +} + +async fn handle_ticket_role_command( + app: &mut App, + command: TicketRoleCommand, + runtime_command: &PodRuntimeCommand, +) { + let role_label = command.role.as_str(); + app.flash_actionbar_notice( + format!("Launching ticket {role_label} Pod..."), + ActionbarNoticeLevel::Info, + ActionbarNoticeSource::Tui, + TICKET_ROLE_NOTICE_DURATION, + ); + + let workspace_root = match std::env::current_dir() { + Ok(path) => path, + Err(err) => { + app.flash_actionbar_notice( + format!("Ticket role launch failed: could not resolve current directory: {err}"), + ActionbarNoticeLevel::Error, + ActionbarNoticeSource::Tui, + TICKET_ROLE_NOTICE_DURATION, + ); + return; + } + }; + + let context = ticket_role_launch_context(workspace_root, command); + let mut progress = Vec::new(); + match launch_ticket_role_pod(context, runtime_command.clone(), |message| { + progress.push(message.to_owned()); + }) + .await + { + Ok(result) => { + let profile = result.plan.profile; + app.flash_actionbar_notice( + format!( + "Launched ticket {role_label} Pod `{}` with profile `{profile}`", + result.ready.pod_name + ), + ActionbarNoticeLevel::Info, + ActionbarNoticeSource::Tui, + TICKET_ROLE_NOTICE_DURATION, + ); + } + Err(err) => { + app.flash_actionbar_notice( + format_ticket_role_launch_error(&err), + ActionbarNoticeLevel::Error, + ActionbarNoticeSource::Tui, + TICKET_ROLE_NOTICE_DURATION, + ); + } + } +} + +fn ticket_role_launch_context( + workspace_root: std::path::PathBuf, + command: TicketRoleCommand, +) -> TicketRoleLaunchContext { + let mut context = TicketRoleLaunchContext::new(workspace_root, command.role); + context.ticket = command.ticket.map(TicketRef::slug); + context.user_instruction = command.instruction; + context +} + +fn format_ticket_role_launch_error(error: &TicketRoleLaunchError) -> String { + match error { + TicketRoleLaunchError::UnsupportedInheritProfile => concat!( + "Ticket role launch failed: role profile is `inherit`. ", + "Top-level TUI ticket launches require concrete role profiles in ", + ".yoi/ticket.config.toml until an inheritance-aware launch path exists." + ) + .to_owned(), + _ => format!("Ticket role launch failed: {error}"), + } +} + fn handle_key(app: &mut App, key: KeyEvent) -> Option { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); @@ -1820,6 +1926,40 @@ mod tests { } } + #[test] + fn ticket_role_launch_context_uses_slug_reference_and_instruction() { + let context = ticket_role_launch_context( + PathBuf::from("/tmp/workspace"), + TicketRoleCommand { + role: client::ticket_role::TicketRole::Coder, + ticket: Some("abc-123".to_owned()), + instruction: Some("focus parser tests".to_owned()), + }, + ); + assert_eq!(context.role, client::ticket_role::TicketRole::Coder); + assert_eq!(context.workspace_root, PathBuf::from("/tmp/workspace")); + assert_eq!( + context + .ticket + .as_ref() + .and_then(|ticket| ticket.slug.as_deref()), + Some("abc-123") + ); + assert_eq!( + context.user_instruction.as_deref(), + Some("focus parser tests") + ); + assert!(context.pod_name.is_none()); + } + + #[test] + fn unsupported_inherit_profile_message_explains_tui_boundary() { + let message = + format_ticket_role_launch_error(&TicketRoleLaunchError::UnsupportedInheritProfile); + assert!(message.contains("Top-level TUI ticket launches require concrete role profiles")); + assert!(message.contains(".yoi/ticket.config.toml")); + } + fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) }