Adding slash commands

This commit is contained in:
2026-06-21 10:39:11 -05:00
parent ef0b93dc28
commit cae390d761
5 changed files with 534 additions and 11 deletions
+10 -1
View File
@@ -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 <model>`: switch the model for future turns
- `/resume <chat>`: resume a saved chat; chat autocomplete lists chats for the current directory
- `/status`: show current chat status
On exit Cass prints:
```text
+360 -8
View File
@@ -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<String>,
@@ -89,9 +90,12 @@ async fn run_tui(
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
let mut active_assistant: Option<usize> = 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 <model>",
description: "switch the model for future turns",
takes_value: true,
},
CommandSpec {
name: "resume",
usage: "/resume <chat>",
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<Option<AutoFillMenu>> {
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<AutoFillMenu> {
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<Option<AutoFillMenu>> {
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<LocalCommand, String> {
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 <model>".into());
};
if parts.next().is_some() {
return Err("usage: /model <model>".into());
}
Ok(LocalCommand::Model(model.to_string()))
}
"/resume" => {
let Some(chat) = parts.next() else {
return Err("usage: /resume <chat>".into());
};
if parts.next().is_some() {
return Err("usage: /resume <chat>".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<String>,
) -> Vec<TranscriptBlock> {
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()
+90
View File
@@ -0,0 +1,90 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoFillItem {
pub label: String,
pub insert: String,
pub detail: Option<String>,
}
impl AutoFillItem {
pub fn new(label: impl Into<String>, insert: impl Into<String>) -> Self {
Self {
label: label.into(),
insert: insert.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> 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<AutoFillItem>,
pub selected: usize,
}
impl AutoFillMenu {
pub fn new(
title: impl Into<String>,
replacement_start: usize,
replacement_end: usize,
items: Vec<AutoFillItem>,
) -> 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<usize> {
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<String> {
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)
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod autofill;
pub mod events;
pub mod render;
pub mod terminal;
+73 -2
View File
@@ -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)