Improving the UI & adding the '/new' command

This commit is contained in:
2026-06-22 11:22:50 -05:00
parent 60f1addd87
commit 7ceb09fca9
4 changed files with 237 additions and 173 deletions
+7 -3
View File
@@ -63,20 +63,22 @@ cass check
## Keys
- Type `/`: show command autocomplete
- Type `/`: show command autocomplete, including command arguments like `/model <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 <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 <chat>`: 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 <id>
## 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`.
+100 -32
View File
@@ -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<PathBuf>) -> Result<PathBuf> {
Ok(cwd)
}
fn create_new_conversation(config: &Config, cwd: &Path) -> Result<Conversation> {
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 {
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
scroll = scroll.saturating_sub(5);
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(5).min(max);
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 <chat>",
@@ -650,7 +699,10 @@ fn command_autofill(input: &str, selected: usize) -> Option<AutoFillMenu> {
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<LocalCommand, String>
}
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<TranscriptBlock> {
let mut blocks = Vec::new();
for r in &conversation.records {
@@ -955,7 +1006,7 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec<TranscriptBlock>
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<TranscriptBlock>
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(
+101 -125
View File
@@ -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::<usize>();
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<Line<'static>> {
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::<String>();
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}");
}
}
+23 -7
View File
@@ -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)
}