use std::collections::BTreeSet;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use super::{Action, Context, Result, View};
use crate::domain::Repo;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Title,
Prompt,
Repos,
}
pub struct NewTaskView {
title: String,
prompt: String,
field: Field,
repos: Vec<Repo>,
chosen: BTreeSet<i64>,
list: ListState,
loaded: bool,
}
impl Default for NewTaskView {
fn default() -> Self {
Self::new()
}
}
impl NewTaskView {
pub fn new() -> Self {
Self {
title: String::new(),
prompt: String::new(),
field: Field::Title,
repos: Vec::new(),
chosen: BTreeSet::new(),
list: ListState::default().with_selected(Some(0)),
loaded: false,
}
}
fn load(&mut self, ctx: &mut Context) -> Result<()> {
self.repos = ctx.store.list_repos(false)?;
self.loaded = true;
Ok(())
}
fn next_field(&mut self) {
self.field = match self.field {
Field::Title => Field::Prompt,
Field::Prompt => Field::Repos,
Field::Repos => Field::Title,
};
}
fn toggle_selected(&mut self) {
if let Some(repo) = self.list.selected().and_then(|i| self.repos.get(i))
&& !self.chosen.insert(repo.id)
{
self.chosen.remove(&repo.id);
}
}
fn move_by(&mut self, delta: isize) {
if self.repos.is_empty() {
return;
}
let current = self.list.selected().unwrap_or(0) as isize;
let last = self.repos.len() as isize - 1;
self.list
.select(Some(current.saturating_add(delta).clamp(0, last) as usize));
}
fn blocker(&self) -> Option<&'static str> {
if self.title.trim().is_empty() {
return Some("a title is required");
}
if self.prompt.trim().is_empty() {
return Some("describe what the agent should do");
}
if self.chosen.is_empty() {
return Some("choose at least one repo with space");
}
None
}
fn create(&self, ctx: &mut Context) -> Result<i64> {
let ids: Vec<i64> = self.chosen.iter().copied().collect();
let task = ctx.store.create_task(
self.title.trim(),
self.prompt.trim(),
&ctx.config.workspace_root,
&ids,
chrono::Utc::now(),
)?;
Ok(task.id)
}
}
impl View for NewTaskView {
fn title(&self) -> String {
"New task".into()
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
if !self.loaded {
let _ = self.load(ctx);
}
let [title_area, prompt_area, repos_area, hint_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(6),
Constraint::Min(4),
Constraint::Length(1),
])
.areas(area);
frame.render_widget(
Paragraph::new(caret(&self.title, self.field == Field::Title))
.block(bordered("Title", self.field == Field::Title)),
title_area,
);
frame.render_widget(
Paragraph::new(caret(&self.prompt, self.field == Field::Prompt))
.block(bordered(
"What should the agent do?",
self.field == Field::Prompt,
))
.wrap(ratatui::widgets::Wrap { trim: false }),
prompt_area,
);
let items: Vec<ListItem> = if self.repos.is_empty() {
vec![ListItem::new(Span::styled(
" no repos found — is the daemon scanning the right root?",
Style::default().fg(Color::Yellow),
))]
} else {
self.repos
.iter()
.map(|repo| {
let mark = if self.chosen.contains(&repo.id) {
"[x] "
} else {
"[ ] "
};
ListItem::new(Line::from(vec![
Span::styled(mark, Style::default().fg(Color::Green)),
Span::raw(repo.name.clone()),
Span::styled(
format!(" {}", repo.path.display()),
Style::default().add_modifier(Modifier::DIM),
),
]))
})
.collect()
};
frame.render_stateful_widget(
List::new(items)
.block(bordered(
&format!("Repos ({} selected)", self.chosen.len()),
self.field == Field::Repos,
))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
repos_area,
&mut self.list,
);
let hint = match self.blocker() {
Some(reason) => Span::styled(format!(" {reason}"), Style::default().fg(Color::Yellow)),
None => Span::styled(
" ready — ctrl-s to queue it",
Style::default().fg(Color::Green),
),
};
frame.render_widget(Paragraph::new(Line::from(hint)), hint_area);
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
return match self.blocker() {
Some(reason) => {
ctx.say(reason);
Ok(Action::None)
}
None => {
let id = self.create(ctx)?;
ctx.say(format!("queued task {id}"));
Ok(Action::Pop)
}
};
}
match key.code {
KeyCode::Esc => return Ok(Action::Pop),
KeyCode::Tab => self.next_field(),
_ => match self.field {
Field::Title => match key.code {
KeyCode::Char(c) => self.title.push(c),
KeyCode::Backspace => {
self.title.pop();
}
KeyCode::Enter => self.next_field(),
_ => {}
},
Field::Prompt => match key.code {
KeyCode::Char(c) => self.prompt.push(c),
KeyCode::Backspace => {
self.prompt.pop();
}
KeyCode::Enter => self.prompt.push('\n'),
_ => {}
},
Field::Repos => match key.code {
KeyCode::Char(' ') => self.toggle_selected(),
KeyCode::Char('j') | KeyCode::Down => self.move_by(1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1),
_ => {}
},
},
}
Ok(Action::None)
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
vec![
("tab", "field"),
("space", "toggle repo"),
("^s", "queue"),
("esc", "cancel"),
]
}
fn captures_input(&self) -> bool {
true
}
}
fn bordered(title: &str, focused: bool) -> Block<'static> {
let style = if focused {
Style::default().fg(Color::Magenta)
} else {
Style::default().add_modifier(Modifier::DIM)
};
Block::default()
.borders(Borders::ALL)
.border_style(style)
.title(title.to_string())
}
fn caret(text: &str, focused: bool) -> String {
if focused {
format!("{text}▌")
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::tui::testing::{press, render_view};
use chrono::{DateTime, Utc};
use std::path::Path;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn store_with_repos(names: &[&str]) -> Store {
let store = Store::open_in_memory().unwrap();
for name in names {
store
.upsert_repo(Path::new(&format!("/w/{name}")), name, at(0))
.unwrap();
}
store
}
fn type_str(view: &mut NewTaskView, store: &mut Store, text: &str) {
for c in text.chars() {
press(view, store, key(KeyCode::Char(c)));
}
}
fn fill(view: &mut NewTaskView, store: &mut Store) {
type_str(view, store, "Fix auth");
press(view, store, key(KeyCode::Tab));
type_str(view, store, "fix the auth flow");
press(view, store, key(KeyCode::Tab));
press(view, store, key(KeyCode::Char(' ')));
}
#[test]
fn typing_goes_to_the_focused_field_and_tab_moves_on() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
type_str(&mut view, &mut store, "Fix auth");
assert_eq!(view.title, "Fix auth");
assert!(view.prompt.is_empty());
press(&mut view, &mut store, key(KeyCode::Tab));
type_str(&mut view, &mut store, "do it");
assert_eq!(view.prompt, "do it");
assert_eq!(view.title, "Fix auth", "the title must not gain the prompt");
}
#[test]
fn q_types_a_q_rather_than_quitting() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
type_str(&mut view, &mut store, "quick fix");
assert_eq!(view.title, "quick fix");
assert!(
view.captures_input(),
"text fields must swallow global keys"
);
}
#[test]
fn backspace_deletes_from_the_focused_field() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
type_str(&mut view, &mut store, "abc");
press(&mut view, &mut store, key(KeyCode::Backspace));
assert_eq!(view.title, "ab");
}
#[test]
fn the_prompt_takes_newlines_but_the_title_does_not() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
press(&mut view, &mut store, key(KeyCode::Enter));
assert_eq!(view.field, Field::Prompt, "enter leaves a one-line title");
press(&mut view, &mut store, key(KeyCode::Enter));
assert_eq!(view.prompt, "\n");
}
#[test]
fn space_toggles_a_repo_on_and_off() {
let mut store = store_with_repos(&["api", "web"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
press(&mut view, &mut store, key(KeyCode::Tab));
press(&mut view, &mut store, key(KeyCode::Tab));
press(&mut view, &mut store, key(KeyCode::Char(' ')));
assert_eq!(view.chosen.len(), 1);
press(&mut view, &mut store, key(KeyCode::Char(' ')));
assert!(view.chosen.is_empty(), "space must toggle, not only select");
press(&mut view, &mut store, key(KeyCode::Char('j')));
press(&mut view, &mut store, key(KeyCode::Char(' ')));
assert_eq!(view.chosen.len(), 1);
}
#[test]
fn an_incomplete_task_explains_what_is_missing_instead_of_being_created() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
assert_eq!(view.blocker(), Some("a title is required"));
type_str(&mut view, &mut store, "Fix auth");
assert_eq!(view.blocker(), Some("describe what the agent should do"));
press(&mut view, &mut store, key(KeyCode::Tab));
type_str(&mut view, &mut store, "do it");
assert_eq!(
view.blocker(),
Some("choose at least one repo with space"),
"a task with no repos has nowhere to work"
);
assert!(matches!(
press(&mut view, &mut store, ctrl('s')),
Action::None
));
assert!(store.list_tasks().unwrap().is_empty());
}
#[test]
fn a_complete_task_is_queued_with_its_repos() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
fill(&mut view, &mut store);
assert!(matches!(
press(&mut view, &mut store, ctrl('s')),
Action::Pop
));
let tasks = store.list_tasks().unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].title, "Fix auth");
assert_eq!(tasks[0].prompt, "fix the auth flow");
assert_eq!(tasks[0].state, crate::domain::TaskState::Queued);
let links = store.list_task_repos(tasks[0].id).unwrap();
assert_eq!(links.len(), 1);
assert!(
!links[0].is_provisioned(),
"queuing must not create worktrees; the daemon does that"
);
}
#[test]
fn a_multi_repo_task_records_every_choice() {
let mut store = store_with_repos(&["api", "web"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
type_str(&mut view, &mut store, "Both");
press(&mut view, &mut store, key(KeyCode::Tab));
type_str(&mut view, &mut store, "change both");
press(&mut view, &mut store, key(KeyCode::Tab));
press(&mut view, &mut store, key(KeyCode::Char(' ')));
press(&mut view, &mut store, key(KeyCode::Char('j')));
press(&mut view, &mut store, key(KeyCode::Char(' ')));
press(&mut view, &mut store, ctrl('s'));
let task = &store.list_tasks().unwrap()[0];
assert_eq!(store.list_task_repos(task.id).unwrap().len(), 2);
}
#[test]
fn escape_abandons_without_creating_anything() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
render_view(&mut view, &mut store, 70, 20);
fill(&mut view, &mut store);
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Esc)),
Action::Pop
));
assert!(store.list_tasks().unwrap().is_empty());
}
#[test]
fn a_workspace_without_repos_says_so() {
let mut store = Store::open_in_memory().unwrap();
let mut view = NewTaskView::new();
let screen = render_view(&mut view, &mut store, 80, 20);
assert!(
screen.iter().any(|l| l.contains("no repos found")),
"an empty repo list should not look like a bug in this screen: {screen:?}"
);
}
#[test]
fn the_screen_shows_what_is_still_missing() {
let mut store = store_with_repos(&["api"]);
let mut view = NewTaskView::new();
let screen = render_view(&mut view, &mut store, 70, 20);
assert!(
screen.iter().any(|l| l.contains("a title is required")),
"{screen:?}"
);
}
}