use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::prelude::*;
use ratatui::widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
};
use serde::Deserialize;
use serde_json::json;
use std::io;
use std::sync::Arc;
use tokio::sync::mpsc;
use tui_textarea::{TextArea, WrapMode};
use crate::config::{Config, TriageMode};
use crate::db::{Database, Issue};
use crate::embedding::Embedder;
use crate::linear::LinearClient;
use crate::llm::{self, LlmClient, Message};
use crate::search::{self, SearchMode};
const QUESTIONS_PROMPT: &str = r#"You are a seasoned engineering project manager helping triage Linear issues. Analyze the issue and produce 2-4 focused clarifying questions to determine the right priority.
Respond with ONLY a JSON object in this format:
{"questions": [{"id": "<short_snake_case_id>", "label": "<Short Label>", "question": "<The question>", "suggested_answer": "<your best guess>"}]}
For each question, provide a suggested_answer based on what you can infer from the issue context. The user can edit, replace, or accept these suggestions. If you're uncertain, phrase it as a guess (e.g., "Likely affects all users of..." or "Probably low frequency based on...").
You may use Markdown in the question text: **bold** for emphasis, *italics* for examples, `backticks` for code/identifiers.
Keep questions concise (one sentence each). Focus on: impact (users affected, blocking?), frequency (how often?), severity (crash vs cosmetic), business context.
Priority levels:
1 = Urgent (production down, data loss, security)
2 = High (major feature broken, significant user impact)
3 = Medium (degraded experience, workarounds exist)
4 = Low (minor polish, nice-to-have)"#;
const EXTRACTION_PROMPT: &str = r#"Based on the issue and the user's answers, propose the final priority, title, and description. Respond with ONLY a JSON object:
{"priority": <1-4>, "title": "<improved title>", "description": "<improved description in markdown>"}
Rules:
- priority must be an integer 1-4
- title should be clear and specific
- description should incorporate context from the answers
- Keep the description concise but informative"#;
const DEFAULT_QUESTIONS: &[(&str, &str, &str)] = &[
(
"impact",
"Impact",
"How many users are affected? Is it blocking?",
),
("frequency", "Frequency", "How often does this occur?"),
(
"severity",
"Severity",
"Is this a crash, degraded experience, or cosmetic?",
),
];
#[derive(Debug, Deserialize)]
struct QuestionsResponse {
questions: Vec<Question>,
}
#[derive(Debug, Clone, Deserialize)]
struct Question {
#[allow(dead_code)]
id: String,
label: String,
question: String,
#[serde(default)]
suggested_answer: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TriageProposal {
priority: i32,
title: String,
description: String,
}
enum Phase {
Loading,
Answering,
Submitting,
Reviewing(TriageProposal),
Applying,
PromptReview {
prompt: String,
scroll: u16,
copied: bool,
},
}
enum AppEvent {
Key(KeyEvent),
Resize,
QuestionsReady(Vec<Question>),
SimilarReady(Vec<SimilarIssue>),
ProposalReady(Result<TriageProposal>),
ApplyDone(Result<()>),
}
#[derive(Clone)]
struct SimilarIssue {
identifier: String,
title: String,
priority: &'static str,
similarity: f32,
}
struct App<'a> {
issues: Vec<Issue>,
current_index: usize,
max_issues: usize,
phase: Phase,
questions: Vec<Question>,
answers: Vec<TextArea<'a>>,
focused_field: usize,
similar_issues: Vec<SimilarIssue>,
similar_loading: bool,
desc_scroll: u16,
applied: usize,
skipped: usize,
status: String,
should_quit: bool,
show_desc: bool,
triage_mode: TriageMode,
}
impl<'a> App<'a> {
fn new(issues: Vec<Issue>, limit: Option<usize>, triage_mode: TriageMode) -> Self {
let max_issues = limit.map(|l| l.min(issues.len())).unwrap_or(issues.len());
Self {
issues,
current_index: 0,
max_issues,
phase: Phase::Loading,
questions: Vec::new(),
answers: Vec::new(),
focused_field: 0,
similar_issues: Vec::new(),
similar_loading: true,
desc_scroll: 0,
applied: 0,
skipped: 0,
status: "Loading questions...".into(),
should_quit: false,
show_desc: false,
triage_mode,
}
}
fn current_issue(&self) -> &Issue {
&self.issues[self.current_index]
}
fn remaining(&self) -> usize {
self.max_issues.saturating_sub(self.current_index + 1)
}
fn issue_url(&self) -> String {
format!(
"https://linear.app/issue/{}",
self.current_issue().identifier
)
}
fn set_questions(&mut self, questions: Vec<Question>) {
self.answers = questions
.iter()
.map(|q| {
let mut ta = if let Some(ref suggestion) = q.suggested_answer {
TextArea::new(suggestion.lines().map(String::from).collect())
} else {
TextArea::default()
};
ta.set_style(Style::default().fg(Color::White));
ta.set_wrap_mode(WrapMode::Word);
ta
})
.collect();
self.questions = questions;
self.focused_field = 0;
self.phase = Phase::Answering;
self.update_status();
}
fn update_status(&mut self) {
self.status = match &self.phase {
Phase::Loading => "Loading questions...".into(),
Phase::Answering if self.show_desc => {
"↑↓=scroll description | d=close | Tab=fields | Ctrl+S=submit".into()
}
Phase::Answering => {
"Tab=next | Ctrl+S=submit | d=description | Esc=skip | Ctrl+C=quit".into()
}
Phase::Submitting => "Generating proposal...".into(),
Phase::Reviewing(_) => "a=accept | s=skip | q=quit".into(),
Phase::Applying => "Applying to Linear...".into(),
Phase::PromptReview { copied, .. } => {
if *copied {
"Copied! ↑↓=scroll | c=copy again | Enter=confirm | Esc=skip | q=quit".into()
} else {
"↑↓=scroll | c=copy to clipboard | Enter=confirm | Esc=skip | q=quit".into()
}
}
};
}
fn advance(&mut self) {
self.current_index += 1;
if self.current_index >= self.max_issues {
self.should_quit = true;
} else {
self.phase = Phase::Loading;
self.questions.clear();
self.answers.clear();
self.similar_issues.clear();
self.similar_loading = true;
self.focused_field = 0;
self.desc_scroll = 0;
self.show_desc = false;
self.update_status();
}
}
fn build_external_prompt(&self) -> String {
let issue = self.current_issue();
let mut prompt = format!(
"Let's improve the detail in the Linear issue, {}:\n",
issue.identifier
);
for (q, a) in self.questions.iter().zip(self.answers.iter()) {
let answer = a.lines().join("\n");
let answer_text = if answer.is_empty() {
"(skipped)".to_string()
} else {
answer
};
prompt.push_str(&format!(
"\n# {}: {}\n\n{}\n",
q.label, q.question, answer_text
));
}
prompt
}
fn collect_qa_text(&self) -> String {
self.questions
.iter()
.zip(self.answers.iter())
.map(|(q, a)| {
let answer = a.lines().join("\n");
format!(
"{}: {}",
q.label,
if answer.is_empty() {
"(skipped)"
} else {
&answer
}
)
})
.collect::<Vec<_>>()
.join("\n")
}
}
pub async fn handle_triage(
db: &Database,
config: &Config,
team: Option<&str>,
limit: Option<usize>,
no_context: bool,
include_completed: bool,
workspace: &str,
) -> Result<()> {
let llm = LlmClient::new(config)?;
let api_key = config.workspace_api_key(workspace)?;
let linear = LinearClient::with_api_key(&api_key);
let default_team = config.workspace_default_team(workspace)?;
let team_key = team.or(default_team.as_deref()).ok_or_else(|| {
anyhow::anyhow!("No team specified. Use --team or set default-team in config")
})?;
let issues = db.get_unprioritized_issues(Some(team_key), include_completed, workspace)?;
if issues.is_empty() {
let total = db.count_issues(Some(team_key), workspace)?;
if total == 0 {
eprintln!(
"No issues found for team \"{}\". Have you run `rectilinear sync --team {}`?",
team_key, team_key
);
} else {
eprintln!("No unprioritized issues found in {}", team_key);
}
return Ok(());
}
let embedder: Option<Arc<Embedder>> = if no_context {
None
} else {
Embedder::new(config).ok().map(Arc::new)
};
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
terminal::disable_raw_mode().ok();
crossterm::execute!(io::stdout(), LeaveAlternateScreen).ok();
original_hook(info);
}));
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
crossterm::execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let tx_evt = tx.clone();
tokio::spawn(async move {
loop {
if event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) {
match event::read() {
Ok(Event::Key(key)) => {
if key.kind == crossterm::event::KeyEventKind::Release {
continue;
}
if tx_evt.send(AppEvent::Key(key)).is_err() {
break;
}
}
Ok(Event::Resize(_, _)) => {
tx_evt.send(AppEvent::Resize).ok();
}
_ => {}
}
}
}
});
let mut app = App::new(issues, limit, config.triage.mode.clone());
spawn_questions(&app, &llm, &tx);
if !no_context {
spawn_similar(&app, db, &embedder, config, &tx, workspace);
}
loop {
terminal.draw(|f| ui(f, &mut app))?;
if let Some(evt) = rx.recv().await {
match evt {
AppEvent::Key(key) => {
handle_key(
&mut app, key, &llm, &linear, db, config, &embedder, no_context, &tx,
workspace,
)
.await;
}
AppEvent::Resize => {
}
AppEvent::QuestionsReady(questions) => {
app.set_questions(questions);
}
AppEvent::SimilarReady(issues) => {
app.similar_issues = issues;
app.similar_loading = false;
}
AppEvent::ProposalReady(result) => match result {
Ok(proposal) => {
app.phase = Phase::Reviewing(proposal);
app.update_status();
}
Err(e) => {
app.status = format!("Proposal failed: {}. Esc=skip", e);
app.phase = Phase::Answering;
}
},
AppEvent::ApplyDone(result) => {
if let Err(e) = result {
app.status = format!("Apply failed: {}", e);
} else {
app.applied += 1;
}
app.advance();
if !app.should_quit {
spawn_questions(&app, &llm, &tx);
if !no_context {
spawn_similar(&app, db, &embedder, config, &tx, workspace);
}
}
}
}
}
if app.should_quit {
break;
}
}
terminal::disable_raw_mode()?;
crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
println!("\n━━━ Triage Session Summary ━━━");
println!(" Applied: {}", app.applied);
println!(" Skipped: {}", app.skipped);
if app.remaining() > 0 {
println!(" Remaining: {}", app.remaining());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_key(
app: &mut App<'_>,
key: KeyEvent,
llm: &LlmClient,
linear: &LinearClient,
db: &Database,
config: &Config,
embedder: &Option<Arc<Embedder>>,
no_context: bool,
tx: &mpsc::UnboundedSender<AppEvent>,
workspace: &str,
) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.should_quit = true;
return;
}
match &app.phase {
Phase::Loading | Phase::Submitting | Phase::Applying => {
}
Phase::Answering => {
if app.show_desc {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
app.desc_scroll = app.desc_scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.desc_scroll = app.desc_scroll.saturating_add(1);
}
KeyCode::Char('d') | KeyCode::Esc => {
app.show_desc = false;
app.update_status();
}
_ => {}
}
return;
}
match key.code {
KeyCode::Tab => {
app.focused_field = (app.focused_field + 1) % app.answers.len();
}
KeyCode::BackTab => {
app.focused_field = if app.focused_field == 0 {
app.answers.len() - 1
} else {
app.focused_field - 1
};
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
submit_answers(app, llm, tx);
}
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
submit_answers(app, llm, tx);
}
KeyCode::Char('d')
if key.modifiers.is_empty()
&& app.answers[app.focused_field].lines().join("").is_empty() =>
{
app.show_desc = true;
app.desc_scroll = 0;
app.update_status();
}
KeyCode::Esc => {
app.skipped += 1;
app.advance();
if !app.should_quit {
spawn_questions(app, llm, tx);
if !no_context {
spawn_similar(app, db, embedder, config, tx, workspace);
}
}
}
_ => {
app.answers[app.focused_field].input(key);
}
}
}
Phase::Reviewing(_) => match key.code {
KeyCode::Char('a') | KeyCode::Enter => {
app.phase = Phase::Applying;
app.update_status();
spawn_apply(app, linear, db, tx);
}
KeyCode::Char('s') | KeyCode::Esc => {
app.skipped += 1;
app.advance();
if !app.should_quit {
spawn_questions(app, llm, tx);
if !no_context {
spawn_similar(app, db, embedder, config, tx, workspace);
}
}
}
KeyCode::Char('q') => {
app.should_quit = true;
}
_ => {}
},
Phase::PromptReview { .. } => match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if let Phase::PromptReview { scroll, .. } = &mut app.phase {
*scroll = scroll.saturating_sub(1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Phase::PromptReview { scroll, .. } = &mut app.phase {
*scroll = scroll.saturating_add(1);
}
}
KeyCode::Char('c') => {
if let Phase::PromptReview { prompt, copied, .. } = &mut app.phase {
let success = copy_to_clipboard(prompt);
*copied = success;
}
app.update_status();
}
KeyCode::Enter => {
app.applied += 1;
app.advance();
if !app.should_quit {
spawn_questions(app, llm, tx);
if !no_context {
spawn_similar(app, db, embedder, config, tx, workspace);
}
}
}
KeyCode::Char('s') | KeyCode::Esc => {
app.skipped += 1;
app.advance();
if !app.should_quit {
spawn_questions(app, llm, tx);
if !no_context {
spawn_similar(app, db, embedder, config, tx, workspace);
}
}
}
KeyCode::Char('q') => {
app.should_quit = true;
}
_ => {}
},
}
}
fn submit_answers(app: &mut App<'_>, llm: &LlmClient, tx: &mpsc::UnboundedSender<AppEvent>) {
match app.triage_mode {
TriageMode::Native => {
app.phase = Phase::Submitting;
app.update_status();
spawn_proposal(app, llm, tx);
}
TriageMode::ClaudeCode | TriageMode::Codex => {
let prompt = app.build_external_prompt();
let copied = copy_to_clipboard(&prompt);
app.phase = Phase::PromptReview {
prompt,
scroll: 0,
copied,
};
app.update_status();
}
}
}
fn copy_to_clipboard(text: &str) -> bool {
use std::process::{Command, Stdio};
let mut child = match Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
Ok(c) => c,
Err(_) => return false,
};
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
if stdin.write_all(text.as_bytes()).is_err() {
return false;
}
}
child.wait().map(|s| s.success()).unwrap_or(false)
}
fn spawn_questions(app: &App<'_>, llm: &LlmClient, tx: &mpsc::UnboundedSender<AppEvent>) {
let issue_context = build_issue_context(app.current_issue());
let system = format!("{}\n\nCurrent issue:\n{}", QUESTIONS_PROMPT, issue_context);
let messages = vec![Message::user(
"Analyze this issue and generate clarifying questions.",
)];
let llm = llm.clone();
let tx = tx.clone();
tokio::spawn(async move {
let result = llm.generate(&messages, &system).await;
let questions = match result {
Ok(response) => {
let json_str = llm::extract_json(&response);
match serde_json::from_str::<QuestionsResponse>(json_str) {
Ok(qr) if !qr.questions.is_empty() => qr.questions,
_ => default_questions(),
}
}
Err(_) => default_questions(),
};
tx.send(AppEvent::QuestionsReady(questions)).ok();
});
}
fn spawn_similar(
app: &App<'_>,
db: &Database,
embedder: &Option<Arc<Embedder>>,
config: &Config,
tx: &mpsc::UnboundedSender<AppEvent>,
workspace: &str,
) {
let Some(embedder) = embedder.clone() else {
tx.send(AppEvent::SimilarReady(Vec::new())).ok();
return;
};
let issue_id = app.current_issue().id.clone();
let query_text = format!(
"{} {}",
app.current_issue().title,
app.current_issue().description.as_deref().unwrap_or("")
);
let team_key = app.current_issue().team_key.clone();
let rrf_k = config.search.rrf_k;
let db = db.clone();
let tx = tx.clone();
let workspace = workspace.to_string();
tokio::spawn(async move {
let results = search::search(
&db,
search::SearchParams {
query: &query_text,
mode: SearchMode::Vector,
team_key: Some(&team_key),
state_filter: None,
limit: 5,
embedder: Some(&embedder),
rrf_k,
workspace_id: &workspace,
},
)
.await;
let similar: Vec<SimilarIssue> = match results {
Ok(results) => results
.into_iter()
.filter(|r| r.issue_id != issue_id)
.take(3)
.map(|r| SimilarIssue {
identifier: r.identifier,
title: r.title,
priority: priority_label(r.priority),
similarity: r.similarity.unwrap_or(0.0),
})
.collect(),
Err(_) => Vec::new(),
};
tx.send(AppEvent::SimilarReady(similar)).ok();
});
}
fn spawn_proposal(app: &App<'_>, llm: &LlmClient, tx: &mpsc::UnboundedSender<AppEvent>) {
let issue_context = build_issue_context(app.current_issue());
let qa_text = app.collect_qa_text();
let similar = if app.similar_issues.is_empty() {
String::new()
} else {
let ctx: String = app
.similar_issues
.iter()
.map(|s| format!(" {} ({}): {}", s.identifier, s.priority, s.title))
.collect::<Vec<_>>()
.join("\n");
format!("\nSimilar issues:\n{}", ctx)
};
let system = format!(
"{}\n\nIssue context:\n{}\n\nUser's answers:\n{}{}",
EXTRACTION_PROMPT, issue_context, qa_text, similar
);
let messages = vec![Message::user(
"Based on the issue and answers above, propose the final priority, title, and description.",
)];
let llm = llm.clone();
let tx = tx.clone();
tokio::spawn(async move {
let result = llm.generate(&messages, &system).await;
let proposal = match result {
Ok(response) => {
let json_str = llm::extract_json(&response);
serde_json::from_str::<TriageProposal>(json_str)
.map_err(|e| anyhow::anyhow!("Failed to parse proposal: {}", e))
}
Err(e) => Err(e),
};
tx.send(AppEvent::ProposalReady(proposal)).ok();
});
}
fn spawn_apply(
app: &App<'_>,
linear: &LinearClient,
db: &Database,
tx: &mpsc::UnboundedSender<AppEvent>,
) {
let Phase::Reviewing(ref proposal) = app.phase else {
return;
};
let issue_id = app.current_issue().id.clone();
let current_title = app.current_issue().title.clone();
let current_desc = app.current_issue().description.clone().unwrap_or_default();
let new_title = if proposal.title != current_title {
Some(proposal.title.clone())
} else {
None
};
let new_desc = if proposal.description != current_desc {
Some(proposal.description.clone())
} else {
None
};
let priority = proposal.priority;
let linear = linear.clone();
let db = db.clone();
let tx = tx.clone();
tokio::spawn(async move {
let result = linear
.update_issue(
&issue_id,
new_title.as_deref(),
new_desc.as_deref(),
Some(priority),
None,
None,
None,
)
.await;
if result.is_ok() {
if let Ok((updated, relations)) = linear.fetch_single_issue(&issue_id).await {
db.upsert_issue(&updated).ok();
db.upsert_relations(&updated.id, &relations).ok();
}
}
tx.send(AppEvent::ApplyDone(result)).ok();
});
}
fn ui(f: &mut Frame, app: &mut App) {
if app.show_desc {
render_description_overlay(f, app);
return;
}
let similar_height = if app.similar_issues.is_empty() && !app.similar_loading {
0
} else {
(app.similar_issues.len() as u16 + 2).max(3) };
let outer = Layout::vertical([
Constraint::Length(5), Constraint::Min(8), Constraint::Length(similar_height), Constraint::Length(1), ])
.split(f.area());
render_header(f, outer[0], app);
match &app.phase {
Phase::Loading => {
let p = Paragraph::new("Loading questions from AI...")
.block(Block::default().borders(Borders::ALL).title(" Triage "));
f.render_widget(p, outer[1]);
}
Phase::Submitting => {
let p = Paragraph::new("Generating proposal...")
.block(Block::default().borders(Borders::ALL).title(" Triage "));
f.render_widget(p, outer[1]);
}
Phase::Applying => {
let p = Paragraph::new("Applying changes to Linear...")
.block(Block::default().borders(Borders::ALL).title(" Triage "));
f.render_widget(p, outer[1]);
}
Phase::Answering => {
render_questions(f, outer[1], app);
}
Phase::Reviewing(proposal) => {
render_proposal(f, outer[1], app, proposal);
}
Phase::PromptReview {
prompt,
scroll,
copied,
} => {
render_prompt_review(f, outer[1], prompt, *scroll, *copied, &app.triage_mode);
}
}
if similar_height > 0 {
render_similar(f, outer[2], app);
}
render_status(f, outer[3], app);
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let issue = app.current_issue();
let progress = format!(
"[{}/{}] Applied: {} Skipped: {}",
app.current_index + 1,
app.max_issues,
app.applied,
app.skipped
);
let desc = issue.description.as_deref().unwrap_or("(no description)");
let desc_preview: String = desc.chars().take(200).collect();
let desc_truncated = desc.len() > 200;
let text = vec![
Line::from(vec![
Span::styled(
format!("{} ", issue.identifier),
Style::default().bold().fg(Color::Cyan),
),
Span::raw(&issue.title),
Span::styled(
format!(" {}", progress),
Style::default().fg(Color::DarkGray),
),
]),
Line::from(vec![
Span::styled("State: ", Style::default().fg(Color::DarkGray)),
Span::styled(&issue.state_name, Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::styled("Assignee: ", Style::default().fg(Color::DarkGray)),
Span::raw(issue.assignee_name.as_deref().unwrap_or("Unassigned")),
Span::raw(" "),
Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
Span::raw(&issue.created_at[..10]),
Span::raw(" "),
Span::styled(
app.issue_url(),
Style::default().fg(Color::Blue).underlined(),
),
]),
Line::from(vec![
Span::styled(desc_preview, Style::default().fg(Color::DarkGray)),
if desc_truncated {
Span::styled(" [d=show more]", Style::default().fg(Color::Blue))
} else {
Span::raw("")
},
]),
];
let block = Block::default().borders(Borders::ALL).title(" Issue ");
let p = Paragraph::new(text).block(block);
f.render_widget(p, area);
}
fn render_description_overlay(f: &mut Frame, app: &mut App) {
let identifier = app.current_issue().identifier.clone();
let title = app.current_issue().title.clone();
let desc = app
.current_issue()
.description
.clone()
.unwrap_or_else(|| "(no description)".into());
let url = app.issue_url();
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::styled(
format!("{} ", identifier),
Style::default().bold().fg(Color::Cyan),
),
Span::raw(title),
]),
Line::from(vec![Span::styled(
url,
Style::default().fg(Color::Blue).underlined(),
)]),
Line::from(""),
];
for line in desc.lines() {
lines.push(Line::from(Span::raw(line.to_string())));
}
let total_lines = lines.len() as u16;
let visible = f.area().height.saturating_sub(3); app.desc_scroll = app.desc_scroll.min(total_lines.saturating_sub(visible));
let block = Block::default()
.borders(Borders::ALL)
.title(" Description (↑↓ scroll, d/Esc close) ");
let p = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((app.desc_scroll, 0));
f.render_widget(p, f.area());
let mut scrollbar_state =
ScrollbarState::new(total_lines as usize).position(app.desc_scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
f.area(),
&mut scrollbar_state,
);
let status_area = Rect {
x: f.area().x,
y: f.area().y + f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
let status = Paragraph::new(Span::styled(
" ↑↓=scroll | d/Esc=close",
Style::default().fg(Color::DarkGray),
));
f.render_widget(status, status_area);
}
fn render_questions(f: &mut Frame, area: Rect, app: &mut App) {
let block = Block::default().borders(Borders::ALL).title(" Questions ");
let inner = block.inner(area);
f.render_widget(block, area);
if app.questions.is_empty() {
return;
}
let n = app.questions.len() as u16;
let label_lines: u16 = 2;
let gap: u16 = 1;
let border: u16 = 2; let overhead_per_q = label_lines + gap;
let available = inner.height.saturating_sub(n * overhead_per_q);
let content_heights: Vec<u16> = app
.answers
.iter()
.map(|ta| (ta.lines().len() as u16).max(2))
.collect();
let total_content: u16 = content_heights.iter().sum();
let textarea_heights: Vec<u16> = if total_content + n * border <= available {
let remaining = available - total_content - n * border;
let bonus = remaining / n;
content_heights
.iter()
.map(|&h| h + border + bonus)
.collect()
} else {
content_heights
.iter()
.map(|&h| {
let share = (h as u32 * available as u32 / total_content.max(1) as u32) as u16;
share.max(2 + border)
})
.collect()
};
let constraints: Vec<Constraint> = textarea_heights
.iter()
.flat_map(|&h| {
[
Constraint::Length(label_lines),
Constraint::Length(h),
Constraint::Length(gap),
]
})
.collect();
let chunks = Layout::vertical(constraints).split(inner);
for (i, (question, textarea)) in app.questions.iter().zip(app.answers.iter_mut()).enumerate() {
let label_area = chunks[i * 3];
let input_area = chunks[i * 3 + 1];
let focused = i == app.focused_field;
let base_color = if focused { Color::Cyan } else { Color::White };
let mut spans = vec![Span::styled(
format!(" {}: ", question.label),
Style::default().bold().fg(base_color),
)];
spans.extend(parse_markdown_spans(&question.question, base_color));
let label = Paragraph::new(Line::from(spans)).wrap(Wrap { trim: false });
f.render_widget(label, label_area);
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
textarea.set_cursor_line_style(Style::default());
f.render_widget(&*textarea, input_area);
}
}
fn render_proposal(f: &mut Frame, area: Rect, app: &App, proposal: &TriageProposal) {
let issue = app.current_issue();
let plabel = priority_label(proposal.priority);
let mut lines = vec![
Line::from(vec![
Span::styled("Priority: ", Style::default().fg(Color::DarkGray)),
Span::styled("No priority", Style::default().fg(Color::DarkGray)),
Span::raw(" → "),
Span::styled(
format!("{} ({})", plabel, proposal.priority),
Style::default().bold().fg(Color::Green),
),
]),
Line::from(""),
];
if proposal.title != issue.title {
lines.push(Line::from(Span::styled(
"Title:",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(Span::styled(
format!(" - {}", issue.title),
Style::default().fg(Color::Red),
)));
lines.push(Line::from(Span::styled(
format!(" + {}", proposal.title),
Style::default().fg(Color::Green),
)));
lines.push(Line::from(""));
}
let current_desc = issue.description.as_deref().unwrap_or("");
if proposal.description != current_desc {
lines.push(Line::from(Span::styled(
"Description:",
Style::default().fg(Color::DarkGray),
)));
for line in proposal.description.lines().take(12) {
lines.push(Line::from(Span::styled(
format!(" {}", line),
Style::default().fg(Color::Green),
)));
}
if proposal.description.lines().count() > 12 {
lines.push(Line::from(Span::styled(
" ...",
Style::default().fg(Color::DarkGray),
)));
}
}
let block = Block::default().borders(Borders::ALL).title(" Proposal ");
let p = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
fn render_prompt_review(
f: &mut Frame,
area: Rect,
prompt: &str,
scroll: u16,
copied: bool,
mode: &TriageMode,
) {
let tool_name = match mode {
TriageMode::ClaudeCode => "Claude Code",
TriageMode::Codex => "Codex",
TriageMode::Native => unreachable!(),
};
let title = if copied {
format!(" {} Prompt (copied to clipboard) ", tool_name)
} else {
format!(" {} Prompt ", tool_name)
};
let lines: Vec<Line> = prompt
.lines()
.map(|line| {
if line.starts_with("# ") {
Line::from(Span::styled(
line.to_string(),
Style::default().bold().fg(Color::Cyan),
))
} else {
Line::from(Span::raw(line.to_string()))
}
})
.collect();
let total_lines = lines.len() as u16;
let visible = area.height.saturating_sub(2); let clamped_scroll = scroll.min(total_lines.saturating_sub(visible));
let block = Block::default().borders(Borders::ALL).title(title);
let p = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((clamped_scroll, 0));
f.render_widget(p, area);
let mut scrollbar_state =
ScrollbarState::new(total_lines as usize).position(clamped_scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
area,
&mut scrollbar_state,
);
}
fn render_similar(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Similar Issues ");
if app.similar_loading {
let p = Paragraph::new(Span::styled(
" Loading...",
Style::default().fg(Color::DarkGray),
))
.block(block);
f.render_widget(p, area);
return;
}
if app.similar_issues.is_empty() {
return;
}
let lines: Vec<Line> = app
.similar_issues
.iter()
.map(|s| {
Line::from(vec![
Span::styled(
format!(" {} ", s.identifier),
Style::default().bold().fg(Color::Cyan),
),
Span::styled(
format!("({}) ", s.priority),
Style::default().fg(Color::DarkGray),
),
Span::raw(&s.title),
Span::styled(
format!(" {:.0}%", s.similarity * 100.0),
Style::default().fg(Color::DarkGray),
),
])
})
.collect();
let p = Paragraph::new(lines).block(block);
f.render_widget(p, area);
}
fn render_status(f: &mut Frame, area: Rect, app: &App) {
let p = Paragraph::new(Span::styled(
format!(" {}", app.status),
Style::default().fg(Color::DarkGray),
));
f.render_widget(p, area);
}
fn build_issue_context(issue: &Issue) -> String {
format!(
"Issue: {} - {}\nState: {}\nAssignee: {}\nCreated: {}\nDescription: {}",
issue.identifier,
issue.title,
issue.state_name,
issue.assignee_name.as_deref().unwrap_or("Unassigned"),
&issue.created_at[..10],
issue.description.as_deref().unwrap_or("(none)")
)
}
fn priority_label(priority: i32) -> &'static str {
match priority {
1 => "Urgent",
2 => "High",
3 => "Medium",
4 => "Low",
_ => "No priority",
}
}
fn parse_markdown_spans(text: &str, base_color: Color) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let next_bold = remaining.find("**");
let next_code = remaining.find('`');
let next_italic = remaining.find('*').filter(|&pos| next_bold != Some(pos));
let next = [
next_bold.map(|p| (p, "**")),
next_code.map(|p| (p, "`")),
next_italic.map(|p| (p, "*")),
]
.into_iter()
.flatten()
.min_by_key(|(pos, _)| *pos);
match next {
None => {
spans.push(Span::styled(
remaining.to_string(),
Style::default().fg(base_color),
));
break;
}
Some((pos, delim)) => {
if pos > 0 {
spans.push(Span::styled(
remaining[..pos].to_string(),
Style::default().fg(base_color),
));
}
let after_open = &remaining[pos + delim.len()..];
if let Some(close_pos) = after_open.find(delim) {
let content = &after_open[..close_pos];
let style = match delim {
"**" => Style::default().bold().fg(base_color),
"`" => Style::default().fg(Color::Yellow),
"*" => Style::default().italic().fg(base_color),
_ => Style::default().fg(base_color),
};
spans.push(Span::styled(content.to_string(), style));
remaining = &after_open[close_pos + delim.len()..];
} else {
spans.push(Span::styled(
remaining[pos..pos + delim.len()].to_string(),
Style::default().fg(base_color),
));
remaining = after_open;
}
}
}
}
spans
}
fn default_questions() -> Vec<Question> {
DEFAULT_QUESTIONS
.iter()
.map(|(id, label, question)| Question {
id: id.to_string(),
label: label.to_string(),
question: question.to_string(),
suggested_answer: None,
})
.collect()
}
pub async fn handle_triage_json(
db: &Database,
config: &Config,
team: Option<&str>,
limit: Option<usize>,
no_context: bool,
include_completed: bool,
workspace: &str,
) -> Result<()> {
let default_team = config.workspace_default_team(workspace)?;
let team_key = team.or(default_team.as_deref()).ok_or_else(|| {
anyhow::anyhow!("No team specified. Use --team or set default-team in config")
})?;
let issues = db.get_unprioritized_issues(Some(team_key), include_completed, workspace)?;
if issues.is_empty() {
let total = db.count_issues(Some(team_key), workspace)?;
if total == 0 {
println!(
"{}",
json!({
"queue": [],
"total_remaining": 0,
"team": team_key,
"error": format!("No issues found for team \"{}\". Have you run `rectilinear sync --team {}`?", team_key, team_key)
})
);
} else {
println!(
"{}",
json!({ "queue": [], "total_remaining": 0, "team": team_key })
);
}
return Ok(());
}
let limit = limit.unwrap_or(20);
let batch: Vec<&Issue> = issues.iter().take(limit).collect();
let total_remaining = issues.len();
let embedder = if no_context {
None
} else {
Embedder::new(config).ok()
};
let mut enriched = Vec::new();
for issue in &batch {
let description = issue.description.as_deref().map(|d| {
if d.len() > 2000 {
let mut end = 2000;
while end > 0 && !d.is_char_boundary(end) {
end -= 1;
}
&d[..end]
} else {
d
}
});
let similar = if let Some(ref emb) = embedder {
let search_text = format!("{}\n\n{}", issue.title, description.unwrap_or(""));
search::find_duplicates(
db,
&search_text,
Some(team_key),
0.3,
4,
emb,
config.search.rrf_k,
workspace,
)
.await
.unwrap_or_default()
.into_iter()
.filter(|r| r.issue_id != issue.id)
.take(3)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let relations = db.get_relations_enriched(&issue.id).unwrap_or_default();
let code_search_hints =
extract_code_hints(&issue.title, description.unwrap_or(""), &issue.labels());
enriched.push(json!({
"identifier": issue.identifier,
"url": issue.url,
"title": issue.title,
"description": description,
"state_name": issue.state_name,
"assignee_name": issue.assignee_name,
"project_name": issue.project_name,
"labels": issue.labels(),
"created_at": issue.created_at,
"similar_issues": similar,
"relations": relations,
"code_search_hints": code_search_hints,
}));
}
let result = json!({
"queue": enriched,
"total_remaining": total_remaining,
"team": team_key,
});
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
fn extract_code_hints(title: &str, description: &str, labels: &[String]) -> Vec<String> {
let mut hints = Vec::new();
let combined = format!("{} {}", title, description);
for word in combined.split_whitespace() {
let word = word.trim_matches(|c: char| {
!c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
});
if (word.contains('/') && word.contains('.'))
|| word.ends_with(".rs")
|| word.ends_with(".ts")
|| word.ends_with(".swift")
{
hints.push(word.to_string());
}
}
for cap in combined.split('`').collect::<Vec<_>>().chunks(2) {
if cap.len() == 2 && !cap[1].is_empty() && cap[1].len() < 80 {
hints.push(cap[1].trim().to_string());
}
}
for word in title.split_whitespace() {
let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
let upper_count = word.chars().filter(|c| c.is_uppercase()).count();
if upper_count >= 2
&& word.len() >= 4
&& word.chars().next().is_some_and(|c| c.is_uppercase())
{
hints.push(word.to_string());
}
if word.contains('_')
&& word.chars().all(|c| c.is_alphanumeric() || c == '_')
&& word.len() >= 4
{
hints.push(word.to_string());
}
}
for label in labels {
if !label.is_empty() {
hints.push(label.clone());
}
}
hints.sort();
hints.dedup();
hints
}