Adding slash commands
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod autofill;
|
||||
pub mod events;
|
||||
pub mod render;
|
||||
pub mod terminal;
|
||||
|
||||
+73
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user