use super::super::registry::SlashCommand;
use crate::app::agent_session::AgentSession;
use crate::store::issues::Priority;
use crate::tui::app::{AppState, NotificationKind, UiEvent};
use crate::tui::overlay;
use crate::tui::slash::SlashCtx;
use crate::tui::slash::SlashOutcome;
use std::collections::HashMap;
use tokio::sync::mpsc;
pub(crate) struct IssueCommand;
impl SlashCommand for IssueCommand {
fn name(&self) -> &str {
"issue"
}
fn aliases(&self) -> &[&str] {
&["issues"]
}
fn description(&self) -> &str {
"Open issues panel, or `new <title>` / `show|start|release|close <id>`"
}
fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
let arg = args.trim();
let arg_opt = if arg.is_empty() { None } else { Some(arg) };
handle_issue_command(arg_opt, ctx.session, ctx.state, ctx.ui_tx);
SlashOutcome::Handled
}
fn complete_arg(
&self,
prefix: &str,
_session: &AgentSession,
_state: &AppState,
) -> Vec<crate::tui::completion::CompletionItem> {
let tokens = ["new", "show", "start", "release", "close", "reopen", "help"];
let last = prefix.rsplit(' ').next().unwrap_or("");
tokens
.iter()
.filter(|t| t.starts_with(last))
.map(|t| crate::tui::completion::CompletionItem {
text: t.to_string(),
label: t.to_string(),
description: None,
kind: crate::tui::completion::CompletionKind::SlashArgument {
command: "issue".to_string(),
},
})
.collect()
}
}
#[allow(clippy::too_many_lines)]
fn handle_issue_command(
arg: Option<&str>,
session: &AgentSession,
state: &mut AppState,
ui_tx: &mpsc::UnboundedSender<UiEvent>,
) -> bool {
let Some(store) = state.issue_store.clone() else {
state.add_notification(
"Issue store is not available in this directory".to_string(),
NotificationKind::Error,
);
return true;
};
let Some(raw) = arg else {
state.overlay = None;
let mut overlay = overlay::IssuesPanelOverlay::new(store);
overlay.set_ui_tx(ui_tx.clone());
state.overlay_state = Some(Box::new(overlay));
return true;
};
let trimmed = raw.trim();
if trimmed.is_empty() {
state.overlay = None;
let mut overlay = overlay::IssuesPanelOverlay::new(store);
overlay.set_ui_tx(ui_tx.clone());
state.overlay_state = Some(Box::new(overlay));
return true;
}
let (head, rest) = match trimmed.find(char::is_whitespace) {
Some(i) => (&trimmed[..i], trimmed[i..].trim_start()),
None => (trimmed, ""),
};
if let Ok(id) = head.parse::<u32>() {
state.overlay = None;
let mut overlay = overlay::IssuesPanelOverlay::new(store);
overlay.set_ui_tx(ui_tx.clone());
if !overlay.pre_select(id) {
state.add_notification(format!("Issue #{id} not found"), NotificationKind::Warning);
}
state.overlay_state = Some(Box::new(overlay));
return true;
}
let sub = head.to_lowercase();
let session_id_owned: String = {
let sid = session.session_id();
if sid.is_empty() {
overlay::IssuesPanelOverlay::session_id().to_string()
} else {
sid
}
};
match sub.as_str() {
"new" => {
if rest.is_empty() {
state.add_notification(
"Usage: /issue new <title>".to_string(),
NotificationKind::Info,
);
return true;
}
let title = rest.to_string();
let (title, opts) = parse_new_opts(&title);
let priority = opts
.as_ref()
.and_then(|o| o.get("priority").map(|s| s.as_str()))
.and_then(parse_priority_loose)
.unwrap_or(Priority::Medium);
let labels: Vec<String> = opts
.as_ref()
.and_then(|o| o.get("labels").map(|s| s.as_str()))
.map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
.unwrap_or_default();
match store.create(
title.clone(),
String::new(),
priority,
labels,
Some(&session_id_owned),
) {
Ok(issue) => {
state.add_notification(
format!("Created issue #{}: {}", issue.meta.id, issue.meta.title),
NotificationKind::Success,
);
}
Err(e) => {
state.add_notification(
format!("Failed to create issue: {e}"),
NotificationKind::Error,
);
}
}
}
"start" | "release" | "close" | "reopen" => {
let Some(id) = parse_id(rest) else {
let cmd = sub.as_str();
state.add_notification(format!("Usage: /issue {cmd} <id>"), NotificationKind::Info);
return true;
};
let sid = session_id_owned.clone();
let store_for_task = store.clone();
let tx = ui_tx.clone();
let op = sub.clone();
tokio::spawn(async move {
let result = match op.as_str() {
"start" => store_for_task.start(id, &sid, None).await.map(|issue| {
format!(
"Assigned issue #{} to {}",
issue.meta.id,
overlay::IssuesPanelOverlay::session_id()
)
}),
"release" => store_for_task
.release(id, &sid, None)
.await
.map(|_| format!("Released issue #{id}")),
"close" => store_for_task.close(id, &sid, None).await.map(|issue| {
format!("Closed issue #{}: {}", issue.meta.id, issue.meta.title)
}),
"reopen" => store_for_task.reopen(id, None).await.map(|issue| {
format!("Reopened issue #{}: {}", issue.meta.id, issue.meta.title)
}),
_ => unreachable!(),
};
let line = match result {
Ok(msg) => msg,
Err(e) => format!("{op} failed: {e}"),
};
let _ = tx.send(UiEvent::SystemMessage(line));
});
}
"help" | "?" => {
let help = "\
`/issue` Open the issues panel
`/issue <id>` Open panel with #id pre-selected
`/issue new <title>` Create a new issue (default priority: medium)
e.g. `/issue new Fix login bug [high] [bug,auth]`
`/issue start <id>` Claim an issue for the current TUI session
`/issue release <id>` Release your claim
`/issue close <id>` Close the issue (must be owner)
`/issue reopen <id>` Reopen a closed issue
";
state.overlay = None;
state.overlay_state = Some(Box::new(overlay::text_viewer::TextViewerOverlay::new(
" Issue Help ",
help.to_string(),
)));
}
other => {
state.add_notification(
format!("Unknown /issue subcommand: `{other}`. Try `/issue help`."),
NotificationKind::Warning,
);
}
}
true
}
fn parse_new_opts(input: &str) -> (String, Option<HashMap<String, String>>) {
let mut tokens: Vec<&str> = input.split_whitespace().collect();
let mut opts = HashMap::new();
let mut ordered_labels: Vec<String> = Vec::new();
while tokens.len() > 1 {
let Some(inner) = tokens
.last()
.and_then(|s| s.strip_prefix('['))
.and_then(|s| s.strip_suffix(']'))
else {
break;
};
if let Some((k, v)) = inner.split_once('=') {
opts.insert(k.trim().to_string(), v.trim().to_string());
} else {
ordered_labels.push(inner.trim().to_string());
}
tokens.pop();
}
if !ordered_labels.is_empty() {
ordered_labels.reverse();
}
let bulk_labels = opts.remove("labels");
let mut labels_vec: Vec<String> = Vec::new();
if let Some(bulk) = bulk_labels {
labels_vec.extend(
bulk.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
);
}
labels_vec.extend(ordered_labels);
if opts.is_empty() && labels_vec.is_empty() {
return (input.to_string(), None);
}
if !labels_vec.is_empty() {
opts.insert("labels".to_string(), labels_vec.join(","));
}
let title = tokens.join(" ");
(title, Some(opts))
}
fn parse_priority_loose(s: &str) -> Option<Priority> {
match s.to_lowercase().as_str() {
"low" | "l" => Some(Priority::Low),
"medium" | "med" | "m" | "default" => Some(Priority::Medium),
"high" | "h" => Some(Priority::High),
"critical" | "crit" | "c" | "urgent" => Some(Priority::Critical),
_ => None,
}
}
fn parse_id(s: &str) -> Option<u32> {
s.trim().parse::<u32>().ok()
}