tui: remove ticket role commands

This commit is contained in:
2026-06-07 06:20:56 +09:00
parent 5f59b0bf57
commit 4ce4ee6532
4 changed files with 31 additions and 432 deletions
+1 -9
View File
@@ -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
View File
@@ -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")
);
}
+2 -134
View File
@@ -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)
}