merge: add tui ticket role actions
This commit is contained in:
commit
5d3209d8e4
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
pending_command_action: Option<CommandAction>,
|
||||
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<CommandAction> {
|
||||
self.pending_command_action.take()
|
||||
}
|
||||
|
||||
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub instruction: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandExecution {
|
||||
pub method: Option<Method>,
|
||||
pub action: Option<CommandAction>,
|
||||
pub diagnostics: Vec<CommandDiagnostic>,
|
||||
pub exit_command_mode: bool,
|
||||
pub clear_input: bool,
|
||||
|
|
@ -61,6 +75,7 @@ impl CommandExecution {
|
|||
pub fn diagnostic(message: impl Into<String>) -> 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<String>) -> 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<String>, 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 <intake|route|investigate|implement|review> ...",
|
||||
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,37 @@ fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
|||
}
|
||||
}
|
||||
|
||||
fn ticket_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||
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 <intake|route|investigate|implement|review> ...",
|
||||
));
|
||||
};
|
||||
match action {
|
||||
"intake" if args.argv().len() >= 2 => Ok(args),
|
||||
"intake" => Err(CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: ticket intake <context...>",
|
||||
)),
|
||||
"route" | "investigate" | "implement" | "review" if args.argv().len() >= 2 => Ok(args),
|
||||
"route" => Err(CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: ticket route <ticket-id-or-slug> [instruction...]",
|
||||
)),
|
||||
"investigate" => Err(CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: ticket investigate <ticket-id-or-slug> [instruction...]",
|
||||
)),
|
||||
"implement" => Err(CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: ticket implement <ticket-id-or-slug> [instruction...]",
|
||||
)),
|
||||
"review" => Err(CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: ticket review <ticket-id-or-slug> [instruction...]",
|
||||
)),
|
||||
_ => Err(CommandDiagnostic::new(format!(
|
||||
"Unknown ticket action: {action}. Usage: ticket <intake|route|investigate|implement|review> ..."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
|
|
@ -410,6 +476,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 +489,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 +502,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 +511,81 @@ 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 <intake|route|investigate|implement|review> ...",
|
||||
);
|
||||
};
|
||||
|
||||
let Some(role) = ticket_role_for_action(action) else {
|
||||
return CommandExecution::diagnostic(format!(
|
||||
"Unknown ticket action: {action}. Usage: ticket <intake|route|investigate|implement|review> ..."
|
||||
));
|
||||
};
|
||||
|
||||
let (ticket, instruction) = if action == "intake" {
|
||||
let Some(instruction) = non_empty_string(rest) else {
|
||||
return CommandExecution::diagnostic(
|
||||
"Invalid arguments. Usage: ticket intake <context...>",
|
||||
);
|
||||
};
|
||||
(None, Some(instruction))
|
||||
} else {
|
||||
let Some((ticket, rest)) = split_first_word(rest) else {
|
||||
return CommandExecution::diagnostic(format!(
|
||||
"Invalid arguments. Usage: ticket {action} <ticket-id-or-slug> [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<TicketRole> {
|
||||
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<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -578,6 +722,90 @@ 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_intake_requires_context() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
for command in ["ticket intake", "ticket intake "] {
|
||||
let result = registry.dispatch(command, &env());
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.action.is_none());
|
||||
assert!(!result.exit_command_mode);
|
||||
assert_eq!(
|
||||
result.diagnostics[0].message,
|
||||
"Invalid arguments. Usage: ticket intake <context...>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 <ticket-id-or-slug>")
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
|
|
@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<String>,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<TermEvent>;
|
|||
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<AtomicBool>,
|
||||
|
|
@ -380,13 +390,14 @@ async fn drain_terminal_events(
|
|||
app: &mut App,
|
||||
client: &mut PodClient,
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
runtime_command: &PodRuntimeCommand,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
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<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: PodClient,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<Method> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user