use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tsk_core::{send_request, socket_path, Priority, Thread, ThreadState};
#[derive(Parser)]
#[command(name = "tsk", about = "tsk — work with a clear context", version = env!("CARGO_PKG_VERSION"))]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Thread {
#[command(subcommand)]
action: ThreadCommands,
},
Task {
#[command(subcommand)]
action: TaskCommands,
},
Context,
}
#[derive(Subcommand)]
enum ThreadCommands {
Create {
slug: String,
priority: String,
description: String,
},
List,
SwitchTo {
id: String,
},
Wait {
id: String,
reason: Option<String>,
},
Resume {
id: String,
note: Option<String>,
},
Update {
id: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
priority: Option<String>,
},
}
#[derive(Subcommand)]
enum TaskCommands {
Create {
description: String,
#[arg(long)]
due_by: Option<String>,
#[arg(long)]
thread: Option<String>,
},
List {
#[arg(long)]
thread: Option<String>,
},
Start {
id: String,
#[arg(long)]
thread: Option<String>,
},
Block {
id: String,
reason: String,
#[arg(long)]
thread: Option<String>,
},
Complete {
id: String,
#[arg(long)]
thread: Option<String>,
},
Cancel {
id: String,
#[arg(long)]
thread: Option<String>,
},
Update {
id: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
due_by: Option<String>,
#[arg(long)]
seq: Option<u32>,
#[arg(long)]
thread: Option<String>,
},
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() == 1 {
if let Err(e) = tui::run() {
eprintln!("TUI error: {}", e);
std::process::exit(1);
}
return;
}
let cli = Cli::parse();
if let Err(e) = run_cli(cli) {
eprintln!("{}", e);
std::process::exit(1);
}
}
fn project_root() -> PathBuf {
std::env::var("TSK_PROJECT_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().expect("Cannot determine current directory"))
}
fn run_cli(cli: Cli) -> Result<(), String> {
let root = project_root();
let sock = socket_path(&root);
match cli.command {
Some(Commands::Thread { action }) => match action {
ThreadCommands::Create {
slug,
priority,
description,
} => {
let _: Priority = priority.parse()?;
let result = send_request(
&sock,
"thread.create",
serde_json::json!({
"slug": slug,
"priority": priority,
"description": description,
}),
)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::List => {
let result = send_request(&sock, "thread.list", serde_json::json!({}))?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::SwitchTo { id } => {
let result = send_request(
&sock,
"thread.switch_to",
serde_json::json!({ "id": id }),
)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::Wait { id, reason } => {
let mut params = serde_json::json!({ "id": id });
if let Some(r) = reason { params["reason"] = r.into(); }
let result = send_request(&sock, "thread.wait", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::Resume { id, note } => {
let mut params = serde_json::json!({ "id": id });
if let Some(n) = note { params["note"] = n.into(); }
let result = send_request(&sock, "thread.resume", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::Update { id, slug, description, priority } => {
if let Some(ref p) = priority {
let _: Priority = p.parse()?;
}
let mut params = serde_json::json!({ "id": id });
if let Some(s) = slug { params["slug"] = s.into(); }
if let Some(d) = description { params["description"] = d.into(); }
if let Some(p) = priority { params["priority"] = p.into(); }
let result = send_request(&sock, "thread.update", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
},
Some(Commands::Task { action }) => match action {
TaskCommands::Create { description, due_by, thread } => {
let mut params = serde_json::json!({ "description": description });
if let Some(d) = due_by { params["due_by"] = d.into(); }
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.create", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::List { thread } => {
let mut params = serde_json::json!({});
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.list", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Start { id, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.start", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Block { id, reason, thread } => {
let mut params = serde_json::json!({ "id": id, "reason": reason });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.block", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Complete { id, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.complete", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Cancel { id, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.cancel", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Update { id, description, due_by, seq, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(d) = description { params["description"] = d.into(); }
if let Some(d) = due_by { params["due_by"] = d.into(); }
if let Some(s) = seq { params["seq"] = s.into(); }
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.update", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
},
Some(Commands::Context) => {
const AGENT_CONTEXT: &str = include_str!("agent-context.md");
print!("{}", AGENT_CONTEXT);
match send_request(&sock, "thread.list", serde_json::json!({})) {
Ok(result) => {
let threads = result["threads"].as_array().cloned().unwrap_or_default();
if threads.is_empty() {
println!("No threads exist yet.");
} else {
for t in &threads {
let id = t["id"].as_u64().unwrap_or(0);
let slug = t["slug"].as_str().unwrap_or("?");
let state = t["state"].as_str().unwrap_or("?");
let priority = t["priority"].as_str().unwrap_or("?");
let description = t["description"].as_str().unwrap_or("");
println!(
"- {:04} {:20} {:6} {:6} {}",
id, slug, priority, state, description
);
}
}
}
Err(_) => {
println!("(tskd is not running — start it with: tskd)");
}
}
Ok(())
}
None => Ok(()),
}
}
mod tui {
use super::*;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
KeyModifiers, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
layout::{Constraint, Direction, Layout},
prelude::*,
widgets::{Clear, Paragraph},
};
use std::io;
use std::time::{Duration, Instant};
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let root = project_root();
let sock = socket_path(&root);
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
original_hook(info);
}));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = event_loop(&mut terminal, &sock);
let _ = std::panic::take_hook(); disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
result
}
fn fetch_threads(sock: &std::path::Path) -> Result<Vec<Thread>, String> {
let result = send_request(sock, "thread.list", serde_json::json!({}))?;
let threads: Vec<Thread> = serde_json::from_value(result["threads"].clone())
.map_err(|e| format!("Failed to parse threads: {}", e))?;
Ok(threads)
}
const POLL_INTERVAL: Duration = Duration::from_millis(500);
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
sock: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let mut threads: Vec<Thread> = fetch_threads(sock).unwrap_or_default();
let mut error_msg: Option<String> = if !sock.exists() {
Some("tskd is not running. Start it with: tskd".to_string())
} else {
None
};
let mut last_poll = Instant::now();
let mut scroll: usize = 0;
let mut last_g = false; let mut show_help = false;
loop {
let height = terminal.size().map(|r| r.height.saturating_sub(1) as usize).unwrap_or(23);
let row_count = count_rows(&threads);
terminal.draw(|frame| render(frame, &threads, error_msg.as_deref(), scroll, show_help))?;
if last_poll.elapsed() >= POLL_INTERVAL {
match fetch_threads(sock) {
Ok(t) => { threads = t; error_msg = None; }
Err(e) => { error_msg = Some(e); }
}
last_poll = Instant::now();
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if show_help {
show_help = false;
continue;
}
let g_pressed = last_g;
last_g = false;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('?') => show_help = true,
KeyCode::Char('j') | KeyCode::Down => {
scroll = scroll_down(scroll, row_count, height, 1);
}
KeyCode::Char('k') | KeyCode::Up => {
scroll = scroll.saturating_sub(1);
}
KeyCode::Char('d')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
let page = (height / 2).max(1);
scroll = scroll_down(scroll, row_count, height, page);
}
KeyCode::Char('u')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
let page = (height / 2).max(1);
scroll = scroll.saturating_sub(page);
}
KeyCode::Char('g') => {
if g_pressed {
scroll = 0; } else {
last_g = true;
}
}
KeyCode::Char('G') => {
scroll = scroll_bottom(row_count, height);
}
_ => {}
}
}
Event::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollDown => {
scroll = scroll_down(scroll, row_count, height, 3);
}
MouseEventKind::ScrollUp => {
scroll = scroll.saturating_sub(3);
}
_ => {}
}
}
_ => {}
}
}
}
Ok(())
}
pub fn count_rows(threads: &[Thread]) -> usize {
let is_inactive = |t: &&Thread| matches!(t.state, ThreadState::Paused | ThreadState::Waiting { .. });
let active = threads.iter().filter(|t| t.state == ThreadState::Active).count();
let focus = threads.iter().filter(|t| {
is_inactive(t) && matches!(t.priority, Priority::Incident | Priority::Priority)
}).count();
let bg = threads.iter().filter(|t| {
is_inactive(t) && matches!(t.priority, Priority::Background)
}).count();
let counts = [active, focus, bg];
let non_empty = counts.iter().filter(|&&n| n > 0).count();
let separators = non_empty.saturating_sub(1);
let mut rows = separators;
for n in counts {
if n > 0 {
rows += 1 + 1 + n + 1; }
}
rows
}
pub fn scroll_down(scroll: usize, row_count: usize, height: usize, amount: usize) -> usize {
let max = row_count.saturating_sub(height);
(scroll + amount).min(max)
}
pub fn scroll_bottom(row_count: usize, height: usize) -> usize {
row_count.saturating_sub(height)
}
const W_ID: u16 = 6;
const W_STATE: u16 = 9;
const W_PRIO: u16 = 6;
const SLUG_FULL: u16 = 40;
struct ColWidths {
id: u16,
slug: u16,
state: u16, prio: u16, desc: u16, }
impl ColWidths {
fn from_area(w: u16) -> Self {
let try5 = 2 + 4 + W_ID + W_STATE + W_PRIO; let try4 = 2 + 3 + W_ID + W_STATE + W_PRIO; let try3 = 2 + 2 + W_ID + W_STATE; let try2 = 2 + 1 + W_ID;
if w > try5 + SLUG_FULL {
let desc = w - try5 - SLUG_FULL;
return Self { id: W_ID, slug: SLUG_FULL, state: W_STATE, prio: W_PRIO, desc };
}
if w > try4 + SLUG_FULL {
return Self { id: W_ID, slug: SLUG_FULL, state: W_STATE, prio: W_PRIO, desc: 0 };
}
if w > try3 + SLUG_FULL {
return Self { id: W_ID, slug: SLUG_FULL, state: W_STATE, prio: 0, desc: 0 };
}
let slug = w.saturating_sub(try2).max(1);
Self { id: W_ID, slug, state: 0, prio: 0, desc: 0 }
}
fn active_widths(&self) -> Vec<u16> {
[self.id, self.slug, self.state, self.prio, self.desc]
.iter()
.copied()
.filter(|&w| w > 0)
.collect()
}
}
fn pad(s: &str, width: u16) -> String {
let w = width as usize;
let char_count = s.chars().count();
if char_count >= w {
s.chars().take(w).collect()
} else {
format!("{}{}", s, " ".repeat(w - char_count))
}
}
fn top_border(ws: &ColWidths) -> Line<'static> {
let inner: Vec<String> = ws.active_widths().iter()
.map(|&w| "─".repeat(w as usize))
.collect();
let line = format!("┌{}┐", inner.join("┬"));
Line::from(Span::raw(line))
}
fn bottom_border(ws: &ColWidths) -> Line<'static> {
let inner: Vec<String> = ws.active_widths().iter()
.map(|&w| "─".repeat(w as usize))
.collect();
let line = format!("└{}┘", inner.join("┴"));
Line::from(Span::raw(line))
}
fn data_line<'a>(
id: &str,
slug: &str,
state: &str,
prio: &str,
desc: &str,
ws: &ColWidths,
style: Style,
) -> Line<'a> {
let mut cells: Vec<String> = vec![
format!(" {}", pad(id, ws.id.saturating_sub(1))),
format!(" {}", pad(slug, ws.slug.saturating_sub(1))),
];
if ws.state > 0 { cells.push(format!(" {}", pad(state, ws.state.saturating_sub(1)))); }
if ws.prio > 0 { cells.push(format!(" {}", pad(prio, ws.prio.saturating_sub(1)))); }
if ws.desc > 0 { cells.push(format!(" {}", pad(desc, ws.desc.saturating_sub(1)))); }
let line = format!("│{}│", cells.join("│"));
Line::from(Span::styled(line, style))
}
fn section_lines(title: &str, threads: &[&Thread], ws: &ColWidths) -> Vec<Line<'static>> {
if threads.is_empty() {
return vec![];
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
format!("> {}", title),
Style::default().bold(),
)));
lines.push(top_border(ws));
for t in threads {
lines.push(data_line(
&t.id_str(),
&t.slug,
&t.state.to_string(),
&t.priority.to_string(),
&t.description,
ws,
Style::default(),
));
}
lines.push(bottom_border(ws));
lines
}
fn render(frame: &mut Frame, threads: &[Thread], error: Option<&str>, scroll: usize, show_help: bool) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
let main_area = chunks[0];
let status_area = chunks[1];
if let Some(msg) = error {
frame.render_widget(Paragraph::new(msg), main_area);
} else {
let ws = ColWidths::from_area(main_area.width);
let active: Vec<&Thread> = threads
.iter()
.filter(|t| t.state == ThreadState::Active)
.collect();
let is_inactive = |t: &&Thread| matches!(t.state, ThreadState::Paused | ThreadState::Waiting { .. });
let mut focus: Vec<&Thread> = threads
.iter()
.filter(|t| {
is_inactive(t) && matches!(t.priority, Priority::Incident | Priority::Priority)
})
.collect();
focus.sort_by_key(|t| match t.priority {
Priority::Incident => 0u8,
_ => 1,
});
let background: Vec<&Thread> = threads
.iter()
.filter(|t| {
is_inactive(t) && matches!(t.priority, Priority::Background)
})
.collect();
let mut lines: Vec<Line> = Vec::new();
let sections = [
("Active", active.as_slice()),
("Priority & Incidents", focus.as_slice()),
("Background", background.as_slice()),
];
for (i, (title, section_threads)) in sections.iter().enumerate() {
let s = section_lines(title, section_threads, &ws);
if s.is_empty() { continue; }
if i > 0 && !lines.is_empty() { lines.push(Line::from("")); }
lines.extend(s);
}
frame.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), main_area);
}
let active_thread = threads.iter().find(|t| t.state == ThreadState::Active);
let status_text = match active_thread {
Some(t) => format!(" #{} {} | ? help", t.id_str(), t.slug),
None => " (no active thread) | ? help".to_string(),
};
frame.render_widget(
Paragraph::new(status_text).style(Style::default().bg(Color::DarkGray)),
status_area,
);
if show_help {
render_help_popup(frame, area);
}
}
fn render_help_popup(frame: &mut Frame, area: Rect) {
const POPUP_W: u16 = 36;
const POPUP_H: u16 = 14;
let x = area.x + area.width.saturating_sub(POPUP_W) / 2;
let y = area.y + area.height.saturating_sub(POPUP_H) / 2;
let popup_area = Rect::new(x, y, POPUP_W.min(area.width), POPUP_H.min(area.height));
let popup_style = Style::default();
let key_style = Style::default().bold();
let desc_style = Style::default();
let bindings: &[(&str, &str)] = &[
("j / ↓", "scroll down"),
("k / ↑", "scroll up"),
("ctrl-d", "page down"),
("ctrl-u", "page up"),
("gg", "jump to top"),
("G", "jump to bottom"),
("?", "toggle this help"),
("q / esc", "quit"),
];
let rows: Vec<Line> = bindings
.iter()
.map(|(key, desc)| {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:<10}", key), key_style),
Span::raw(" "),
Span::styled(*desc, desc_style),
])
})
.collect();
use ratatui::widgets::{Block, BorderType, Borders};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default());
frame.render_widget(Clear, popup_area);
frame.render_widget(Paragraph::new(rows).block(block).style(popup_style), popup_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_args_would_launch_tui() {
let cli = Cli::try_parse_from(["tsk", "thread", "list"]);
assert!(cli.is_ok());
let cli = Cli::try_parse_from(["tsk", "thread", "create", "fix-login", "PRIO", "Fix it"]);
assert!(cli.is_ok());
let cli = Cli::try_parse_from(["tsk", "thread", "switch-to", "a3f1b2c"]);
assert!(cli.is_ok());
}
#[test]
fn cli_validates_priority() {
assert!("BG".parse::<Priority>().is_ok());
assert!("PRIO".parse::<Priority>().is_ok());
assert!("INC".parse::<Priority>().is_ok());
assert!("NORMAL".parse::<Priority>().is_err());
}
#[test]
fn scroll_down_clamps_to_max() {
assert_eq!(tui::scroll_down(0, 10, 24, 5), 0);
assert_eq!(tui::scroll_down(0, 30, 24, 10), 6);
assert_eq!(tui::scroll_down(4, 30, 24, 10), 6);
}
#[test]
fn scroll_down_advances_by_amount() {
assert_eq!(tui::scroll_down(0, 30, 24, 3), 3);
assert_eq!(tui::scroll_down(2, 30, 24, 3), 5);
}
#[test]
fn scroll_bottom_goes_to_last_page() {
assert_eq!(tui::scroll_bottom(30, 24), 6);
assert_eq!(tui::scroll_bottom(10, 24), 0); }
#[test]
fn count_rows_empty_threads() {
assert_eq!(tui::count_rows(&[]), 0);
}
#[test]
fn count_rows_one_active() {
let t = Thread {
id: 1,
slug: "foo".to_string(),
state: ThreadState::Active,
priority: Priority::Priority,
description: "".to_string(),
};
assert_eq!(tui::count_rows(&[t]), 4);
}
#[test]
fn count_rows_two_sections() {
let active = Thread {
id: 1, slug: "a".to_string(), state: ThreadState::Active,
priority: Priority::Priority, description: "".to_string(),
};
let paused = Thread {
id: 2, slug: "b".to_string(), state: ThreadState::Paused,
priority: Priority::Background, description: "".to_string(),
};
assert_eq!(tui::count_rows(&[active, paused]), 9);
}
}