Skip to content

Commit 1939bbf

Browse files
Shahinyanmclaude
andcommitted
fix(tui): add missing task_list.rs and task_detail.rs to b668876
Forgot to git add new files before previous commit; CI failed on E0583 file not found. Adding now as a separate commit so history is clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b668876 commit 1939bbf

2 files changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! TUI task detail: renders the compact resume-pack of a task — same
2+
//! text the CLI prints from `task-journal pack <id>`. Read-only,
3+
//! scrollable, escape goes back to the task list.
4+
5+
use ratatui::{
6+
layout::{Constraint, Direction, Layout},
7+
style::{Color, Style},
8+
text::{Line, Span},
9+
widgets::{Paragraph, Wrap},
10+
Frame,
11+
};
12+
13+
pub struct TaskDetail {
14+
pub task_id: String,
15+
pub title: String,
16+
pub status: String,
17+
pub body: String,
18+
pub scroll: u16,
19+
}
20+
21+
impl TaskDetail {
22+
pub fn new(task_id: String, title: String, status: String, body: String) -> Self {
23+
Self {
24+
task_id,
25+
title,
26+
status,
27+
body,
28+
scroll: 0,
29+
}
30+
}
31+
32+
pub fn scroll_up(&mut self, n: u16) {
33+
self.scroll = self.scroll.saturating_sub(n);
34+
}
35+
36+
pub fn scroll_down(&mut self, n: u16) {
37+
self.scroll = self.scroll.saturating_add(n);
38+
}
39+
40+
pub fn scroll_top(&mut self) {
41+
self.scroll = 0;
42+
}
43+
44+
pub fn scroll_bottom(&mut self) {
45+
// Approximate — Paragraph clamps to its content; we set a large
46+
// scroll value and let the widget cap it.
47+
self.scroll = u16::MAX / 2;
48+
}
49+
50+
pub fn render(&self, frame: &mut Frame<'_>) {
51+
let chunks = Layout::default()
52+
.direction(Direction::Vertical)
53+
.constraints([
54+
Constraint::Length(3),
55+
Constraint::Min(0),
56+
Constraint::Length(2),
57+
])
58+
.split(frame.area());
59+
60+
let header = Paragraph::new(format!(
61+
" {} · {} · [{}]",
62+
self.task_id, self.title, self.status
63+
))
64+
.style(Style::default().fg(Color::White).bg(Color::Blue));
65+
frame.render_widget(header, chunks[0]);
66+
67+
let body = Paragraph::new(self.body.as_str())
68+
.wrap(Wrap { trim: false })
69+
.scroll((self.scroll, 0));
70+
frame.render_widget(body, chunks[1]);
71+
72+
let footer = Paragraph::new(Line::from(vec![
73+
Span::styled("↑↓/jk", Style::default().fg(Color::Cyan)),
74+
Span::raw(" scroll · "),
75+
Span::styled("PgUp/PgDn", Style::default().fg(Color::Cyan)),
76+
Span::raw(" page · "),
77+
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
78+
Span::raw(" back"),
79+
]));
80+
frame.render_widget(footer, chunks[2]);
81+
}
82+
}

crates/tj-cli/src/tui/task_list.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//! TUI task list: tasks of the current project, ordered open-first by
2+
//! recency. Replaces the older session-browser default — surfaces what
3+
//! the journal is *for* (tracked tasks with reasoning chains) instead
4+
//! of raw chat session JSONLs.
5+
6+
use ratatui::{
7+
layout::{Constraint, Direction, Layout, Rect},
8+
style::{Color, Modifier, Style},
9+
text::{Line, Span},
10+
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
11+
Frame,
12+
};
13+
use tj_core::db::TaskRow;
14+
15+
pub struct TaskList {
16+
pub tasks: Vec<TaskRow>,
17+
pub project_path: String,
18+
pub state: ListState,
19+
}
20+
21+
impl TaskList {
22+
pub fn new(tasks: Vec<TaskRow>, project_path: String) -> Self {
23+
let mut state = ListState::default();
24+
if !tasks.is_empty() {
25+
state.select(Some(0));
26+
}
27+
Self {
28+
tasks,
29+
project_path,
30+
state,
31+
}
32+
}
33+
34+
pub fn selected(&self) -> Option<&TaskRow> {
35+
self.state.selected().and_then(|i| self.tasks.get(i))
36+
}
37+
38+
pub fn next(&mut self) {
39+
if self.tasks.is_empty() {
40+
return;
41+
}
42+
let i = self
43+
.state
44+
.selected()
45+
.map(|i| (i + 1) % self.tasks.len())
46+
.unwrap_or(0);
47+
self.state.select(Some(i));
48+
}
49+
50+
pub fn previous(&mut self) {
51+
if self.tasks.is_empty() {
52+
return;
53+
}
54+
let i = self
55+
.state
56+
.selected()
57+
.map(|i| if i == 0 { self.tasks.len() - 1 } else { i - 1 })
58+
.unwrap_or(0);
59+
self.state.select(Some(i));
60+
}
61+
62+
pub fn first(&mut self) {
63+
if !self.tasks.is_empty() {
64+
self.state.select(Some(0));
65+
}
66+
}
67+
68+
pub fn last(&mut self) {
69+
if !self.tasks.is_empty() {
70+
self.state.select(Some(self.tasks.len() - 1));
71+
}
72+
}
73+
74+
pub fn render(&mut self, frame: &mut Frame<'_>) {
75+
let chunks = Layout::default()
76+
.direction(Direction::Vertical)
77+
.constraints([
78+
Constraint::Length(3),
79+
Constraint::Min(0),
80+
Constraint::Length(2),
81+
])
82+
.split(frame.area());
83+
84+
self.render_header(frame, chunks[0]);
85+
self.render_list(frame, chunks[1]);
86+
self.render_footer(frame, chunks[2]);
87+
}
88+
89+
fn render_header(&self, frame: &mut Frame<'_>, area: Rect) {
90+
let open = self.tasks.iter().filter(|t| t.status == "open").count();
91+
let closed = self.tasks.len() - open;
92+
let header = Paragraph::new(format!(
93+
" Task Journal — {} — {open} open · {closed} closed",
94+
shorten_path(&self.project_path)
95+
))
96+
.style(Style::default().fg(Color::White).bg(Color::Blue));
97+
frame.render_widget(header, area);
98+
}
99+
100+
fn render_list(&mut self, frame: &mut Frame<'_>, area: Rect) {
101+
if self.tasks.is_empty() {
102+
let msg = Paragraph::new(
103+
"\n No tasks yet.\n\n Run `task-journal create \"<title>\"` to open one,\n or `task-journal install-hooks --backfill` to import\n existing Claude Code history.\n",
104+
)
105+
.style(Style::default().fg(Color::Yellow));
106+
frame.render_widget(msg, area);
107+
return;
108+
}
109+
110+
let items: Vec<ListItem> = self
111+
.tasks
112+
.iter()
113+
.map(|t| {
114+
let status_glyph = if t.status == "open" { "○" } else { "✓" };
115+
let status_color = if t.status == "open" {
116+
Color::Cyan
117+
} else {
118+
Color::DarkGray
119+
};
120+
ListItem::new(Line::from(vec![
121+
Span::styled(
122+
format!(" {status_glyph} "),
123+
Style::default().fg(status_color),
124+
),
125+
Span::styled(
126+
format!("{:<14}", truncate(&t.task_id, 14)),
127+
Style::default().fg(Color::DarkGray),
128+
),
129+
Span::raw(" "),
130+
Span::raw(truncate(&t.title, 60)),
131+
Span::styled(
132+
format!(" {} ev", t.event_count),
133+
Style::default().fg(Color::DarkGray),
134+
),
135+
Span::styled(
136+
format!(" {}", short_date(&t.last_event_at)),
137+
Style::default().fg(Color::DarkGray),
138+
),
139+
]))
140+
})
141+
.collect();
142+
143+
let list = List::new(items)
144+
.block(Block::default().borders(Borders::NONE))
145+
.highlight_style(
146+
Style::default()
147+
.bg(Color::DarkGray)
148+
.add_modifier(Modifier::BOLD),
149+
)
150+
.highlight_symbol("▸ ");
151+
152+
frame.render_stateful_widget(list, area, &mut self.state);
153+
}
154+
155+
fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) {
156+
let footer = Paragraph::new(Line::from(vec![
157+
Span::styled("↑↓/jk", Style::default().fg(Color::Cyan)),
158+
Span::raw(" navigate · "),
159+
Span::styled("Enter", Style::default().fg(Color::Cyan)),
160+
Span::raw(" open task · "),
161+
Span::styled("q", Style::default().fg(Color::Cyan)),
162+
Span::raw(" quit"),
163+
]));
164+
frame.render_widget(footer, area);
165+
}
166+
}
167+
168+
fn truncate(s: &str, n: usize) -> String {
169+
if s.chars().count() <= n {
170+
s.to_string()
171+
} else {
172+
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
173+
out.push('…');
174+
out
175+
}
176+
}
177+
178+
fn short_date(iso: &str) -> String {
179+
// Display the date portion (YYYY-MM-DD) plus HH:MM if present.
180+
iso.chars().take(16).collect::<String>().replace('T', " ")
181+
}
182+
183+
fn shorten_path(p: &str) -> String {
184+
if let Some(home) = std::env::var_os("HOME") {
185+
let home = home.to_string_lossy().to_string();
186+
if let Some(rest) = p.strip_prefix(&home) {
187+
return format!("~{rest}");
188+
}
189+
}
190+
p.to_string()
191+
}

0 commit comments

Comments
 (0)