Improving the UI & adding the '/new' command
This commit is contained in:
@@ -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`.
|
||||
|
||||
+106
-38
@@ -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 {
|
||||
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 <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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user