diff --git a/README.md b/README.md index 2bda361..df73cd3 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,22 @@ cass --resume ## Keys -- `Enter`: send message +- Type `/`: show command autocomplete +- `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-C` twice within 1.5 seconds: exit +## Commands + +- `/model `: switch the model for future turns +- `/resume `: resume a saved chat; chat autocomplete lists chats for the current directory +- `/status`: show current chat status + On exit Cass prints: ```text diff --git a/src/app.rs b/src/app.rs index 4dd7b86..dddad2b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,13 +3,14 @@ use crate::cli; use crate::config::Config; use crate::conversation::{self, Conversation}; use crate::prompt; +use crate::ui::autofill::{AutoFillItem, AutoFillMenu}; use crate::ui::events::poll_event; use crate::ui::render::{self, TranscriptBlock, TranscriptKind}; use crate::ui::terminal; use anyhow::{Context, Result}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -63,7 +64,7 @@ fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> { } async fn run_tui( - config: Config, + mut config: Config, cwd: PathBuf, mut conversation: Conversation, warning: Option, @@ -89,9 +90,12 @@ async fn run_tui( let mut handle: Option>> = None; let mut active_assistant: Option = None; let mut stick_to_bottom = true; - let chat_id = conversation.id.clone(); + let mut chat_id = conversation.id.clone(); + let mut autofill_selected = 0usize; loop { + let autofill = build_autofill(&input, autofill_selected, &config, &cwd)?; + autofill_selected = autofill.as_ref().map(|m| m.selected).unwrap_or(0); scroll = clamp_scroll(&terminal, &input, &transcript, show_full_tools, scroll)?; terminal.draw(|f| { @@ -109,6 +113,7 @@ async fn run_tui( busy: handle.is_some(), show_full_tools, scroll, + autofill: autofill.as_ref(), }, ) })?; @@ -219,6 +224,7 @@ async fn run_tui( (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { let now = Instant::now(); input.clear(); + autofill_selected = 0; if last_ctrl_c .map(|t| now.duration_since(t) <= Duration::from_millis(1500)) .unwrap_or(false) @@ -238,6 +244,14 @@ async fn run_tui( status = format!("mode: {mode}"); } } + (KeyCode::Tab, _) => { + if let Some(menu) = &autofill { + if let Some(next_input) = menu.apply(&input) { + input = next_input; + autofill_selected = 0; + } + } + } (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => { show_full_tools = !show_full_tools; scroll = if stick_to_bottom { @@ -259,19 +273,129 @@ async fn run_tui( .into(); } (KeyCode::Char('j'), m) if m.contains(KeyModifiers::CONTROL) => { - input.push('\n') + input.push('\n'); + autofill_selected = 0; } (KeyCode::Enter, m) if m.contains(KeyModifiers::CONTROL) => { - input.push('\n') + input.push('\n'); + autofill_selected = 0; } (KeyCode::Enter, _) => { - if busy { - status = "agent is still running".into(); + if let Some(menu) = &autofill { + if let Some(next_input) = menu.apply(&input) { + input = next_input; + autofill_selected = 0; + } } else if input.trim().is_empty() { input.clear(); + } else if input.trim_start().starts_with('/') { + match parse_local_command(&input) { + Ok(LocalCommand::Status) => { + let content = chat_status( + &chat_id, + &config.model, + mode, + &cwd, + busy, + &status, + conversation.records.len(), + ); + input.clear(); + autofill_selected = 0; + transcript.push(TranscriptBlock { + kind: TranscriptKind::Status, + title: "status".into(), + content, + }); + status = "status shown".into(); + if stick_to_bottom { + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + )?; + } + } + Ok(LocalCommand::Model(model)) => { + if busy { + status = "model can be changed when idle".into(); + } else { + config.model = model.clone(); + input.clear(); + autofill_selected = 0; + transcript.push(TranscriptBlock { + kind: TranscriptKind::Status, + title: "model".into(), + content: format!("model changed to {model}"), + }); + status = format!("model: {model}"); + 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(); + } else { + match Conversation::load( + &config.conversations_dir(), + &id, + ) { + Ok((loaded, warning)) => { + conversation = loaded; + chat_id = conversation.id.clone(); + transcript = transcript_from_loaded( + &conversation, + warning, + ); + input.clear(); + autofill_selected = 0; + active_assistant = None; + status = format!("resumed chat {chat_id}"); + stick_to_bottom = true; + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + )?; + } + Err(err) => { + status = format!("resume failed: {err}"); + transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "resume".into(), + content: err.to_string(), + }); + if stick_to_bottom { + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + )?; + } + } + } + } + } + Err(err) => { + status = err; + } + } + } else if busy { + status = "agent is still running".into(); } else { let msg = input.trim_end().to_string(); input.clear(); + autofill_selected = 0; transcript.push(TranscriptBlock { kind: TranscriptKind::User, title: "message".into(), @@ -296,6 +420,17 @@ async fn run_tui( } (KeyCode::Backspace, _) => { input.pop(); + autofill_selected = 0; + } + (KeyCode::Up, _) => { + if let Some(menu) = &autofill { + autofill_selected = menu.previous_index(); + } + } + (KeyCode::Down, _) => { + if let Some(menu) = &autofill { + autofill_selected = menu.next_index(); + } } (KeyCode::PageUp, _) => { scroll = scroll.saturating_sub(10); @@ -311,7 +446,8 @@ async fn run_tui( if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => { - input.push(ch) + input.push(ch); + autofill_selected = 0; } _ => {} } @@ -348,6 +484,222 @@ async fn run_tui( } } +#[derive(Debug, Clone)] +enum LocalCommand { + Model(String), + Resume(String), + Status, +} + +struct CommandSpec { + name: &'static str, + usage: &'static str, + description: &'static str, + takes_value: bool, +} + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + name: "model", + usage: "/model ", + description: "switch the model for future turns", + takes_value: true, + }, + CommandSpec { + name: "resume", + usage: "/resume ", + description: "resume a chat from this directory", + takes_value: true, + }, + CommandSpec { + name: "status", + usage: "/status", + description: "show chat status", + takes_value: false, + }, +]; + +fn build_autofill( + input: &str, + selected: usize, + config: &Config, + cwd: &Path, +) -> Result> { + if input.contains('\n') || !input.starts_with('/') { + return Ok(None); + } + + if let Some(menu) = command_autofill(input, selected) { + return Ok(Some(menu)); + } + + resume_chat_autofill(input, selected, config, cwd) +} + +fn command_autofill(input: &str, selected: usize) -> Option { + if !input.starts_with('/') || input[1..].chars().any(char::is_whitespace) { + return None; + } + if input == "/status" { + return None; + } + + let query = input[1..].to_ascii_lowercase(); + let mut items = Vec::new(); + for spec in COMMANDS { + if spec.name.starts_with(&query) || spec.usage[1..].starts_with(&query) { + let insert = if spec.takes_value { + format!("/{} ", spec.name) + } else { + format!("/{}", spec.name) + }; + items.push( + AutoFillItem::new(spec.usage, insert).with_detail(spec.description.to_string()), + ); + } + } + + if items.is_empty() { + None + } else { + Some(AutoFillMenu::new("Commands", 0, input.len(), items).with_selected(selected)) + } +} + +fn resume_chat_autofill( + input: &str, + selected: usize, + config: &Config, + cwd: &Path, +) -> Result> { + let Some(rest) = input.strip_prefix("/resume") else { + return Ok(None); + }; + if rest.is_empty() || !rest.chars().next().is_some_and(|c| c.is_whitespace()) { + return Ok(None); + } + + let arg = rest.trim_start_matches(char::is_whitespace); + if arg.chars().any(char::is_whitespace) { + return Ok(None); + } + let replacement_start = input.len() - arg.len(); + let query = arg.to_ascii_lowercase(); + + let chats = conversation::list_chats(&config.conversations_dir(), cwd)?; + if !arg.is_empty() && chats.iter().any(|chat| chat.id == arg) { + return Ok(None); + } + + let mut items = Vec::new(); + for chat in chats { + if chat_matches(&chat, &query) { + let detail = chat_detail(&chat); + items.push(AutoFillItem::new(chat.id.clone(), chat.id).with_detail(detail)); + } + } + + if items.is_empty() { + Ok(None) + } else { + Ok(Some( + AutoFillMenu::new("Chats", replacement_start, input.len(), items) + .with_selected(selected), + )) + } +} + +fn chat_matches(chat: &conversation::ChatSummary, query: &str) -> bool { + if query.is_empty() { + return true; + } + chat.id.to_ascii_lowercase().contains(query) + || chat.created_at.to_ascii_lowercase().contains(query) + || chat.model.to_ascii_lowercase().contains(query) + || chat.first_user_preview.to_ascii_lowercase().contains(query) +} + +fn chat_detail(chat: &conversation::ChatSummary) -> String { + let mut parts = vec![chat.created_at.clone(), short_model_name(&chat.model)]; + if !chat.first_user_preview.is_empty() { + parts.push(chat.first_user_preview.clone()); + } + parts.join(" · ") +} + +fn short_model_name(model: &str) -> String { + model.rsplit('/').next().unwrap_or(model).to_string() +} + +fn parse_local_command(input: &str) -> std::result::Result { + let trimmed = input.trim(); + let mut parts = trimmed.split_whitespace(); + let Some(command) = parts.next() else { + return Err("empty command".into()); + }; + + match command { + "/model" => { + let Some(model) = parts.next() else { + return Err("usage: /model ".into()); + }; + if parts.next().is_some() { + return Err("usage: /model ".into()); + } + Ok(LocalCommand::Model(model.to_string())) + } + "/resume" => { + let Some(chat) = parts.next() else { + return Err("usage: /resume ".into()); + }; + if parts.next().is_some() { + return Err("usage: /resume ".into()); + } + Ok(LocalCommand::Resume(chat.to_string())) + } + "/status" => { + if parts.next().is_some() { + return Err("usage: /status".into()); + } + Ok(LocalCommand::Status) + } + other => Err(format!("unknown command: {other}")), + } +} + +fn chat_status( + chat_id: &str, + model: &str, + mode: crate::access::AccessMode, + cwd: &Path, + busy: bool, + status: &str, + record_count: usize, +) -> String { + format!( + "chat: {chat_id}\nstate: {}\nmodel: {model}\nmode: {mode}\ncwd: {}\nrecords: {record_count}\nstatus: {}", + if busy { "running" } else { "idle" }, + cwd.display(), + if status.is_empty() { "idle" } else { status } + ) +} + +fn transcript_from_loaded( + conversation: &Conversation, + warning: Option, +) -> Vec { + let mut blocks = Vec::new(); + if let Some(w) = warning { + blocks.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "warning".into(), + content: w, + }); + } + blocks.extend(blocks_from_conversation(conversation)); + blocks +} + fn short_call_id(id: &str) -> String { if id.len() <= 12 { id.to_string() diff --git a/src/ui/autofill.rs b/src/ui/autofill.rs new file mode 100644 index 0000000..8f45850 --- /dev/null +++ b/src/ui/autofill.rs @@ -0,0 +1,90 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutoFillItem { + pub label: String, + pub insert: String, + pub detail: Option, +} + +impl AutoFillItem { + pub fn new(label: impl Into, insert: impl Into) -> Self { + Self { + label: label.into(), + insert: insert.into(), + detail: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutoFillMenu { + pub title: String, + pub replacement_start: usize, + pub replacement_end: usize, + pub items: Vec, + pub selected: usize, +} + +impl AutoFillMenu { + pub fn new( + title: impl Into, + replacement_start: usize, + replacement_end: usize, + items: Vec, + ) -> Self { + Self { + title: title.into(), + replacement_start, + replacement_end, + items, + selected: 0, + } + } + + pub fn with_selected(mut self, selected: usize) -> Self { + self.selected = selected.min(self.items.len().saturating_sub(1)); + self + } + + pub fn selected_index(&self) -> Option { + if self.items.is_empty() { + None + } else { + Some(self.selected.min(self.items.len() - 1)) + } + } + + pub fn previous_index(&self) -> usize { + self.selected_index().unwrap_or(0).saturating_sub(1) + } + + pub fn next_index(&self) -> usize { + self.selected_index() + .map(|idx| (idx + 1).min(self.items.len().saturating_sub(1))) + .unwrap_or(0) + } + + pub fn apply(&self, input: &str) -> Option { + let item = self.items.get(self.selected_index()?)?; + if self.replacement_start > self.replacement_end || self.replacement_end > input.len() { + return None; + } + if !input.is_char_boundary(self.replacement_start) + || !input.is_char_boundary(self.replacement_end) + { + return None; + } + + let mut out = String::with_capacity( + input.len() - (self.replacement_end - self.replacement_start) + item.insert.len(), + ); + out.push_str(&input[..self.replacement_start]); + out.push_str(&item.insert); + out.push_str(&input[self.replacement_end..]); + Some(out) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6ef2ad3..c233152 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod autofill; pub mod events; pub mod render; pub mod terminal; diff --git a/src/ui/render.rs b/src/ui/render.rs index 239f0a3..bb7b503 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1,8 +1,9 @@ use crate::access::AccessMode; +use crate::ui::autofill::AutoFillMenu; use crate::ui::theme; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use std::path::Path; #[derive(Debug, Clone)] @@ -34,6 +35,7 @@ pub struct RenderState<'a> { pub busy: bool, pub show_full_tools: bool, pub scroll: u16, + pub autofill: Option<&'a AutoFillMenu>, } pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { @@ -64,9 +66,13 @@ pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { .wrap(Wrap { trim: false }); f.render_widget(input, chunks[2]); + if let Some(menu) = state.autofill { + render_autofill_menu(f, chunks[1], menu); + } + let footer = truncate_end( &format!( - " Shift-Tab mode | Enter send | Ctrl-J newline | Ctrl-O tools | wheel/PgUp/PgDn scroll | Ctrl-C twice exit {}", + " / 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, @@ -90,6 +96,71 @@ pub fn max_transcript_scroll( .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 { + 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 selected = menu.selected_index().unwrap_or(0); + let start = if selected >= visible_items { + selected + 1 - visible_items + } else { + 0 + }; + let end = (start + visible_items).min(menu.items.len()); + let line_width = area.width.saturating_sub(2) as usize; + let mut lines = Vec::new(); + + for idx in start..end { + let item = &menu.items[idx]; + let marker = if idx == selected { "›" } else { " " }; + let mut text = format!("{marker} {}", item.label); + if let Some(detail) = &item.detail { + text.push_str(" "); + text.push_str(detail); + } + let style = if idx == selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + lines.push(Line::styled(truncate_end(&text, line_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)