merge: add tui ticket role actions

This commit is contained in:
Keisuke Hirata 2026-06-06 05:07:44 +09:00
commit 5d3209d8e4
No known key found for this signature in database
4 changed files with 394 additions and 17 deletions

View File

@ -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};

View File

@ -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,

View File

@ -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();

View File

@ -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)
}