tui: remove ticket role commands
This commit is contained in:
@@ -11,8 +11,7 @@ use crate::block::{
|
||||
};
|
||||
use crate::cache::FileCache;
|
||||
use crate::command::{
|
||||
CommandAction, CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode,
|
||||
CommandRegistry,
|
||||
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
|
||||
};
|
||||
use crate::input::InputBuffer;
|
||||
use crate::scroll::Scroll;
|
||||
@@ -247,7 +246,6 @@ 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
|
||||
@@ -316,7 +314,6 @@ 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(),
|
||||
@@ -1629,14 +1626,9 @@ 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,
|
||||
|
||||
+2
-217
@@ -1,4 +1,3 @@
|
||||
use client::ticket_role::TicketRole;
|
||||
use protocol::Method;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -50,22 +49,9 @@ 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,
|
||||
@@ -75,7 +61,6 @@ 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,
|
||||
@@ -85,17 +70,6 @@ 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,
|
||||
@@ -191,15 +165,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -357,37 +322,6 @@ 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(
|
||||
@@ -476,7 +410,6 @@ 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,
|
||||
@@ -489,7 +422,6 @@ 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,
|
||||
@@ -502,7 +434,6 @@ 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}`"
|
||||
))],
|
||||
@@ -511,81 +442,6 @@ 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::*;
|
||||
@@ -723,86 +579,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_intake_command_returns_local_ticket_action() {
|
||||
fn ticket_command_is_unknown() {
|
||||
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")
|
||||
.contains("Unknown command: ticket")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,9 @@ use ratatui::backend::CrosstermBackend;
|
||||
use session_store::SegmentId;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use client::ticket_role::TicketRef;
|
||||
use client::{
|
||||
PodClient, PodRuntimeCommand, TicketRoleLaunchContext, TicketRoleLaunchError,
|
||||
launch_ticket_role_pod,
|
||||
};
|
||||
use client::{PodClient, PodRuntimeCommand};
|
||||
|
||||
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};
|
||||
@@ -304,7 +299,6 @@ 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>,
|
||||
@@ -487,14 +481,12 @@ async fn handle_terminal_event(
|
||||
app: &mut App,
|
||||
client: &mut PodClient,
|
||||
event: TermEvent,
|
||||
runtime_command: &PodRuntimeCommand,
|
||||
_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) => {
|
||||
@@ -543,96 +535,6 @@ 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);
|
||||
@@ -1926,40 +1828,6 @@ 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user