diff --git a/README.md b/README.md index e0f8c5d..596b49a 100644 --- a/README.md +++ b/README.md @@ -63,20 +63,22 @@ cass check ## Keys -- Type `/`: show command autocomplete +- Type `/`: show command autocomplete, including command arguments like `/model ` and `/new` - `Up`/`Down`: move through an autocomplete menu - `Tab`/`Enter`: fill autocomplete selection - `Enter`: send message / run command when no autocomplete menu is open - `Ctrl-J`: insert newline - `Shift-Tab`: toggle read-only/full-access mode while idle -- `Ctrl-O`: toggle truncated/full tool output display -- `PageUp`/`PageDown` or mouse wheel: transcript scroll +- `Ctrl-O`: toggle compact/full tool output display +- `Up`/`Down` or mouse wheel: scroll transcript when no autocomplete menu is open +- `PageUp`/`PageDown`: transcript scroll - `Ctrl-C` twice within 1.5 seconds: exit ## Commands - `cass check`: validate Cass config files - `/model `: switch the model for future turns; model autocomplete lists entries from `~/.cass/models.json` +- `/new`: create a new chat for the current directory - `/resume `: resume a saved chat; chat autocomplete lists chats for the current directory - `/status`: show current chat status @@ -88,6 +90,8 @@ Resume this chat with: cass --resume ## Tools +Tool calls are shown compactly by default; press `Ctrl-O` to expand full tool output. + Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`. Full-access mode additionally allows `write` and `edit`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`. diff --git a/src/app.rs b/src/app.rs index 10c0641..1dd2151 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,12 +37,7 @@ pub async fn run() -> Result<()> { let (conversation, warning) = if let Some(Some(id)) = cli.resume.clone() { Conversation::load(&config.conversations_dir(), &id)? } else { - let global = fs::read_to_string(config.global_path()).ok(); - let base = prompt::build_base_system_prompt(global.as_deref()); - ( - Conversation::create(&config.conversations_dir(), &config.model, &cwd, base)?, - None, - ) + (create_new_conversation(&config, &cwd)?, None) }; run_tui(config, cwd, conversation, warning).await @@ -57,6 +52,12 @@ fn resolve_cwd(cwd: Option) -> Result { Ok(cwd) } +fn create_new_conversation(config: &Config, cwd: &Path) -> Result { + let global = fs::read_to_string(config.global_path()).ok(); + let base = prompt::build_base_system_prompt(global.as_deref()); + Conversation::create(&config.conversations_dir(), &config.model, cwd, base) +} + fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> { let chats = conversation::list_chats(&config.conversations_dir(), cwd)?; if chats.is_empty() { @@ -233,7 +234,7 @@ async fn run_tui( status = if show_full_tools { "showing full tool output" } else { - "showing truncated tool output" + "showing compact tool output" } .into(); } @@ -305,6 +306,46 @@ async fn run_tui( } } } + Ok(LocalCommand::New) => { + if busy { + status = "new chat can be created when idle".into(); + } else { + match create_new_conversation(&config, &cwd) { + Ok(new_conversation) => { + conversation = new_conversation; + chat_id = conversation.id.clone(); + transcript.clear(); + input.clear(); + autofill_selected = 0; + active_assistant = None; + status = format!("new chat {chat_id}"); + stick_to_bottom = true; + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + )?; + } + Err(err) => { + status = format!("new chat failed: {err}"); + transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "new".into(), + content: err.to_string(), + }); + if stick_to_bottom { + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + )?; + } + } + } + } + } Ok(LocalCommand::Resume(id)) => { if busy { status = "chat can be resumed when idle".into(); @@ -390,11 +431,19 @@ async fn run_tui( (KeyCode::Up, _) => { if let Some(menu) = &autofill { autofill_selected = menu.previous_index(); + } else { + scroll = scroll.saturating_sub(1); + stick_to_bottom = false; } } (KeyCode::Down, _) => { if let Some(menu) = &autofill { autofill_selected = menu.next_index(); + } else { + let max = + bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + scroll = scroll.saturating_add(1).min(max); + stick_to_bottom = scroll >= max; } } (KeyCode::PageUp, _) => { @@ -417,25 +466,18 @@ async fn run_tui( _ => {} } } - Event::Mouse(mouse) => { - let transcript_area = - render::transcript_area(terminal_area(&terminal)?, &input); - if rect_contains(transcript_area, mouse.column, mouse.row) { - match mouse.kind { - MouseEventKind::ScrollUp => { - scroll = scroll.saturating_sub(5); - stick_to_bottom = false; - } - MouseEventKind::ScrollDown => { - let max = - bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; - scroll = scroll.saturating_add(5).min(max); - stick_to_bottom = scroll >= max; - } - _ => {} - } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => { + scroll = scroll.saturating_sub(3); + stick_to_bottom = false; } - } + MouseEventKind::ScrollDown => { + let max = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + scroll = scroll.saturating_add(3).min(max); + stick_to_bottom = scroll >= max; + } + _ => {} + }, _ => {} } } @@ -500,7 +542,7 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu *ctx.active_assistant = None; ctx.transcript.push(TranscriptBlock { kind: TranscriptKind::Tool, - title: format!("call: {name} ({})", short_call_id(&id)), + title: format!("{name} … ({})", short_call_id(&id)), content: serde_json::to_string_pretty(&arguments) .unwrap_or_else(|_| arguments.to_string()), }); @@ -519,7 +561,7 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu TranscriptKind::Error }, title: format!( - "result: {name} {} ({})", + "{name} {} ({})", if ok { "✓" } else { "✗" }, short_call_id(&id) ), @@ -590,9 +632,10 @@ fn assistant_content_matches(a: &str, b: &str) -> bool { a == b || (!a.trim().is_empty() && a.trim() == b.trim()) } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum LocalCommand { Model(String), + New, Resume(String), Status, } @@ -611,6 +654,12 @@ const COMMANDS: &[CommandSpec] = &[ description: "switch the model for future turns", takes_value: true, }, + CommandSpec { + name: "new", + usage: "/new", + description: "create a new chat", + takes_value: false, + }, CommandSpec { name: "resume", usage: "/resume ", @@ -650,7 +699,10 @@ fn command_autofill(input: &str, selected: usize) -> Option { if !input.starts_with('/') || input[1..].chars().any(char::is_whitespace) { return None; } - if input == "/status" { + if COMMANDS + .iter() + .any(|spec| !spec.takes_value && input == format!("/{}", spec.name)) + { return None; } @@ -844,6 +896,12 @@ fn parse_local_command(input: &str) -> std::result::Result } Ok(LocalCommand::Resume(chat.to_string())) } + "/new" => { + if parts.next().is_some() { + return Err("usage: /new".into()); + } + Ok(LocalCommand::New) + } "/status" => { if parts.next().is_some() { return Err("usage: /status".into()); @@ -924,13 +982,6 @@ fn clamp_scroll( Ok(scroll.min(bottom_scroll(terminal, input, transcript, show_full_tools)?)) } -fn rect_contains(area: ratatui::layout::Rect, column: u16, row: u16) -> bool { - column >= area.x - && column < area.x.saturating_add(area.width) - && row >= area.y - && row < area.y.saturating_add(area.height) -} - fn blocks_from_conversation(conversation: &Conversation) -> Vec { let mut blocks = Vec::new(); for r in &conversation.records { @@ -955,7 +1006,7 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec for call in tool_calls { blocks.push(TranscriptBlock { kind: TranscriptKind::Tool, - title: format!("call: {} ({})", call.name, short_call_id(&call.id)), + title: format!("{} … ({})", call.name, short_call_id(&call.id)), content: serde_json::to_string_pretty(&call.arguments) .unwrap_or_else(|_| call.arguments.to_string()), }); @@ -974,7 +1025,7 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec TranscriptKind::Error }, title: format!( - "result: {name} {} ({})", + "{name} {} ({})", if *ok { "✓" } else { "✗" }, short_call_id(tool_call_id) ), @@ -1002,6 +1053,23 @@ mod tests { (root, config) } + #[test] + fn command_autofill_lists_new_command_and_hides_exact_match() { + let menu = command_autofill("/n", 0).unwrap(); + + assert_eq!(menu.items.len(), 1); + assert_eq!(menu.items[0].label, "/new"); + assert_eq!(menu.items[0].insert, "/new"); + assert_eq!(menu.apply("/n").unwrap(), "/new"); + assert!(command_autofill("/new", 0).is_none()); + } + + #[test] + fn parse_local_command_accepts_new_without_args() { + assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New); + assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into())); + } + #[test] fn model_autofill_lists_models_from_models_json() { let (_root, config) = config_with_models( diff --git a/src/ui/render.rs b/src/ui/render.rs index 84c601d..8c0115d 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -3,7 +3,7 @@ use crate::ui::autofill::AutoFillMenu; use crate::ui::theme; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use std::path::Path; use unicode_width::UnicodeWidthChar; @@ -40,49 +40,34 @@ pub struct RenderState<'a> { } pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { - let chunks = main_layout(f.area(), state.input); - - let header = header_text(state, chunks[0].width as usize); - f.render_widget(Paragraph::new(header).style(theme::header()), chunks[0]); + let menu_height = autofill_height(state.autofill); + let chunks = main_layout(f.area(), state.input, menu_height); let lines = transcript_lines_from(state.transcript, state.show_full_tools); let effective_scroll = state.scroll.min(max_transcript_scroll( state.transcript, state.show_full_tools, - chunks[1], + chunks[0], )); let transcript = Paragraph::new(Text::from(lines)) - .block(Block::default().borders(Borders::ALL).title(" Transcript ")) .wrap(Wrap { trim: false }) .scroll((effective_scroll, 0)); - f.render_widget(transcript, chunks[1]); - - let input_title = if state.busy { - " Input · waiting " - } else { - " Input " - }; - let input = Paragraph::new(state.input.to_string()) - .block(Block::default().borders(Borders::ALL).title(input_title)) - .wrap(Wrap { trim: false }); - f.render_widget(input, chunks[2]); + f.render_widget(transcript, chunks[0]); if let Some(menu) = state.autofill { render_autofill_menu(f, chunks[1], menu); } - let footer = truncate_end( - &format!( - " / commands | Shift-Tab mode | Tab/Enter fill | Enter send | Ctrl-J newline | Ctrl-O tools | scroll PgUp/PgDn/wheel | Ctrl-C twice exit {}", - state.status - ), - chunks[3].width as usize, - ); - f.render_widget(Paragraph::new(footer), chunks[3]); + let input = + Paragraph::new(Text::from(input_lines(state.input, state.busy))).wrap(Wrap { trim: false }); + f.render_widget(input, chunks[2]); + + let footer = truncate_end(&footer_text(state), chunks[3].width as usize); + f.render_widget(Paragraph::new(footer).style(theme::footer()), chunks[3]); } pub fn transcript_area(area: Rect, input: &str) -> Rect { - main_layout(area, input)[1] + main_layout(area, input, 0)[0] } pub fn max_transcript_scroll( @@ -93,38 +78,39 @@ pub fn max_transcript_scroll( // Paragraph::scroll is measured in rendered rows, not logical lines. Long // tool output and assistant messages wrap, so count rows with the same // word-wrapping behavior ratatui uses for Paragraph::wrap(trim: false). - let content_width = area.width.saturating_sub(2).max(1) as usize; + let content_width = area.width.max(1) as usize; let row_count = transcript_lines_from(transcript, show_full_tools) .iter() .map(|line| ratatui_wrapped_row_count(&line.to_string(), content_width)) .sum::(); - let viewport_rows = area.height.saturating_sub(2) as usize; + let viewport_rows = area.height as usize; row_count .saturating_sub(viewport_rows) .min(u16::MAX as usize) as u16 } -fn render_autofill_menu(f: &mut Frame<'_>, transcript_area: Rect, menu: &AutoFillMenu) { - if menu.items.is_empty() || transcript_area.height < 3 { +fn main_layout(area: Rect, input: &str, menu_height: u16) -> std::rc::Rc<[Rect]> { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(menu_height), + Constraint::Length(input_height(input)), + Constraint::Length(1), + ]) + .split(area) +} + +fn autofill_height(menu: Option<&AutoFillMenu>) -> u16 { + menu.map(|menu| menu.items.len().min(6) as u16).unwrap_or(0) +} + +fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) { + if area.height == 0 || menu.items.is_empty() { return; } - let visible_items = menu - .items - .len() - .min(6) - .min(transcript_area.height as usize - 2); - if visible_items == 0 { - return; - } - - let height = visible_items as u16 + 2; - let area = Rect::new( - transcript_area.x, - transcript_area.y + transcript_area.height.saturating_sub(height), - transcript_area.width, - height, - ); + let visible_items = menu.items.len().min(area.height as usize).min(6); let selected = menu.selected_index().unwrap_or(0); let start = if selected >= visible_items { selected + 1 - visible_items @@ -132,7 +118,7 @@ fn render_autofill_menu(f: &mut Frame<'_>, transcript_area: Rect, menu: &AutoFil 0 }; let end = (start + visible_items).min(menu.items.len()); - let line_width = area.width.saturating_sub(2) as usize; + let width = area.width as usize; let mut lines = Vec::new(); for idx in start..end { @@ -144,41 +130,14 @@ fn render_autofill_menu(f: &mut Frame<'_>, transcript_area: Rect, menu: &AutoFil text.push_str(detail); } let style = if idx == selected { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) + theme::selection() } else { - Style::default().fg(Color::Gray) + theme::menu() }; - lines.push(Line::styled(truncate_end(&text, line_width), style)); + lines.push(Line::styled(truncate_end(&text, width), style)); } - let title = format!( - " {} {}/{} ", - menu.title, - selected.saturating_add(1), - menu.items.len() - ); - let menu = Paragraph::new(Text::from(lines)).block( - Block::default() - .borders(Borders::ALL) - .title(truncate_end(&title, area.width as usize)), - ); - f.render_widget(Clear, area); - f.render_widget(menu, area); -} - -fn main_layout(area: Rect, input: &str) -> std::rc::Rc<[Rect]> { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(5), - Constraint::Length(input_height(input)), - Constraint::Length(1), - ]) - .split(area) + f.render_widget(Paragraph::new(Text::from(lines)), area); } fn transcript_lines_from( @@ -192,11 +151,7 @@ fn transcript_lines_from( } let style = style_for(&block.kind); - lines.push(Line::from(vec![ - Span::styled(symbol_for(&block.kind), style), - Span::raw(" "), - Span::styled(display_title(block), style), - ])); + lines.push(Line::styled(heading_for(block), style)); let content = display_content(block, show_full_tools); if !content.trim().is_empty() { @@ -208,13 +163,33 @@ fn transcript_lines_from( } if lines.is_empty() { lines.push(Line::styled( - "Welcome to Cassady. Ask a question or request a code change.", + "Ask a question or request a code change.", Style::default().fg(Color::DarkGray), )); } lines } +fn input_lines(input: &str, busy: bool) -> Vec> { + let prefix = if busy { "… " } else { "› " }; + if input.is_empty() { + return vec![Line::styled(prefix.to_string(), theme::input())]; + } + + let mut lines = Vec::new(); + for (idx, line) in input.lines().enumerate() { + let marker = if idx == 0 { prefix } else { " " }; + lines.push(Line::styled( + format!("{marker}{}", sanitize_line(line)), + theme::input(), + )); + } + if input.ends_with('\n') { + lines.push(Line::styled(" ", theme::input())); + } + lines +} + fn ratatui_wrapped_row_count(line: &str, content_width: usize) -> usize { // This mirrors ratatui's WordWrapper::process_input for Wrap { trim: false } // closely enough for scroll bounds. Details like whitespace-only wrapped @@ -317,58 +292,56 @@ fn style_for(kind: &TranscriptKind) -> Style { TranscriptKind::User => theme::user(), TranscriptKind::Assistant => theme::assistant(), TranscriptKind::Tool => theme::tool(), - TranscriptKind::Status => Style::default().fg(Color::Blue), + TranscriptKind::Status => Style::default().fg(Color::DarkGray), TranscriptKind::Error => theme::error(), } } -fn symbol_for(kind: &TranscriptKind) -> &'static str { - match kind { - TranscriptKind::User => "You", - TranscriptKind::Assistant => "Cass", - TranscriptKind::Tool => "Tool", - TranscriptKind::Status => "Info", - TranscriptKind::Error => "Error", - } -} - -fn display_title(block: &TranscriptBlock) -> String { +fn heading_for(block: &TranscriptBlock) -> String { match block.kind { - TranscriptKind::User => "message".into(), - TranscriptKind::Assistant => "response".into(), - TranscriptKind::Status => block.title.clone(), - TranscriptKind::Error => block.title.clone(), - TranscriptKind::Tool => block.title.clone(), + TranscriptKind::User => "› you".into(), + TranscriptKind::Assistant => "cass".into(), + TranscriptKind::Tool => format!("· {}", block.title), + TranscriptKind::Status => { + if block.title.trim().is_empty() || block.title == "status" { + "· status".into() + } else { + format!("· {}", block.title) + } + } + TranscriptKind::Error => format!("! {}", block.title), } } fn display_content(block: &TranscriptBlock, show_full_tools: bool) -> String { - if matches!(block.kind, TranscriptKind::Tool) && !show_full_tools && block.content.len() > 1200 - { - let mut s = block.content.chars().take(1200).collect::(); - s.push_str("\n… truncated, press Ctrl-O to expand"); - s + if matches!(block.kind, TranscriptKind::Tool) && !show_full_tools { + String::new() } else { block.content.clone() } } -fn header_text(state: &RenderState<'_>, width: usize) -> String { - let busy = if state.busy { "busy" } else { "idle" }; +fn footer_text(state: &RenderState<'_>) -> String { + let busy = if state.busy { "running" } else { "idle" }; let mode = match state.mode { AccessMode::ReadOnly => "read-only", AccessMode::FullAccess => "full-access", }; - let model = short_model(state.model); - let cwd = short_path(state.cwd); - let chat = short_chat_id(state.chat_id); - truncate_end( - &format!( - " {} {} · {} model={} cwd={} chat={}", - state.app_name, mode, busy, model, cwd, chat - ), - width, - ) + let mut parts = vec![ + state.app_name.to_ascii_lowercase(), + mode.to_string(), + busy.to_string(), + short_model(state.model), + short_path(state.cwd), + short_chat_id(state.chat_id), + ]; + if state.show_full_tools { + parts.push("tools:full".into()); + } + if !state.status.trim().is_empty() { + parts.push(state.status.trim().to_string()); + } + parts.join(" · ") } fn short_model(model: &str) -> String { @@ -445,8 +418,11 @@ fn truncate_middle(s: &str, max: usize) -> String { } fn input_height(input: &str) -> u16 { - let lines = input.lines().count().max(1) as u16; - (lines + 2).clamp(3, 8) + let mut lines = input.lines().count().max(1) as u16; + if input.ends_with('\n') { + lines = lines.saturating_add(1); + } + lines.clamp(1, 6) } #[cfg(test)] @@ -460,7 +436,7 @@ mod tests { title: "response".into(), content: "12345 12345 12345".into(), }]; - let area = Rect::new(0, 0, 12, 5); + let area = Rect::new(0, 0, 12, 3); assert!(max_transcript_scroll(&transcript, false, area) > 0); } @@ -486,7 +462,7 @@ mod tests { let transcript = vec![ TranscriptBlock { kind: TranscriptKind::Tool, - title: "result: read ✓ (call_1)".into(), + title: "read ✓ (call_1)".into(), content, }, TranscriptBlock { @@ -496,8 +472,8 @@ mod tests { }, ]; let area = Rect::new(0, 0, 80, 5); - let max = max_transcript_scroll(&transcript, false, area); + let max = max_transcript_scroll(&transcript, true, area); - assert!(max > 80, "max scroll too low: {max}"); + assert!(max > 50, "max scroll was {max}"); } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index a438b68..e51194a 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,21 +1,37 @@ use ratatui::style::{Color, Modifier, Style}; -pub fn header() -> Style { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) -} pub fn user() -> Style { Style::default() - .fg(Color::Green) + .fg(Color::White) .add_modifier(Modifier::BOLD) } + pub fn assistant() -> Style { Style::default().fg(Color::White) } + pub fn tool() -> Style { - Style::default().fg(Color::Yellow) + Style::default().fg(Color::DarkGray) } + pub fn error() -> Style { Style::default().fg(Color::Red) } + +pub fn input() -> Style { + Style::default().fg(Color::White) +} + +pub fn footer() -> Style { + Style::default().fg(Color::DarkGray) +} + +pub fn menu() -> Style { + Style::default().fg(Color::DarkGray) +} + +pub fn selection() -> Style { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) +}