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 protocol::{ErrorCode, Event, InvokeKind, Method, Segment};
|
||||||
use thiserror::Error;
|
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};
|
use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ use crate::block::{
|
||||||
};
|
};
|
||||||
use crate::cache::FileCache;
|
use crate::cache::FileCache;
|
||||||
use crate::command::{
|
use crate::command::{
|
||||||
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
|
CommandAction, CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode,
|
||||||
|
CommandRegistry,
|
||||||
};
|
};
|
||||||
use crate::input::InputBuffer;
|
use crate::input::InputBuffer;
|
||||||
use crate::scroll::Scroll;
|
use crate::scroll::Scroll;
|
||||||
|
|
@ -246,6 +247,7 @@ pub struct App {
|
||||||
pub input_mode: CommandInputMode,
|
pub input_mode: CommandInputMode,
|
||||||
pub command_registry: CommandRegistry,
|
pub command_registry: CommandRegistry,
|
||||||
command_completion_selected: Option<usize>,
|
command_completion_selected: Option<usize>,
|
||||||
|
pending_command_action: Option<CommandAction>,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
/// 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
|
/// records the instant; a second press within the timeout exits the
|
||||||
|
|
@ -314,6 +316,7 @@ impl App {
|
||||||
input_mode: CommandInputMode::Composer,
|
input_mode: CommandInputMode::Composer,
|
||||||
command_registry: CommandRegistry::default(),
|
command_registry: CommandRegistry::default(),
|
||||||
command_completion_selected: None,
|
command_completion_selected: None,
|
||||||
|
pending_command_action: None,
|
||||||
quit: false,
|
quit: false,
|
||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
|
|
@ -1626,9 +1629,14 @@ impl App {
|
||||||
self.rewind_picker = None;
|
self.rewind_picker = None;
|
||||||
self.rewind_request_pending = true;
|
self.rewind_request_pending = true;
|
||||||
}
|
}
|
||||||
|
self.pending_command_action = result.action;
|
||||||
result.method
|
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>) {
|
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Warn,
|
level: AlertLevel::Warn,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use client::ticket_role::TicketRole;
|
||||||
use protocol::Method;
|
use protocol::Method;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -49,9 +50,22 @@ pub struct CommandEnvironment {
|
||||||
pub paused: bool,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CommandExecution {
|
pub struct CommandExecution {
|
||||||
pub method: Option<Method>,
|
pub method: Option<Method>,
|
||||||
|
pub action: Option<CommandAction>,
|
||||||
pub diagnostics: Vec<CommandDiagnostic>,
|
pub diagnostics: Vec<CommandDiagnostic>,
|
||||||
pub exit_command_mode: bool,
|
pub exit_command_mode: bool,
|
||||||
pub clear_input: bool,
|
pub clear_input: bool,
|
||||||
|
|
@ -61,6 +75,7 @@ impl CommandExecution {
|
||||||
pub fn diagnostic(message: impl Into<String>) -> Self {
|
pub fn diagnostic(message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
method: None,
|
method: None,
|
||||||
|
action: None,
|
||||||
diagnostics: vec![CommandDiagnostic::new(message)],
|
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||||
exit_command_mode: false,
|
exit_command_mode: false,
|
||||||
clear_input: false,
|
clear_input: false,
|
||||||
|
|
@ -70,6 +85,17 @@ impl CommandExecution {
|
||||||
pub fn notice(message: impl Into<String>) -> Self {
|
pub fn notice(message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
method: None,
|
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)],
|
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||||
exit_command_mode: true,
|
exit_command_mode: true,
|
||||||
clear_input: true,
|
clear_input: true,
|
||||||
|
|
@ -165,6 +191,15 @@ impl CommandRegistry {
|
||||||
can_execute: peer_available,
|
can_execute: peer_available,
|
||||||
executor: peer_command,
|
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
|
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> {
|
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||||
if !environment.connected {
|
if !environment.connected {
|
||||||
return Err(CommandDiagnostic::new(
|
return Err(CommandDiagnostic::new(
|
||||||
|
|
@ -410,6 +476,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||||
let _ = invocation.args.raw();
|
let _ = invocation.args.raw();
|
||||||
CommandExecution {
|
CommandExecution {
|
||||||
method: Some(Method::Compact),
|
method: Some(Method::Compact),
|
||||||
|
action: None,
|
||||||
diagnostics: vec![CommandDiagnostic::new("compact requested")],
|
diagnostics: vec![CommandDiagnostic::new("compact requested")],
|
||||||
exit_command_mode: true,
|
exit_command_mode: true,
|
||||||
clear_input: true,
|
clear_input: true,
|
||||||
|
|
@ -422,6 +489,7 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||||
let _ = invocation.args.raw();
|
let _ = invocation.args.raw();
|
||||||
CommandExecution {
|
CommandExecution {
|
||||||
method: Some(Method::ListRewindTargets),
|
method: Some(Method::ListRewindTargets),
|
||||||
|
action: None,
|
||||||
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
|
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
|
||||||
exit_command_mode: true,
|
exit_command_mode: true,
|
||||||
clear_input: true,
|
clear_input: true,
|
||||||
|
|
@ -434,6 +502,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||||
let name = invocation.args.argv()[0].clone();
|
let name = invocation.args.argv()[0].clone();
|
||||||
CommandExecution {
|
CommandExecution {
|
||||||
method: Some(Method::RegisterPeer { name: name.clone() }),
|
method: Some(Method::RegisterPeer { name: name.clone() }),
|
||||||
|
action: None,
|
||||||
diagnostics: vec![CommandDiagnostic::new(format!(
|
diagnostics: vec![CommandDiagnostic::new(format!(
|
||||||
"peer metadata registration requested with `{name}`"
|
"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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn peer_help_mentions_metadata_registration() {
|
fn peer_help_mentions_metadata_registration() {
|
||||||
let registry = CommandRegistry::builtins();
|
let registry = CommandRegistry::builtins();
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,14 @@ use ratatui::backend::CrosstermBackend;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tokio::sync::mpsc;
|
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::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
|
use crate::command::{CommandAction, TicketRoleCommand};
|
||||||
use crate::picker::PickerOutcome;
|
use crate::picker::PickerOutcome;
|
||||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||||
use crate::{multi_pod, picker, spawn, ui};
|
use crate::{multi_pod, picker, spawn, ui};
|
||||||
|
|
@ -48,17 +53,17 @@ pub(crate) async fn run_pod_name(
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
||||||
let mut terminal = enter_fullscreen()?;
|
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(());
|
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::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
};
|
};
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
terminal.clear()?;
|
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);
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
@ -67,10 +72,11 @@ async fn run_connected_pod(
|
||||||
terminal: &mut FullscreenTerminal,
|
terminal: &mut FullscreenTerminal,
|
||||||
pod_name: String,
|
pod_name: String,
|
||||||
client: PodClient,
|
client: PodClient,
|
||||||
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = App::new(pod_name);
|
let mut app = App::new(pod_name);
|
||||||
app.connected = true;
|
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(
|
async fn run_pod_name_nested(
|
||||||
|
|
@ -84,11 +90,12 @@ async fn run_pod_name_nested(
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
|
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?;
|
let ready =
|
||||||
run_ready_pod(terminal, ready).await
|
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(
|
async fn spawn_pod_name_from_fullscreen(
|
||||||
|
|
@ -131,12 +138,13 @@ impl std::error::Error for NestedOpenCancelled {}
|
||||||
async fn run_ready_pod(
|
async fn run_ready_pod(
|
||||||
terminal: &mut FullscreenTerminal,
|
terminal: &mut FullscreenTerminal,
|
||||||
ready: SpawnReady,
|
ready: SpawnReady,
|
||||||
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let SpawnReady {
|
let SpawnReady {
|
||||||
pod_name,
|
pod_name,
|
||||||
socket_path,
|
socket_path,
|
||||||
} = ready;
|
} = ready;
|
||||||
run(terminal, pod_name, &socket_path).await
|
run(terminal, pod_name, &socket_path, runtime_command).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect_live_pod(
|
async fn connect_live_pod(
|
||||||
|
|
@ -215,7 +223,7 @@ pub(crate) async fn run_spawn(
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> 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::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
@ -226,7 +234,7 @@ pub(crate) async fn run_spawn(
|
||||||
} = ready;
|
} = ready;
|
||||||
|
|
||||||
let mut terminal = enter_fullscreen()?;
|
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.
|
// Leave alt-screen explicitly before `main`'s terminal restore path.
|
||||||
let _ = execute!(
|
let _ = execute!(
|
||||||
|
|
@ -268,6 +276,7 @@ async fn run(
|
||||||
terminal: &mut FullscreenTerminal,
|
terminal: &mut FullscreenTerminal,
|
||||||
pod_name: String,
|
pod_name: String,
|
||||||
socket_path: &std::path::Path,
|
socket_path: &std::path::Path,
|
||||||
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = App::new(pod_name);
|
let mut app = App::new(pod_name);
|
||||||
|
|
||||||
|
|
@ -276,7 +285,7 @@ async fn run(
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
// The Pod sends `Event::Snapshot` automatically on connect;
|
// The Pod sends `Event::Snapshot` automatically on connect;
|
||||||
// no explicit method call is required to fetch history.
|
// 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) => {
|
Err(e) => {
|
||||||
app.push_error(format!(
|
app.push_error(format!(
|
||||||
|
|
@ -295,6 +304,7 @@ type TerminalEventResult = io::Result<TermEvent>;
|
||||||
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64;
|
const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64;
|
||||||
const POD_EVENT_DRAIN_LIMIT: usize = 32;
|
const POD_EVENT_DRAIN_LIMIT: usize = 32;
|
||||||
|
const TICKET_ROLE_NOTICE_DURATION: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
struct TerminalEventReader {
|
struct TerminalEventReader {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
|
@ -380,13 +390,14 @@ async fn drain_terminal_events(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut PodClient,
|
client: &mut PodClient,
|
||||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||||
|
runtime_command: &PodRuntimeCommand,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
||||||
match term_rx.try_recv() {
|
match term_rx.try_recv() {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
handled = true;
|
handled = true;
|
||||||
handle_terminal_event(app, client, event?).await?;
|
handle_terminal_event(app, client, event?, runtime_command).await?;
|
||||||
if app.quit {
|
if app.quit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -426,6 +437,7 @@ async fn run_loop(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
mut client: PodClient,
|
mut client: PodClient,
|
||||||
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||||
|
|
||||||
|
|
@ -436,7 +448,8 @@ async fn run_loop(
|
||||||
break;
|
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 {
|
if app.quit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -448,7 +461,7 @@ async fn run_loop(
|
||||||
|
|
||||||
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
||||||
LoopInput::Terminal(term_event) => {
|
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 {
|
LoopInput::Pod(event) => match event {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
|
|
@ -474,11 +487,14 @@ async fn handle_terminal_event(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut PodClient,
|
client: &mut PodClient,
|
||||||
event: TermEvent,
|
event: TermEvent,
|
||||||
|
runtime_command: &PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match event {
|
match event {
|
||||||
TermEvent::Key(key) => {
|
TermEvent::Key(key) => {
|
||||||
if let Some(method) = handle_key(app, key) {
|
if let Some(method) = handle_key(app, key) {
|
||||||
client.send(&method).await?;
|
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) => {
|
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> {
|
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
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 {
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
KeyEvent::new(code, KeyModifiers::NONE)
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user