Skip to content

Commit e718cc2

Browse files
Shahinyanmclaude
andcommitted
feat: add interactive TUI for browsing Claude Code sessions
Adds (alias: ) command with ratatui-based terminal interface for browsing and reading Claude Code session history. Two-screen architecture: - Session list: date, ID, message counts, title with ▸ selection - Chat view: scrollable conversation with role headers, tool badges, word wrapping, and keyboard navigation Dependencies: ratatui 0.29, crossterm 0.28 Includes workaround for rustc 1.95 ICE in check_mod_deathness. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ff462a commit e718cc2

8 files changed

Lines changed: 1013 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 301 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ tracing = "0.1"
3636
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3737

3838
ureq = { version = "2", features = ["json"] }
39+
ratatui = "0.29"
40+
crossterm = "0.28"
3941

4042
# Test deps
4143
assert_fs = "1"

crates/tj-cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ serde_json = { workspace = true }
2525
ulid = { workspace = true }
2626
rusqlite = { workspace = true }
2727
chrono = { workspace = true }
28+
ratatui = { workspace = true }
29+
crossterm = { workspace = true }
2830

2931
[dev-dependencies]
3032
assert_fs = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use anyhow::Result;
22
use clap::{Parser, Subcommand};
33

4+
mod tui;
5+
46
#[derive(Parser)]
57
#[command(name = "task-journal", version, about = "Task Journal CLI", long_about = None)]
68
struct Cli {
@@ -86,6 +88,13 @@ enum Commands {
8688
},
8789
/// Show local classifier and journal statistics.
8890
Stats,
91+
/// Interactive TUI: browse sessions and read chats.
92+
#[command(alias = "tui")]
93+
Ui {
94+
/// Project path override (default: current directory).
95+
#[arg(long)]
96+
project: Option<String>,
97+
},
8998
/// Import task-journal events from existing Claude Code session history.
9099
/// Parses JSONL session files and creates tasks retroactively.
91100
Backfill {
@@ -573,6 +582,18 @@ fn main() -> Result<()> {
573582
}
574583
}
575584
}
585+
Commands::Ui { project } => {
586+
let project_path = match project {
587+
Some(p) => std::path::PathBuf::from(p),
588+
None => std::env::current_dir()?,
589+
};
590+
let mut app = tui::app::App::new(&project_path)?;
591+
if app.session_list.sessions.is_empty() {
592+
eprintln!("No Claude Code sessions found for: {}", project_path.display());
593+
return Ok(());
594+
}
595+
app.run()?;
596+
}
576597
Commands::Backfill {
577598
dry_run,
578599
limit,

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

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
//! Main TUI application — manages screens and terminal lifecycle.
2+
3+
use anyhow::Result;
4+
use crossterm::{
5+
event::{self, Event, KeyCode, KeyModifiers},
6+
execute,
7+
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
8+
};
9+
use ratatui::{backend::CrosstermBackend, Terminal};
10+
use std::io;
11+
use std::path::Path;
12+
use tj_core::session::{discovery, parser};
13+
14+
use super::chat_view::ChatView;
15+
use super::session_list::SessionList;
16+
17+
pub enum Screen {
18+
List,
19+
Chat,
20+
}
21+
22+
pub struct App {
23+
pub screen: Screen,
24+
pub session_list: SessionList,
25+
pub chat_view: Option<ChatView>,
26+
pub should_quit: bool,
27+
}
28+
29+
impl App {
30+
pub fn new(project_path: &Path) -> Result<Self> {
31+
let proj_dir = discovery::find_project_dir(project_path)?;
32+
let sessions = match proj_dir {
33+
Some(ref d) => discovery::list_sessions(d)?,
34+
None => vec![],
35+
};
36+
37+
// Parse session metadata (lightweight — just first user msg + timestamps).
38+
let mut items = Vec::new();
39+
for path in &sessions {
40+
match parser::parse_session(path) {
41+
Ok(parsed) => items.push(parsed),
42+
Err(_) => continue,
43+
}
44+
}
45+
46+
Ok(App {
47+
screen: Screen::List,
48+
session_list: SessionList::new(items),
49+
chat_view: None,
50+
should_quit: false,
51+
})
52+
}
53+
54+
pub fn run(&mut self) -> Result<()> {
55+
enable_raw_mode()?;
56+
let mut stdout = io::stdout();
57+
execute!(stdout, EnterAlternateScreen)?;
58+
let backend = CrosstermBackend::new(stdout);
59+
let mut terminal = Terminal::new(backend)?;
60+
61+
let result = self.main_loop(&mut terminal);
62+
63+
disable_raw_mode()?;
64+
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
65+
terminal.show_cursor()?;
66+
67+
result
68+
}
69+
70+
fn main_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
71+
loop {
72+
terminal.draw(|frame| {
73+
match &self.screen {
74+
Screen::List => self.session_list.render(frame),
75+
Screen::Chat => {
76+
if let Some(ref cv) = self.chat_view {
77+
cv.render(frame);
78+
}
79+
}
80+
}
81+
})?;
82+
83+
if event::poll(std::time::Duration::from_millis(100))? {
84+
if let Event::Key(key) = event::read()? {
85+
// Global: Ctrl+C or q quits.
86+
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
87+
self.should_quit = true;
88+
}
89+
90+
match &self.screen {
91+
Screen::List => self.handle_list_input(key.code),
92+
Screen::Chat => self.handle_chat_input(key.code),
93+
}
94+
}
95+
}
96+
97+
if self.should_quit {
98+
break;
99+
}
100+
}
101+
Ok(())
102+
}
103+
104+
fn handle_list_input(&mut self, key: KeyCode) {
105+
match key {
106+
KeyCode::Char('q') | KeyCode::Esc => {
107+
self.should_quit = true;
108+
}
109+
KeyCode::Up | KeyCode::Char('k') => {
110+
self.session_list.previous();
111+
}
112+
KeyCode::Down | KeyCode::Char('j') => {
113+
self.session_list.next();
114+
}
115+
KeyCode::Home => {
116+
self.session_list.first();
117+
}
118+
KeyCode::End => {
119+
self.session_list.last();
120+
}
121+
KeyCode::PageUp => {
122+
for _ in 0..10 {
123+
self.session_list.previous();
124+
}
125+
}
126+
KeyCode::PageDown => {
127+
for _ in 0..10 {
128+
self.session_list.next();
129+
}
130+
}
131+
KeyCode::Enter => {
132+
if let Some(idx) = self.session_list.selected {
133+
let session = &self.session_list.sessions[idx];
134+
self.chat_view = Some(ChatView::from_session(session));
135+
self.screen = Screen::Chat;
136+
}
137+
}
138+
_ => {}
139+
}
140+
}
141+
142+
fn handle_chat_input(&mut self, key: KeyCode) {
143+
match key {
144+
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Backspace => {
145+
self.screen = Screen::List;
146+
self.chat_view = None;
147+
}
148+
KeyCode::Up | KeyCode::Char('k') => {
149+
if let Some(ref mut cv) = self.chat_view {
150+
cv.scroll_up(1);
151+
}
152+
}
153+
KeyCode::Down | KeyCode::Char('j') => {
154+
if let Some(ref mut cv) = self.chat_view {
155+
cv.scroll_down(1);
156+
}
157+
}
158+
KeyCode::PageUp => {
159+
if let Some(ref mut cv) = self.chat_view {
160+
cv.scroll_up(20);
161+
}
162+
}
163+
KeyCode::PageDown => {
164+
if let Some(ref mut cv) = self.chat_view {
165+
cv.scroll_down(20);
166+
}
167+
}
168+
KeyCode::Home => {
169+
if let Some(ref mut cv) = self.chat_view {
170+
cv.scroll_top();
171+
}
172+
}
173+
KeyCode::End => {
174+
if let Some(ref mut cv) = self.chat_view {
175+
cv.scroll_bottom();
176+
}
177+
}
178+
_ => {}
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)