feat: Complete LLMX v0.1.0 - Rebrand from Codex with LiteLLM Integration
This release represents a comprehensive transformation of the codebase from Codex to LLMX, enhanced with LiteLLM integration to support 100+ LLM providers through a unified API. ## Major Changes ### Phase 1: Repository & Infrastructure Setup - Established new repository structure and branching strategy - Created comprehensive project documentation (CLAUDE.md, LITELLM-SETUP.md) - Set up development environment and tooling configuration ### Phase 2: Rust Workspace Transformation - Renamed all Rust crates from `codex-*` to `llmx-*` (30+ crates) - Updated package names, binary names, and workspace members - Renamed core modules: codex.rs → llmx.rs, codex_delegate.rs → llmx_delegate.rs - Updated all internal references, imports, and type names - Renamed directories: codex-rs/ → llmx-rs/, codex-backend-openapi-models/ → llmx-backend-openapi-models/ - Fixed all Rust compilation errors after mass rename ### Phase 3: LiteLLM Integration - Integrated LiteLLM for multi-provider LLM support (Anthropic, OpenAI, Azure, Google AI, AWS Bedrock, etc.) - Implemented OpenAI-compatible Chat Completions API support - Added model family detection and provider-specific handling - Updated authentication to support LiteLLM API keys - Renamed environment variables: OPENAI_BASE_URL → LLMX_BASE_URL - Added LLMX_API_KEY for unified authentication - Enhanced error handling for Chat Completions API responses - Implemented fallback mechanisms between Responses API and Chat Completions API ### Phase 4: TypeScript/Node.js Components - Renamed npm package: @codex/codex-cli → @valknar/llmx - Updated TypeScript SDK to use new LLMX APIs and endpoints - Fixed all TypeScript compilation and linting errors - Updated SDK tests to support both API backends - Enhanced mock server to handle multiple API formats - Updated build scripts for cross-platform packaging ### Phase 5: Configuration & Documentation - Updated all configuration files to use LLMX naming - Rewrote README and documentation for LLMX branding - Updated config paths: ~/.codex/ → ~/.llmx/ - Added comprehensive LiteLLM setup guide - Updated all user-facing strings and help text - Created release plan and migration documentation ### Phase 6: Testing & Validation - Fixed all Rust tests for new naming scheme - Updated snapshot tests in TUI (36 frame files) - Fixed authentication storage tests - Updated Chat Completions payload and SSE tests - Fixed SDK tests for new API endpoints - Ensured compatibility with Claude Sonnet 4.5 model - Fixed test environment variables (LLMX_API_KEY, LLMX_BASE_URL) ### Phase 7: Build & Release Pipeline - Updated GitHub Actions workflows for LLMX binary names - Fixed rust-release.yml to reference llmx-rs/ instead of codex-rs/ - Updated CI/CD pipelines for new package names - Made Apple code signing optional in release workflow - Enhanced npm packaging resilience for partial platform builds - Added Windows sandbox support to workspace - Updated dotslash configuration for new binary names ### Phase 8: Final Polish - Renamed all assets (.github images, labels, templates) - Updated VSCode and DevContainer configurations - Fixed all clippy warnings and formatting issues - Applied cargo fmt and prettier formatting across codebase - Updated issue templates and pull request templates - Fixed all remaining UI text references ## Technical Details **Breaking Changes:** - Binary name changed from `codex` to `llmx` - Config directory changed from `~/.codex/` to `~/.llmx/` - Environment variables renamed (CODEX_* → LLMX_*) - npm package renamed to `@valknar/llmx` **New Features:** - Support for 100+ LLM providers via LiteLLM - Unified authentication with LLMX_API_KEY - Enhanced model provider detection and handling - Improved error handling and fallback mechanisms **Files Changed:** - 578 files modified across Rust, TypeScript, and documentation - 30+ Rust crates renamed and updated - Complete rebrand of UI, CLI, and documentation - All tests updated and passing **Dependencies:** - Updated Cargo.lock with new package names - Updated npm dependencies in llmx-cli - Enhanced OpenAPI models for LLMX backend This release establishes LLMX as a standalone project with comprehensive LiteLLM integration, maintaining full backward compatibility with existing functionality while opening support for a wide ecosystem of LLM providers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Sebastian Krüger <support@pivoine.art>
This commit is contained in:
552
llmx-rs/tui/src/bottom_pane/approval_overlay.rs
Normal file
552
llmx-rs/tui/src/bottom_pane/approval_overlay.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::list_selection_view::ListSelectionView;
|
||||
use crate::bottom_pane::list_selection_view::SelectionItem;
|
||||
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
||||
use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use llmx_core::protocol::FileChange;
|
||||
use llmx_core::protocol::Op;
|
||||
use llmx_core::protocol::ReviewDecision;
|
||||
use llmx_core::protocol::SandboxCommandAssessment;
|
||||
use llmx_core::protocol::SandboxRiskLevel;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ApprovalRequest {
|
||||
Exec {
|
||||
id: String,
|
||||
command: Vec<String>,
|
||||
reason: Option<String>,
|
||||
risk: Option<SandboxCommandAssessment>,
|
||||
},
|
||||
ApplyPatch {
|
||||
id: String,
|
||||
reason: Option<String>,
|
||||
cwd: PathBuf,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Modal overlay asking the user to approve or deny one or more requests.
|
||||
pub(crate) struct ApprovalOverlay {
|
||||
current_request: Option<ApprovalRequest>,
|
||||
current_variant: Option<ApprovalVariant>,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: AppEventSender,
|
||||
list: ListSelectionView,
|
||||
options: Vec<ApprovalOption>,
|
||||
current_complete: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl ApprovalOverlay {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
let mut view = Self {
|
||||
current_request: None,
|
||||
current_variant: None,
|
||||
queue: Vec::new(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
list: ListSelectionView::new(Default::default(), app_event_tx),
|
||||
options: Vec::new(),
|
||||
current_complete: false,
|
||||
done: false,
|
||||
};
|
||||
view.set_current(request);
|
||||
view
|
||||
}
|
||||
|
||||
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
|
||||
self.queue.push(req);
|
||||
}
|
||||
|
||||
fn set_current(&mut self, request: ApprovalRequest) {
|
||||
self.current_request = Some(request.clone());
|
||||
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
|
||||
self.current_variant = Some(variant.clone());
|
||||
self.current_complete = false;
|
||||
let (options, params) = Self::build_options(variant, header);
|
||||
self.options = options;
|
||||
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
}
|
||||
|
||||
fn build_options(
|
||||
variant: ApprovalVariant,
|
||||
header: Box<dyn Renderable>,
|
||||
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let (options, title) = match &variant {
|
||||
ApprovalVariant::Exec { .. } => (
|
||||
exec_options(),
|
||||
"Would you like to run the following command?".to_string(),
|
||||
),
|
||||
ApprovalVariant::ApplyPatch { .. } => (
|
||||
patch_options(),
|
||||
"Would you like to make the following edits?".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let header = Box::new(ColumnRenderable::with([
|
||||
Line::from(title.bold()).into(),
|
||||
Line::from("").into(),
|
||||
header,
|
||||
]));
|
||||
|
||||
let items = options
|
||||
.iter()
|
||||
.map(|opt| SelectionItem {
|
||||
name: opt.label.clone(),
|
||||
display_shortcut: opt
|
||||
.display_shortcut
|
||||
.or_else(|| opt.additional_shortcuts.first().copied()),
|
||||
dismiss_on_select: false,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let params = SelectionViewParams {
|
||||
footer_hint: Some(Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to confirm or ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to cancel".into(),
|
||||
])),
|
||||
items,
|
||||
header,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
(options, params)
|
||||
}
|
||||
|
||||
fn apply_selection(&mut self, actual_idx: usize) {
|
||||
if self.current_complete {
|
||||
return;
|
||||
}
|
||||
let Some(option) = self.options.get(actual_idx) else {
|
||||
return;
|
||||
};
|
||||
if let Some(variant) = self.current_variant.as_ref() {
|
||||
match (&variant, option.decision) {
|
||||
(ApprovalVariant::Exec { id, command }, decision) => {
|
||||
self.handle_exec_decision(id, command, decision);
|
||||
}
|
||||
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
|
||||
self.handle_patch_decision(id, decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.current_complete = true;
|
||||
self.advance_queue();
|
||||
}
|
||||
|
||||
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
|
||||
let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||||
self.app_event_tx.send(AppEvent::LlmxOp(Op::ExecApproval {
|
||||
id: id.to_string(),
|
||||
decision,
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
|
||||
self.app_event_tx.send(AppEvent::LlmxOp(Op::PatchApproval {
|
||||
id: id.to_string(),
|
||||
decision,
|
||||
}));
|
||||
}
|
||||
|
||||
fn advance_queue(&mut self) {
|
||||
if let Some(next) = self.queue.pop() {
|
||||
self.set_current(next);
|
||||
} else {
|
||||
self.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(request) = self.current_request.as_ref() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
e => {
|
||||
if let Some(idx) = self
|
||||
.options
|
||||
.iter()
|
||||
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
|
||||
{
|
||||
self.apply_selection(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ApprovalOverlay {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.try_handle_shortcut(&key_event) {
|
||||
return;
|
||||
}
|
||||
self.list.handle_key_event(key_event);
|
||||
if let Some(idx) = self.list.take_last_selected_index() {
|
||||
self.apply_selection(idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if self.done {
|
||||
return CancellationEvent::Handled;
|
||||
}
|
||||
if !self.current_complete
|
||||
&& let Some(variant) = self.current_variant.as_ref()
|
||||
{
|
||||
match &variant {
|
||||
ApprovalVariant::Exec { id, command } => {
|
||||
self.handle_exec_decision(id, command, ReviewDecision::Abort);
|
||||
}
|
||||
ApprovalVariant::ApplyPatch { id, .. } => {
|
||||
self.handle_patch_decision(id, ReviewDecision::Abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.queue.clear();
|
||||
self.done = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
fn try_consume_approval_request(
|
||||
&mut self,
|
||||
request: ApprovalRequest,
|
||||
) -> Option<ApprovalRequest> {
|
||||
self.enqueue_request(request);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ApprovalOverlay {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.list.desired_height(width)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.list.render(area, buf);
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.list.cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequestState {
|
||||
variant: ApprovalVariant,
|
||||
header: Box<dyn Renderable>,
|
||||
}
|
||||
|
||||
impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
fn from(value: ApprovalRequest) -> Self {
|
||||
match value {
|
||||
ApprovalRequest::Exec {
|
||||
id,
|
||||
command,
|
||||
reason,
|
||||
risk,
|
||||
} => {
|
||||
let reason = reason.filter(|item| !item.is_empty());
|
||||
let has_reason = reason.is_some();
|
||||
let mut header: Vec<Line<'static>> = Vec::new();
|
||||
if let Some(reason) = reason {
|
||||
header.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
|
||||
}
|
||||
if let Some(risk) = risk.as_ref() {
|
||||
header.extend(render_risk_lines(risk));
|
||||
} else if has_reason {
|
||||
header.push(Line::from(""));
|
||||
}
|
||||
let full_cmd = strip_bash_lc_and_escape(&command);
|
||||
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
||||
if let Some(first) = full_cmd_lines.first_mut() {
|
||||
first.spans.insert(0, Span::from("$ "));
|
||||
}
|
||||
header.extend(full_cmd_lines);
|
||||
Self {
|
||||
variant: ApprovalVariant::Exec { id, command },
|
||||
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||
}
|
||||
}
|
||||
ApprovalRequest::ApplyPatch {
|
||||
id,
|
||||
reason,
|
||||
cwd,
|
||||
changes,
|
||||
} => {
|
||||
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
if let Some(reason) = reason
|
||||
&& !reason.is_empty()
|
||||
{
|
||||
header.push(Box::new(
|
||||
Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()]))
|
||||
.wrap(Wrap { trim: false }),
|
||||
));
|
||||
header.push(Box::new(Line::from("")));
|
||||
}
|
||||
header.push(DiffSummary::new(changes, cwd).into());
|
||||
Self {
|
||||
variant: ApprovalVariant::ApplyPatch { id },
|
||||
header: Box::new(ColumnRenderable::with(header)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec<Line<'static>> {
|
||||
let level_span = match risk.risk_level {
|
||||
SandboxRiskLevel::Low => "LOW".green().bold(),
|
||||
SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(),
|
||||
SandboxRiskLevel::High => "HIGH".red().bold(),
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let description = risk.description.trim();
|
||||
if !description.is_empty() {
|
||||
lines.push(Line::from(vec![
|
||||
"Summary: ".into(),
|
||||
description.to_string().into(),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(vec!["Risk: ".into(), level_span].into());
|
||||
lines.push(Line::from(""));
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ApprovalVariant {
|
||||
Exec { id: String, command: Vec<String> },
|
||||
ApplyPatch { id: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ApprovalOption {
|
||||
label: String,
|
||||
decision: ReviewDecision,
|
||||
display_shortcut: Option<KeyBinding>,
|
||||
additional_shortcuts: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
impl ApprovalOption {
|
||||
fn shortcuts(&self) -> impl Iterator<Item = KeyBinding> + '_ {
|
||||
self.display_shortcut
|
||||
.into_iter()
|
||||
.chain(self.additional_shortcuts.iter().copied())
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_options() -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, proceed".to_string(),
|
||||
decision: ReviewDecision::Approved,
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Yes, and don't ask again for this command".to_string(),
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, and tell LLMX what to do differently".to_string(),
|
||||
decision: ReviewDecision::Abort,
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn patch_options() -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, proceed".to_string(),
|
||||
decision: ReviewDecision::Approved,
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, and tell LLMX what to do differently".to_string(),
|
||||
decision: ReviewDecision::Abort,
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "test".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
reason: Some("reason".to_string()),
|
||||
risk: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_aborts_and_clears_queue() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
|
||||
view.enqueue_request(make_exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
|
||||
assert!(view.queue.is_empty());
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_triggers_selection() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
|
||||
assert!(!view.is_complete());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
// We expect at least one LlmxOp message in the queue.
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if matches!(ev, AppEvent::LlmxOp(_)) {
|
||||
saw_op = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_op, "expected approval decision to emit an op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_includes_command_snippet() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let command = vec!["echo".into(), "hello".into(), "world".into()];
|
||||
let exec_request = ApprovalRequest::Exec {
|
||||
id: "test".into(),
|
||||
command,
|
||||
reason: None,
|
||||
risk: None,
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
|
||||
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
|
||||
|
||||
let rendered: Vec<String> = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
(0..buf.area.width)
|
||||
.map(|col| buf[(col, row)].symbol().to_string())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("echo hello world")),
|
||||
"expected header to include command snippet, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_wraps_with_two_space_indent() {
|
||||
let command = vec![
|
||||
"/bin/zsh".into(),
|
||||
"-lc".into(),
|
||||
"git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(),
|
||||
];
|
||||
let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved);
|
||||
let lines = cell.display_lines(28);
|
||||
let rendered: Vec<String> = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
let expected = vec![
|
||||
"✔ You approved llmx to run".to_string(),
|
||||
" git add tui/src/render/".to_string(),
|
||||
" mod.rs tui/src/render/".to_string(),
|
||||
" renderable.rs this time".to_string(),
|
||||
];
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_sets_last_selected_index_without_dismissing() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
view.is_complete(),
|
||||
"exec approval should complete without queued requests"
|
||||
);
|
||||
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::LlmxOp(Op::ExecApproval { decision: d, .. }) = ev {
|
||||
decision = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ReviewDecision::ApprovedForSession));
|
||||
}
|
||||
}
|
||||
37
llmx-rs/tui/src/bottom_pane/bottom_pane_view.rs
Normal file
37
llmx-rs/tui/src/bottom_pane/bottom_pane_view.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Trait implemented by every view that can be shown in the bottom pane.
|
||||
pub(crate) trait BottomPaneView: Renderable {
|
||||
/// Handle a key event while the view is active. A redraw is always
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
|
||||
|
||||
/// Return `true` if the view has finished and should be removed.
|
||||
fn is_complete(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C while this view is active.
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
CancellationEvent::NotHandled
|
||||
}
|
||||
|
||||
/// Optional paste handler. Return true if the view modified its state and
|
||||
/// needs a redraw.
|
||||
fn handle_paste(&mut self, _pasted: String) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Try to handle approval request; return the original value if not
|
||||
/// consumed.
|
||||
fn try_consume_approval_request(
|
||||
&mut self,
|
||||
request: ApprovalRequest,
|
||||
) -> Option<ApprovalRequest> {
|
||||
Some(request)
|
||||
}
|
||||
}
|
||||
3481
llmx-rs/tui/src/bottom_pane/chat_composer.rs
Normal file
3481
llmx-rs/tui/src/bottom_pane/chat_composer.rs
Normal file
File diff suppressed because it is too large
Load Diff
300
llmx-rs/tui/src/bottom_pane/chat_composer_history.rs
Normal file
300
llmx-rs/tui/src/bottom_pane/chat_composer_history.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use llmx_core::protocol::Op;
|
||||
|
||||
/// State machine that manages shell-style history navigation (Up/Down) inside
|
||||
/// the chat composer. This struct is intentionally decoupled from the
|
||||
/// rendering widget so the logic remains isolated and easier to test.
|
||||
pub(crate) struct ChatComposerHistory {
|
||||
/// Identifier of the history log as reported by `SessionConfiguredEvent`.
|
||||
history_log_id: Option<u64>,
|
||||
/// Number of entries already present in the persistent cross-session
|
||||
/// history file when the session started.
|
||||
history_entry_count: usize,
|
||||
|
||||
/// Messages submitted by the user *during this UI session* (newest at END).
|
||||
local_history: Vec<String>,
|
||||
|
||||
/// Cache of persistent history entries fetched on-demand.
|
||||
fetched_history: HashMap<usize, String>,
|
||||
|
||||
/// Current cursor within the combined (persistent + local) history. `None`
|
||||
/// indicates the user is *not* currently browsing history.
|
||||
history_cursor: Option<isize>,
|
||||
|
||||
/// The text that was last inserted into the composer as a result of
|
||||
/// history navigation. Used to decide if further Up/Down presses should be
|
||||
/// treated as navigation versus normal cursor movement.
|
||||
last_history_text: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatComposerHistory {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
history_log_id: None,
|
||||
history_entry_count: 0,
|
||||
local_history: Vec::new(),
|
||||
fetched_history: HashMap::new(),
|
||||
history_cursor: None,
|
||||
last_history_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update metadata when a new session is configured.
|
||||
pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||||
self.history_log_id = Some(log_id);
|
||||
self.history_entry_count = entry_count;
|
||||
self.fetched_history.clear();
|
||||
self.local_history.clear();
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
}
|
||||
|
||||
/// Record a message submitted by the user in the current session so it can
|
||||
/// be recalled later.
|
||||
pub fn record_local_submission(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
|
||||
// Avoid inserting a duplicate if identical to the previous entry.
|
||||
if self.local_history.last().is_some_and(|prev| prev == text) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.local_history.push(text.to_string());
|
||||
}
|
||||
|
||||
/// Reset navigation tracking so the next Up key resumes from the latest entry.
|
||||
pub fn reset_navigation(&mut self) {
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
}
|
||||
|
||||
/// Should Up/Down key presses be interpreted as history navigation given
|
||||
/// the current content and cursor position of `textarea`?
|
||||
pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
|
||||
if self.history_entry_count == 0 && self.local_history.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Textarea is not empty – only navigate when cursor is at start and
|
||||
// text matches last recalled history entry so regular editing is not
|
||||
// hijacked.
|
||||
if cursor != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(&self.last_history_text, Some(prev) if prev == text)
|
||||
}
|
||||
|
||||
/// Handle <Up>. Returns true when the key was consumed and the caller
|
||||
/// should request a redraw.
|
||||
pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
|
||||
let total_entries = self.history_entry_count + self.local_history.len();
|
||||
if total_entries == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let next_idx = match self.history_cursor {
|
||||
None => (total_entries as isize) - 1,
|
||||
Some(0) => return None, // already at oldest
|
||||
Some(idx) => idx - 1,
|
||||
};
|
||||
|
||||
self.history_cursor = Some(next_idx);
|
||||
self.populate_history_at_index(next_idx as usize, app_event_tx)
|
||||
}
|
||||
|
||||
/// Handle <Down>.
|
||||
pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<String> {
|
||||
let total_entries = self.history_entry_count + self.local_history.len();
|
||||
if total_entries == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let next_idx_opt = match self.history_cursor {
|
||||
None => return None, // not browsing
|
||||
Some(idx) if (idx as usize) + 1 >= total_entries => None,
|
||||
Some(idx) => Some(idx + 1),
|
||||
};
|
||||
|
||||
match next_idx_opt {
|
||||
Some(idx) => {
|
||||
self.history_cursor = Some(idx);
|
||||
self.populate_history_at_index(idx as usize, app_event_tx)
|
||||
}
|
||||
None => {
|
||||
// Past newest – clear and exit browsing mode.
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
Some(String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrate a GetHistoryEntryResponse event.
|
||||
pub fn on_entry_response(
|
||||
&mut self,
|
||||
log_id: u64,
|
||||
offset: usize,
|
||||
entry: Option<String>,
|
||||
) -> Option<String> {
|
||||
if self.history_log_id != Some(log_id) {
|
||||
return None;
|
||||
}
|
||||
let text = entry?;
|
||||
self.fetched_history.insert(offset, text.clone());
|
||||
|
||||
if self.history_cursor == Some(offset as isize) {
|
||||
self.last_history_text = Some(text.clone());
|
||||
return Some(text);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn populate_history_at_index(
|
||||
&mut self,
|
||||
global_idx: usize,
|
||||
app_event_tx: &AppEventSender,
|
||||
) -> Option<String> {
|
||||
if global_idx >= self.history_entry_count {
|
||||
// Local entry.
|
||||
if let Some(text) = self
|
||||
.local_history
|
||||
.get(global_idx - self.history_entry_count)
|
||||
{
|
||||
self.last_history_text = Some(text.clone());
|
||||
return Some(text.clone());
|
||||
}
|
||||
} else if let Some(text) = self.fetched_history.get(&global_idx) {
|
||||
self.last_history_text = Some(text.clone());
|
||||
return Some(text.clone());
|
||||
} else if let Some(log_id) = self.history_log_id {
|
||||
let op = Op::GetHistoryEntryRequest {
|
||||
offset: global_idx,
|
||||
log_id,
|
||||
};
|
||||
app_event_tx.send(AppEvent::LlmxOp(op));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use llmx_core::protocol::Op;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn duplicate_submissions_are_not_recorded() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
|
||||
// Empty submissions are ignored.
|
||||
history.record_local_submission("");
|
||||
assert_eq!(history.local_history.len(), 0);
|
||||
|
||||
// First entry is recorded.
|
||||
history.record_local_submission("hello");
|
||||
assert_eq!(history.local_history.len(), 1);
|
||||
assert_eq!(history.local_history.last().unwrap(), "hello");
|
||||
|
||||
// Identical consecutive entry is skipped.
|
||||
history.record_local_submission("hello");
|
||||
assert_eq!(history.local_history.len(), 1);
|
||||
|
||||
// Different entry is recorded.
|
||||
history.record_local_submission("world");
|
||||
assert_eq!(history.local_history.len(), 2);
|
||||
assert_eq!(history.local_history.last().unwrap(), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_with_async_fetch() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
|
||||
let mut history = ChatComposerHistory::new();
|
||||
// Pretend there are 3 persistent entries.
|
||||
history.set_metadata(1, 3);
|
||||
|
||||
// First Up should request offset 2 (latest) and await async data.
|
||||
assert!(history.should_handle_navigation("", 0));
|
||||
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
|
||||
|
||||
// Verify that an AppEvent::LlmxOp with the correct GetHistoryEntryRequest was sent.
|
||||
let event = rx.try_recv().expect("expected AppEvent to be sent");
|
||||
let AppEvent::LlmxOp(history_request1) = event else {
|
||||
panic!("unexpected event variant");
|
||||
};
|
||||
assert_eq!(
|
||||
Op::GetHistoryEntryRequest {
|
||||
log_id: 1,
|
||||
offset: 2
|
||||
},
|
||||
history_request1
|
||||
);
|
||||
|
||||
// Inject the async response.
|
||||
assert_eq!(
|
||||
Some("latest".into()),
|
||||
history.on_entry_response(1, 2, Some("latest".into()))
|
||||
);
|
||||
|
||||
// Next Up should move to offset 1.
|
||||
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
|
||||
|
||||
// Verify second LlmxOp event for offset 1.
|
||||
let event2 = rx.try_recv().expect("expected second event");
|
||||
let AppEvent::LlmxOp(history_request_2) = event2 else {
|
||||
panic!("unexpected event variant");
|
||||
};
|
||||
assert_eq!(
|
||||
Op::GetHistoryEntryRequest {
|
||||
log_id: 1,
|
||||
offset: 1
|
||||
},
|
||||
history_request_2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Some("older".into()),
|
||||
history.on_entry_response(1, 1, Some("older".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_navigation_resets_cursor() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
|
||||
let mut history = ChatComposerHistory::new();
|
||||
history.set_metadata(1, 3);
|
||||
history.fetched_history.insert(1, "command2".into());
|
||||
history.fetched_history.insert(2, "command3".into());
|
||||
|
||||
assert_eq!(Some("command3".into()), history.navigate_up(&tx));
|
||||
assert_eq!(Some("command2".into()), history.navigate_up(&tx));
|
||||
|
||||
history.reset_navigation();
|
||||
assert!(history.history_cursor.is_none());
|
||||
assert!(history.last_history_text.is_none());
|
||||
|
||||
assert_eq!(Some("command3".into()), history.navigate_up(&tx));
|
||||
}
|
||||
}
|
||||
364
llmx-rs/tui/src/bottom_pane/command_popup.rs
Normal file
364
llmx-rs/tui/src/bottom_pane/command_popup.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use llmx_common::fuzzy_match::fuzzy_match;
|
||||
use llmx_protocol::custom_prompts::CustomPrompt;
|
||||
use llmx_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CommandItem {
|
||||
Builtin(SlashCommand),
|
||||
// Index into `prompts`
|
||||
UserPrompt(usize),
|
||||
}
|
||||
|
||||
pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
builtins: Vec<(&'static str, SlashCommand)>,
|
||||
prompts: Vec<CustomPrompt>,
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
|
||||
let builtins = built_in_slash_commands();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self {
|
||||
command_filter: String::new(),
|
||||
builtins,
|
||||
prompts,
|
||||
state: ScrollState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
||||
let exclude: HashSet<String> = self
|
||||
.builtins
|
||||
.iter()
|
||||
.map(|(n, _)| (*n).to_string())
|
||||
.collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.prompts = prompts;
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
|
||||
self.prompts.get(idx)
|
||||
}
|
||||
|
||||
/// Update the filter string based on the current composer text. The text
|
||||
/// passed in is expected to start with a leading '/'. Everything after the
|
||||
/// *first* '/" on the *first* line becomes the active filter that is used
|
||||
/// to narrow down the list of available commands.
|
||||
pub(crate) fn on_composer_text_change(&mut self, text: String) {
|
||||
let first_line = text.lines().next().unwrap_or("");
|
||||
|
||||
if let Some(stripped) = first_line.strip_prefix('/') {
|
||||
// Extract the *first* token (sequence of non-whitespace
|
||||
// characters) after the slash so that `/clear something` still
|
||||
// shows the help for `/clear`.
|
||||
let token = stripped.trim_start();
|
||||
let cmd_token = token.split_whitespace().next().unwrap_or("");
|
||||
|
||||
// Update the filter keeping the original case (commands are all
|
||||
// lower-case for now but this may change in the future).
|
||||
self.command_filter = cmd_token.to_string();
|
||||
} else {
|
||||
// The composer no longer starts with '/'. Reset the filter so the
|
||||
// popup shows the *full* command list if it is still displayed
|
||||
// for some reason.
|
||||
self.command_filter.clear();
|
||||
}
|
||||
|
||||
// Reset or clamp selected index based on new filtered list.
|
||||
let matches_len = self.filtered_items().len();
|
||||
self.state.clamp_selection(matches_len);
|
||||
self.state
|
||||
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||
}
|
||||
|
||||
/// Determine the preferred height of the popup for a given width.
|
||||
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
|
||||
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
let rows = self.rows_from_matches(self.filtered());
|
||||
|
||||
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
|
||||
}
|
||||
|
||||
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||
/// paired with optional highlight indices and score. Sorted by ascending
|
||||
/// score, then by name for stability.
|
||||
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
|
||||
let filter = self.command_filter.trim();
|
||||
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
|
||||
if filter.is_empty() {
|
||||
// Built-ins first, in presentation order.
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
out.push((CommandItem::Builtin(*cmd), None, 0));
|
||||
}
|
||||
// Then prompts, already sorted by name.
|
||||
for idx in 0..self.prompts.len() {
|
||||
out.push((CommandItem::UserPrompt(idx), None, 0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
||||
out.push((CommandItem::Builtin(*cmd), Some(indices), score));
|
||||
}
|
||||
}
|
||||
// Support both search styles:
|
||||
// - Typing "name" should surface "/prompts:name" results.
|
||||
// - Typing "prompts:name" should also work.
|
||||
for (idx, p) in self.prompts.iter().enumerate() {
|
||||
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
|
||||
if let Some((indices, score)) = fuzzy_match(&display, filter) {
|
||||
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
||||
}
|
||||
}
|
||||
// When filtering, sort by ascending score and then by name for stability.
|
||||
out.sort_by(|a, b| {
|
||||
a.2.cmp(&b.2).then_with(|| {
|
||||
let an = match a.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
let bn = match b.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
an.cmp(bn)
|
||||
})
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn filtered_items(&self) -> Vec<CommandItem> {
|
||||
self.filtered().into_iter().map(|(c, _, _)| c).collect()
|
||||
}
|
||||
|
||||
fn rows_from_matches(
|
||||
&self,
|
||||
matches: Vec<(CommandItem, Option<Vec<usize>>, i32)>,
|
||||
) -> Vec<GenericDisplayRow> {
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(item, indices, _)| {
|
||||
let (name, description) = match item {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
(format!("/{}", cmd.command()), cmd.description().to_string())
|
||||
}
|
||||
CommandItem::UserPrompt(i) => {
|
||||
let prompt = &self.prompts[i];
|
||||
let description = prompt
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "send saved prompt".to_string());
|
||||
(
|
||||
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
|
||||
description,
|
||||
)
|
||||
}
|
||||
};
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
display_shortcut: None,
|
||||
description: Some(description),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Move the selection cursor one step up.
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
let len = self.filtered_items().len();
|
||||
self.state.move_up_wrap(len);
|
||||
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||
}
|
||||
|
||||
/// Move the selection cursor one step down.
|
||||
pub(crate) fn move_down(&mut self) {
|
||||
let matches_len = self.filtered_items().len();
|
||||
self.state.move_down_wrap(matches_len);
|
||||
self.state
|
||||
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||
}
|
||||
|
||||
/// Return currently selected command, if any.
|
||||
pub(crate) fn selected_item(&self) -> Option<CommandItem> {
|
||||
let matches = self.filtered_items();
|
||||
self.state
|
||||
.selected_idx
|
||||
.and_then(|idx| matches.get(idx).copied())
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for CommandPopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let rows = self.rows_from_matches(self.filtered());
|
||||
render_rows(
|
||||
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
"no matches",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn filter_includes_init_when_typing_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new());
|
||||
// Simulate the composer line starting with '/in' so the popup filters
|
||||
// matching commands by prefix.
|
||||
popup.on_composer_text_change("/in".to_string());
|
||||
|
||||
// Access the filtered list via the selected command and ensure that
|
||||
// one of the matches is the new "init" command.
|
||||
let matches = popup.filtered_items();
|
||||
let has_init = matches.iter().any(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command() == "init",
|
||||
CommandItem::UserPrompt(_) => false,
|
||||
});
|
||||
assert!(
|
||||
has_init,
|
||||
"expected '/init' to appear among filtered commands"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_init_by_exact_match() {
|
||||
let mut popup = CommandPopup::new(Vec::new());
|
||||
popup.on_composer_text_change("/init".to_string());
|
||||
|
||||
// When an exact match exists, the selected command should be that
|
||||
// command by default.
|
||||
let selected = popup.selected_item();
|
||||
match selected {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
|
||||
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
|
||||
None => panic!("expected a selected command for exact match"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_first_suggestion_for_mo() {
|
||||
let mut popup = CommandPopup::new(Vec::new());
|
||||
popup.on_composer_text_change("/mo".to_string());
|
||||
let matches = popup.filtered_items();
|
||||
match matches.first() {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
|
||||
Some(CommandItem::UserPrompt(_)) => {
|
||||
panic!("unexpected prompt ranked before '/model' for '/mo'")
|
||||
}
|
||||
None => panic!("expected at least one match for '/mo'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_discovery_lists_custom_prompts() {
|
||||
let prompts = vec![
|
||||
CustomPrompt {
|
||||
name: "foo".to_string(),
|
||||
path: "/tmp/foo.md".to_string().into(),
|
||||
content: "hello from foo".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
},
|
||||
CustomPrompt {
|
||||
name: "bar".to_string(),
|
||||
path: "/tmp/bar.md".to_string().into(),
|
||||
content: "hello from bar".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
},
|
||||
];
|
||||
let popup = CommandPopup::new(prompts);
|
||||
let items = popup.filtered_items();
|
||||
let mut prompt_names: Vec<String> = items
|
||||
.into_iter()
|
||||
.filter_map(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
prompt_names.sort();
|
||||
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_name_collision_with_builtin_is_ignored() {
|
||||
// Create a prompt named like a builtin (e.g. "init").
|
||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
||||
name: "init".to_string(),
|
||||
path: "/tmp/init.md".to_string().into(),
|
||||
content: "should be ignored".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
let items = popup.filtered_items();
|
||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
!has_collision_prompt,
|
||||
"prompt with builtin name should be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_uses_frontmatter_metadata() {
|
||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
||||
name: "draftpr".to_string(),
|
||||
path: "/tmp/draftpr.md".to_string().into(),
|
||||
content: "body".to_string(),
|
||||
description: Some("Create feature branch, commit and open draft PR.".to_string()),
|
||||
argument_hint: None,
|
||||
}]);
|
||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(
|
||||
description,
|
||||
Some("Create feature branch, commit and open draft PR.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_falls_back_when_missing() {
|
||||
let popup = CommandPopup::new(vec![CustomPrompt {
|
||||
name: "foo".to_string(),
|
||||
path: "/tmp/foo.md".to_string().into(),
|
||||
content: "body".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(description, Some("send saved prompt"));
|
||||
}
|
||||
}
|
||||
247
llmx-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
247
llmx-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use super::popup_consts::standard_popup_hint_line;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::textarea::TextArea;
|
||||
use super::textarea::TextAreaState;
|
||||
|
||||
/// Callback invoked when the user submits a custom prompt.
|
||||
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// Minimal multi-line text input view to collect custom review instructions.
|
||||
pub(crate) struct CustomPromptView {
|
||||
title: String,
|
||||
placeholder: String,
|
||||
context_label: Option<String>,
|
||||
on_submit: PromptSubmitted,
|
||||
|
||||
// UI state
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl CustomPromptView {
|
||||
pub(crate) fn new(
|
||||
title: String,
|
||||
placeholder: String,
|
||||
context_label: Option<String>,
|
||||
on_submit: PromptSubmitted,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
placeholder,
|
||||
context_label,
|
||||
on_submit,
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for CustomPromptView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
let text = self.textarea.text().trim().to_string();
|
||||
if !text.is_empty() {
|
||||
(self.on_submit)(text);
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => {
|
||||
self.textarea.input(key_event);
|
||||
}
|
||||
other => {
|
||||
self.textarea.input(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
if pasted.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.textarea.insert_str(&pasted);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for CustomPromptView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
1u16 + extra_top + self.input_height(width) + 3u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let input_height = self.input_height(area.width);
|
||||
|
||||
// Title line
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
|
||||
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
|
||||
|
||||
// Optional context line
|
||||
let mut input_y = area.y.saturating_add(1);
|
||||
if let Some(context_label) = &self.context_label {
|
||||
let context_area = Rect {
|
||||
x: area.x,
|
||||
y: input_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
|
||||
Paragraph::new(Line::from(spans)).render(context_area, buf);
|
||||
input_y = input_y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Input line
|
||||
let input_area = Rect {
|
||||
x: area.x,
|
||||
y: input_y,
|
||||
width: area.width,
|
||||
height: input_height,
|
||||
};
|
||||
if input_area.width >= 2 {
|
||||
for row in 0..input_area.height {
|
||||
Paragraph::new(Line::from(vec![gutter()])).render(
|
||||
Rect {
|
||||
x: input_area.x,
|
||||
y: input_area.y.saturating_add(row),
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
let text_area_height = input_area.height.saturating_sub(1);
|
||||
if text_area_height > 0 {
|
||||
if input_area.width > 2 {
|
||||
let blank_rect = Rect {
|
||||
x: input_area.x.saturating_add(2),
|
||||
y: input_area.y,
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
Clear.render(blank_rect, buf);
|
||||
}
|
||||
let textarea_rect = Rect {
|
||||
x: input_area.x.saturating_add(2),
|
||||
y: input_area.y.saturating_add(1),
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let mut state = self.textarea_state.borrow_mut();
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
if self.textarea.text().is_empty() {
|
||||
Paragraph::new(Line::from(self.placeholder.clone().dim()))
|
||||
.render(textarea_rect, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hint_blank_y = input_area.y.saturating_add(input_height);
|
||||
if hint_blank_y < area.y.saturating_add(area.height) {
|
||||
let blank_area = Rect {
|
||||
x: area.x,
|
||||
y: hint_blank_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Clear.render(blank_area, buf);
|
||||
}
|
||||
|
||||
let hint_y = hint_blank_y.saturating_add(1);
|
||||
if hint_y < area.y.saturating_add(area.height) {
|
||||
Paragraph::new(standard_popup_hint_line()).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: hint_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if area.height < 2 || area.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
||||
if text_area_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
let top_line_count = 1u16 + extra_offset;
|
||||
let textarea_rect = Rect {
|
||||
x: area.x.saturating_add(2),
|
||||
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomPromptView {
|
||||
fn input_height(&self, width: u16) -> u16 {
|
||||
let usable_width = width.saturating_sub(2);
|
||||
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
|
||||
text_height.saturating_add(1).min(9)
|
||||
}
|
||||
}
|
||||
|
||||
fn gutter() -> Span<'static> {
|
||||
"▌ ".cyan()
|
||||
}
|
||||
517
llmx-rs/tui/src/bottom_pane/feedback_view.rs
Normal file
517
llmx-rs/tui/src/bottom_pane/feedback_view.rs
Normal file
@@ -0,0 +1,517 @@
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::FeedbackCategory;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::history_cell;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::standard_popup_hint_line;
|
||||
use super::textarea::TextArea;
|
||||
use super::textarea::TextAreaState;
|
||||
|
||||
const BASE_ISSUE_URL: &str = "https://github.com/valknar/llmx/issues/new?template=2-bug-report.yml";
|
||||
|
||||
/// Minimal input overlay to collect an optional feedback note, then upload
|
||||
/// both logs and rollout with classification + metadata.
|
||||
pub(crate) struct FeedbackNoteView {
|
||||
category: FeedbackCategory,
|
||||
snapshot: llmx_feedback::LlmxLogSnapshot,
|
||||
rollout_path: Option<PathBuf>,
|
||||
app_event_tx: AppEventSender,
|
||||
include_logs: bool,
|
||||
|
||||
// UI state
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl FeedbackNoteView {
|
||||
pub(crate) fn new(
|
||||
category: FeedbackCategory,
|
||||
snapshot: llmx_feedback::LlmxLogSnapshot,
|
||||
rollout_path: Option<PathBuf>,
|
||||
app_event_tx: AppEventSender,
|
||||
include_logs: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
category,
|
||||
snapshot,
|
||||
rollout_path,
|
||||
app_event_tx,
|
||||
include_logs,
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) {
|
||||
let note = self.textarea.text().trim().to_string();
|
||||
let reason_opt = if note.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note.as_str())
|
||||
};
|
||||
let rollout_path_ref = self.rollout_path.as_deref();
|
||||
let classification = feedback_classification(self.category);
|
||||
|
||||
let mut thread_id = self.snapshot.thread_id.clone();
|
||||
|
||||
let result = self.snapshot.upload_feedback(
|
||||
classification,
|
||||
reason_opt,
|
||||
self.include_logs,
|
||||
if self.include_logs {
|
||||
rollout_path_ref
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let issue_url = format!("{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}");
|
||||
let prefix = if self.include_logs {
|
||||
"• Feedback uploaded."
|
||||
} else {
|
||||
"• Feedback recorded (no logs)."
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::PlainHistoryCell::new(vec![
|
||||
Line::from(format!(
|
||||
"{prefix} Please open an issue using the following URL:"
|
||||
)),
|
||||
"".into(),
|
||||
Line::from(vec![" ".into(), issue_url.cyan().underlined()]),
|
||||
"".into(),
|
||||
Line::from(vec![
|
||||
" Or mention your thread ID ".into(),
|
||||
std::mem::take(&mut thread_id).bold(),
|
||||
" in an existing issue.".into(),
|
||||
]),
|
||||
]),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(format!("Failed to upload feedback: {e}")),
|
||||
)));
|
||||
}
|
||||
}
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for FeedbackNoteView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.submit();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => {
|
||||
self.textarea.input(key_event);
|
||||
}
|
||||
other => {
|
||||
self.textarea.input(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
if pasted.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.textarea.insert_str(&pasted);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for FeedbackNoteView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
1u16 + self.input_height(width) + 3u16
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if area.height < 2 || area.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
||||
if text_area_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let top_line_count = 1u16; // title only
|
||||
let textarea_rect = Rect {
|
||||
x: area.x.saturating_add(2),
|
||||
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (title, placeholder) = feedback_title_and_placeholder(self.category);
|
||||
let input_height = self.input_height(area.width);
|
||||
|
||||
// Title line
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let title_spans: Vec<Span<'static>> = vec![gutter(), title.bold()];
|
||||
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
|
||||
|
||||
// Input line
|
||||
let input_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(1),
|
||||
width: area.width,
|
||||
height: input_height,
|
||||
};
|
||||
if input_area.width >= 2 {
|
||||
for row in 0..input_area.height {
|
||||
Paragraph::new(Line::from(vec![gutter()])).render(
|
||||
Rect {
|
||||
x: input_area.x,
|
||||
y: input_area.y.saturating_add(row),
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
let text_area_height = input_area.height.saturating_sub(1);
|
||||
if text_area_height > 0 {
|
||||
if input_area.width > 2 {
|
||||
let blank_rect = Rect {
|
||||
x: input_area.x.saturating_add(2),
|
||||
y: input_area.y,
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
Clear.render(blank_rect, buf);
|
||||
}
|
||||
let textarea_rect = Rect {
|
||||
x: input_area.x.saturating_add(2),
|
||||
y: input_area.y.saturating_add(1),
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let mut state = self.textarea_state.borrow_mut();
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
if self.textarea.text().is_empty() {
|
||||
Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hint_blank_y = input_area.y.saturating_add(input_height);
|
||||
if hint_blank_y < area.y.saturating_add(area.height) {
|
||||
let blank_area = Rect {
|
||||
x: area.x,
|
||||
y: hint_blank_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Clear.render(blank_area, buf);
|
||||
}
|
||||
|
||||
let hint_y = hint_blank_y.saturating_add(1);
|
||||
if hint_y < area.y.saturating_add(area.height) {
|
||||
Paragraph::new(standard_popup_hint_line()).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: hint_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FeedbackNoteView {
|
||||
fn input_height(&self, width: u16) -> u16 {
|
||||
let usable_width = width.saturating_sub(2);
|
||||
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
|
||||
text_height.saturating_add(1).min(9)
|
||||
}
|
||||
}
|
||||
|
||||
fn gutter() -> Span<'static> {
|
||||
"▌ ".cyan()
|
||||
}
|
||||
|
||||
fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) {
|
||||
match category {
|
||||
FeedbackCategory::BadResult => (
|
||||
"Tell us more (bad result)".to_string(),
|
||||
"(optional) Write a short description to help us further".to_string(),
|
||||
),
|
||||
FeedbackCategory::GoodResult => (
|
||||
"Tell us more (good result)".to_string(),
|
||||
"(optional) Write a short description to help us further".to_string(),
|
||||
),
|
||||
FeedbackCategory::Bug => (
|
||||
"Tell us more (bug)".to_string(),
|
||||
"(optional) Write a short description to help us further".to_string(),
|
||||
),
|
||||
FeedbackCategory::Other => (
|
||||
"Tell us more (other)".to_string(),
|
||||
"(optional) Write a short description to help us further".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn feedback_classification(category: FeedbackCategory) -> &'static str {
|
||||
match category {
|
||||
FeedbackCategory::BadResult => "bad_result",
|
||||
FeedbackCategory::GoodResult => "good_result",
|
||||
FeedbackCategory::Bug => "bug",
|
||||
FeedbackCategory::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
// Build the selection popup params for feedback categories.
|
||||
pub(crate) fn feedback_selection_params(
|
||||
app_event_tx: AppEventSender,
|
||||
) -> super::SelectionViewParams {
|
||||
super::SelectionViewParams {
|
||||
title: Some("How was this?".to_string()),
|
||||
items: vec![
|
||||
make_feedback_item(
|
||||
app_event_tx.clone(),
|
||||
"bug",
|
||||
"Crash, error message, hang, or broken UI/behavior.",
|
||||
FeedbackCategory::Bug,
|
||||
),
|
||||
make_feedback_item(
|
||||
app_event_tx.clone(),
|
||||
"bad result",
|
||||
"Output was off-target, incorrect, incomplete, or unhelpful.",
|
||||
FeedbackCategory::BadResult,
|
||||
),
|
||||
make_feedback_item(
|
||||
app_event_tx.clone(),
|
||||
"good result",
|
||||
"Helpful, correct, high‑quality, or delightful result worth celebrating.",
|
||||
FeedbackCategory::GoodResult,
|
||||
),
|
||||
make_feedback_item(
|
||||
app_event_tx,
|
||||
"other",
|
||||
"Slowness, feature suggestion, UX feedback, or anything else.",
|
||||
FeedbackCategory::Other,
|
||||
),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_feedback_item(
|
||||
app_event_tx: AppEventSender,
|
||||
name: &str,
|
||||
description: &str,
|
||||
category: FeedbackCategory,
|
||||
) -> super::SelectionItem {
|
||||
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
|
||||
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
|
||||
});
|
||||
super::SelectionItem {
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
actions: vec![action],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the upload consent popup params for a given feedback category.
|
||||
pub(crate) fn feedback_upload_consent_params(
|
||||
app_event_tx: AppEventSender,
|
||||
category: FeedbackCategory,
|
||||
rollout_path: Option<std::path::PathBuf>,
|
||||
) -> super::SelectionViewParams {
|
||||
use super::popup_consts::standard_popup_hint_line;
|
||||
let yes_action: super::SelectionAction = Box::new({
|
||||
let tx = app_event_tx.clone();
|
||||
move |sender: &AppEventSender| {
|
||||
let _ = sender;
|
||||
tx.send(AppEvent::OpenFeedbackNote {
|
||||
category,
|
||||
include_logs: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let no_action: super::SelectionAction = Box::new({
|
||||
let tx = app_event_tx;
|
||||
move |sender: &AppEventSender| {
|
||||
let _ = sender;
|
||||
tx.send(AppEvent::OpenFeedbackNote {
|
||||
category,
|
||||
include_logs: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Build header listing files that would be sent if user consents.
|
||||
let mut header_lines: Vec<Box<dyn crate::render::renderable::Renderable>> = vec![
|
||||
Line::from("Upload logs?".bold()).into(),
|
||||
Line::from("").into(),
|
||||
Line::from("The following files will be sent:".dim()).into(),
|
||||
Line::from(vec![" • ".into(), "llmx-logs.log".into()]).into(),
|
||||
];
|
||||
if let Some(path) = rollout_path.as_deref()
|
||||
&& let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string())
|
||||
{
|
||||
header_lines.push(Line::from(vec![" • ".into(), name.into()]).into());
|
||||
}
|
||||
|
||||
super::SelectionViewParams {
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
super::SelectionItem {
|
||||
name: "Yes".to_string(),
|
||||
description: Some(
|
||||
"Share the current LLMX session logs with the team for troubleshooting."
|
||||
.to_string(),
|
||||
),
|
||||
actions: vec![yes_action],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
super::SelectionItem {
|
||||
name: "No".to_string(),
|
||||
description: Some("".to_string()),
|
||||
actions: vec![no_action],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
header: Box::new(crate::render::renderable::ColumnRenderable::with(
|
||||
header_lines,
|
||||
)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
fn render(view: &FeedbackNoteView, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let mut lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..area.width {
|
||||
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line.trim_end().to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
while lines.first().is_some_and(|l| l.trim().is_empty()) {
|
||||
lines.remove(0);
|
||||
}
|
||||
while lines.last().is_some_and(|l| l.trim().is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
|
||||
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let snapshot = llmx_feedback::LlmxFeedback::new().snapshot(None);
|
||||
FeedbackNoteView::new(category, snapshot, None, tx, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_view_bad_result() {
|
||||
let view = make_view(FeedbackCategory::BadResult);
|
||||
let rendered = render(&view, 60);
|
||||
insta::assert_snapshot!("feedback_view_bad_result", rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_view_good_result() {
|
||||
let view = make_view(FeedbackCategory::GoodResult);
|
||||
let rendered = render(&view, 60);
|
||||
insta::assert_snapshot!("feedback_view_good_result", rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_view_bug() {
|
||||
let view = make_view(FeedbackCategory::Bug);
|
||||
let rendered = render(&view, 60);
|
||||
insta::assert_snapshot!("feedback_view_bug", rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_view_other() {
|
||||
let view = make_view(FeedbackCategory::Other);
|
||||
let rendered = render(&view, 60);
|
||||
insta::assert_snapshot!("feedback_view_other", rendered);
|
||||
}
|
||||
}
|
||||
154
llmx-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
154
llmx-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use llmx_file_search::FileMatch;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
|
||||
/// Visual state for the file-search popup.
|
||||
pub(crate) struct FileSearchPopup {
|
||||
/// Query corresponding to the `matches` currently shown.
|
||||
display_query: String,
|
||||
/// Latest query typed by the user. May differ from `display_query` when
|
||||
/// a search is still in-flight.
|
||||
pending_query: String,
|
||||
/// When `true` we are still waiting for results for `pending_query`.
|
||||
waiting: bool,
|
||||
/// Cached matches; paths relative to the search dir.
|
||||
matches: Vec<FileMatch>,
|
||||
/// Shared selection/scroll state.
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
impl FileSearchPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
display_query: String::new(),
|
||||
pending_query: String::new(),
|
||||
waiting: true,
|
||||
matches: Vec::new(),
|
||||
state: ScrollState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the query and reset state to *waiting*.
|
||||
pub(crate) fn set_query(&mut self, query: &str) {
|
||||
if query == self.pending_query {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if current matches are still relevant.
|
||||
let keep_existing = query.starts_with(&self.display_query);
|
||||
|
||||
self.pending_query.clear();
|
||||
self.pending_query.push_str(query);
|
||||
|
||||
self.waiting = true; // waiting for new results
|
||||
|
||||
if !keep_existing {
|
||||
self.matches.clear();
|
||||
self.state.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the popup into an "idle" state used for an empty query (just "@").
|
||||
/// Shows a hint instead of matches until the user types more characters.
|
||||
pub(crate) fn set_empty_prompt(&mut self) {
|
||||
self.display_query.clear();
|
||||
self.pending_query.clear();
|
||||
self.waiting = false;
|
||||
self.matches.clear();
|
||||
// Reset selection/scroll state when showing the empty prompt.
|
||||
self.state.reset();
|
||||
}
|
||||
|
||||
/// Replace matches when a `FileSearchResult` arrives.
|
||||
/// Replace matches. Only applied when `query` matches `pending_query`.
|
||||
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
|
||||
if query != self.pending_query {
|
||||
return; // stale
|
||||
}
|
||||
|
||||
self.display_query = query.to_string();
|
||||
self.matches = matches;
|
||||
self.waiting = false;
|
||||
let len = self.matches.len();
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||
}
|
||||
|
||||
/// Move selection cursor up.
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
let len = self.matches.len();
|
||||
self.state.move_up_wrap(len);
|
||||
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||
}
|
||||
|
||||
/// Move selection cursor down.
|
||||
pub(crate) fn move_down(&mut self) {
|
||||
let len = self.matches.len();
|
||||
self.state.move_down_wrap(len);
|
||||
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||
}
|
||||
|
||||
pub(crate) fn selected_match(&self) -> Option<&str> {
|
||||
self.state
|
||||
.selected_idx
|
||||
.and_then(|idx| self.matches.get(idx))
|
||||
.map(|file_match| file_match.path.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||
// Row count depends on whether we already have matches. If no matches
|
||||
// yet (e.g. initial search or query with no results) reserve a single
|
||||
// row so the popup is still visible. When matches are present we show
|
||||
// up to MAX_RESULTS regardless of the waiting flag so the list
|
||||
// remains stable while a newer search is in-flight.
|
||||
|
||||
self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &FileSearchPopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
||||
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.matches
|
||||
.iter()
|
||||
.map(|m| GenericDisplayRow {
|
||||
name: m.path.clone(),
|
||||
match_indices: m
|
||||
.indices
|
||||
.as_ref()
|
||||
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||
is_current: false,
|
||||
display_shortcut: None,
|
||||
description: None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let empty_message = if self.waiting {
|
||||
"loading..."
|
||||
} else {
|
||||
"no matches"
|
||||
};
|
||||
|
||||
render_rows(
|
||||
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
buf,
|
||||
&rows_all,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
empty_message,
|
||||
);
|
||||
}
|
||||
}
|
||||
472
llmx-rs/tui/src/bottom_pane/footer.rs
Normal file
472
llmx-rs/tui/src/bottom_pane/footer.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FooterProps {
|
||||
pub(crate) mode: FooterMode,
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum FooterMode {
|
||||
CtrlCReminder,
|
||||
ShortcutSummary,
|
||||
ShortcutOverlay,
|
||||
EscHint,
|
||||
ContextOnly,
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
|
||||
if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) {
|
||||
return current;
|
||||
}
|
||||
|
||||
match current {
|
||||
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
|
||||
_ => FooterMode::ShortcutOverlay,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode {
|
||||
if is_task_running {
|
||||
current
|
||||
} else {
|
||||
FooterMode::EscHint
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
||||
match current {
|
||||
FooterMode::EscHint
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::CtrlCReminder
|
||||
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn footer_height(props: FooterProps) -> u16 {
|
||||
footer_lines(props).len() as u16
|
||||
}
|
||||
|
||||
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
|
||||
Paragraph::new(prefix_lines(
|
||||
footer_lines(props),
|
||||
" ".repeat(FOOTER_INDENT_COLS).into(),
|
||||
" ".repeat(FOOTER_INDENT_COLS).into(),
|
||||
))
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
// Show the context indicator on the left, appended after the primary hint
|
||||
// (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when
|
||||
// the shortcut hint is hidden). Hide it only for the multi-line
|
||||
// ShortcutOverlay.
|
||||
match props.mode {
|
||||
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
FooterMode::ShortcutSummary => {
|
||||
let mut line = context_window_line(props.context_window_percent);
|
||||
line.push_span(" · ".dim());
|
||||
line.extend(vec![
|
||||
key_hint::plain(KeyCode::Char('?')).into(),
|
||||
" for shortcuts".dim(),
|
||||
]);
|
||||
vec![line]
|
||||
}
|
||||
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
|
||||
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
}),
|
||||
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
|
||||
FooterMode::ContextOnly => vec![context_window_line(props.context_window_percent)],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CtrlCReminderState {
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ShortcutsState {
|
||||
use_shift_enter_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
}
|
||||
|
||||
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
|
||||
let action = if state.is_task_running {
|
||||
"interrupt"
|
||||
} else {
|
||||
"quit"
|
||||
};
|
||||
Line::from(vec![
|
||||
key_hint::ctrl(KeyCode::Char('c')).into(),
|
||||
format!(" again to {action}").into(),
|
||||
])
|
||||
.dim()
|
||||
}
|
||||
|
||||
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
|
||||
let esc = key_hint::plain(KeyCode::Esc);
|
||||
if esc_backtrack_hint {
|
||||
Line::from(vec![esc.into(), " again to edit previous message".into()]).dim()
|
||||
} else {
|
||||
Line::from(vec![
|
||||
esc.into(),
|
||||
" ".into(),
|
||||
esc.into(),
|
||||
" to edit previous message".into(),
|
||||
])
|
||||
.dim()
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
let mut commands = Line::from("");
|
||||
let mut newline = Line::from("");
|
||||
let mut file_paths = Line::from("");
|
||||
let mut paste_image = Line::from("");
|
||||
let mut edit_previous = Line::from("");
|
||||
let mut quit = Line::from("");
|
||||
let mut show_transcript = Line::from("");
|
||||
|
||||
for descriptor in SHORTCUTS {
|
||||
if let Some(text) = descriptor.overlay_entry(state) {
|
||||
match descriptor.id {
|
||||
ShortcutId::Commands => commands = text,
|
||||
ShortcutId::InsertNewline => newline = text,
|
||||
ShortcutId::FilePaths => file_paths = text,
|
||||
ShortcutId::PasteImage => paste_image = text,
|
||||
ShortcutId::EditPrevious => edit_previous = text,
|
||||
ShortcutId::Quit => quit = text,
|
||||
ShortcutId::ShowTranscript => show_transcript = text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ordered = vec![
|
||||
commands,
|
||||
newline,
|
||||
file_paths,
|
||||
paste_image,
|
||||
edit_previous,
|
||||
quit,
|
||||
Line::from(""),
|
||||
show_transcript,
|
||||
];
|
||||
|
||||
build_columns(ordered)
|
||||
}
|
||||
|
||||
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
if entries.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
const COLUMNS: usize = 2;
|
||||
const COLUMN_PADDING: [usize; COLUMNS] = [4, 4];
|
||||
const COLUMN_GAP: usize = 4;
|
||||
|
||||
let rows = entries.len().div_ceil(COLUMNS);
|
||||
let target_len = rows * COLUMNS;
|
||||
let mut entries = entries;
|
||||
if entries.len() < target_len {
|
||||
entries.extend(std::iter::repeat_n(
|
||||
Line::from(""),
|
||||
target_len - entries.len(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut column_widths = [0usize; COLUMNS];
|
||||
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
let column = idx % COLUMNS;
|
||||
column_widths[column] = column_widths[column].max(entry.width());
|
||||
}
|
||||
|
||||
for (idx, width) in column_widths.iter_mut().enumerate() {
|
||||
*width += COLUMN_PADDING[idx];
|
||||
}
|
||||
|
||||
entries
|
||||
.chunks(COLUMNS)
|
||||
.map(|chunk| {
|
||||
let mut line = Line::from("");
|
||||
for (col, entry) in chunk.iter().enumerate() {
|
||||
line.extend(entry.spans.clone());
|
||||
if col < COLUMNS - 1 {
|
||||
let target_width = column_widths[col];
|
||||
let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP;
|
||||
line.push_span(Span::from(" ".repeat(padding)));
|
||||
}
|
||||
}
|
||||
line.dim()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn context_window_line(percent: Option<i64>) -> Line<'static> {
|
||||
let percent = percent.unwrap_or(100).clamp(0, 100);
|
||||
Line::from(vec![Span::from(format!("{percent}% context left")).dim()])
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShortcutId {
|
||||
Commands,
|
||||
InsertNewline,
|
||||
FilePaths,
|
||||
PasteImage,
|
||||
EditPrevious,
|
||||
Quit,
|
||||
ShowTranscript,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct ShortcutBinding {
|
||||
key: KeyBinding,
|
||||
condition: DisplayCondition,
|
||||
}
|
||||
|
||||
impl ShortcutBinding {
|
||||
fn matches(&self, state: ShortcutsState) -> bool {
|
||||
self.condition.matches(state)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DisplayCondition {
|
||||
Always,
|
||||
WhenShiftEnterHint,
|
||||
WhenNotShiftEnterHint,
|
||||
}
|
||||
|
||||
impl DisplayCondition {
|
||||
fn matches(self, state: ShortcutsState) -> bool {
|
||||
match self {
|
||||
DisplayCondition::Always => true,
|
||||
DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint,
|
||||
DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShortcutDescriptor {
|
||||
id: ShortcutId,
|
||||
bindings: &'static [ShortcutBinding],
|
||||
prefix: &'static str,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl ShortcutDescriptor {
|
||||
fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> {
|
||||
self.bindings.iter().find(|binding| binding.matches(state))
|
||||
}
|
||||
|
||||
fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
|
||||
let binding = self.binding_for(state)?;
|
||||
let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]);
|
||||
match self.id {
|
||||
ShortcutId::EditPrevious => {
|
||||
if state.esc_backtrack_hint {
|
||||
line.push_span(" again to edit previous message");
|
||||
} else {
|
||||
line.extend(vec![
|
||||
" ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to edit previous message".into(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
_ => line.push_span(self.label),
|
||||
};
|
||||
Some(line)
|
||||
}
|
||||
}
|
||||
|
||||
const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Commands,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::plain(KeyCode::Char('/')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " for commands",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::InsertNewline,
|
||||
bindings: &[
|
||||
ShortcutBinding {
|
||||
key: key_hint::shift(KeyCode::Enter),
|
||||
condition: DisplayCondition::WhenShiftEnterHint,
|
||||
},
|
||||
ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('j')),
|
||||
condition: DisplayCondition::WhenNotShiftEnterHint,
|
||||
},
|
||||
],
|
||||
prefix: "",
|
||||
label: " for newline",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::FilePaths,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::plain(KeyCode::Char('@')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " for file paths",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::PasteImage,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('v')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to paste images",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::EditPrevious,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::plain(KeyCode::Esc),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Quit,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to exit",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::ShowTranscript,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('t')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to view transcript",
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
fn snapshot_footer(name: &str, props: FooterProps) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, f.area().width, height);
|
||||
render_footer(area, f.buffer_mut(), props);
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_snapshots() {
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_default",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_shift_and_esc",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutOverlay,
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_idle",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_esc_hint_idle",
|
||||
FooterProps {
|
||||
mode: FooterMode::EscHint,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_esc_hint_primed",
|
||||
FooterProps {
|
||||
mode: FooterMode::EscHint,
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_context_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: Some(72),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
538
llmx-rs/tui/src/bottom_pane/list_selection_view.rs
Normal file
538
llmx-rs/tui/src/bottom_pane/list_selection_view.rs
Normal file
@@ -0,0 +1,538 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use itertools::Itertools as _;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
use super::selection_popup_common::render_rows;
|
||||
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
pub display_shortcut: Option<KeyBinding>,
|
||||
pub description: Option<String>,
|
||||
pub selected_description: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub actions: Vec<SelectionAction>,
|
||||
pub dismiss_on_select: bool,
|
||||
pub search_value: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct SelectionViewParams {
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
pub footer_hint: Option<Line<'static>>,
|
||||
pub items: Vec<SelectionItem>,
|
||||
pub is_searchable: bool,
|
||||
pub search_placeholder: Option<String>,
|
||||
pub header: Box<dyn Renderable>,
|
||||
}
|
||||
|
||||
impl Default for SelectionViewParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: None,
|
||||
subtitle: None,
|
||||
footer_hint: None,
|
||||
items: Vec::new(),
|
||||
is_searchable: false,
|
||||
search_placeholder: None,
|
||||
header: Box::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ListSelectionView {
|
||||
footer_hint: Option<Line<'static>>,
|
||||
items: Vec<SelectionItem>,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
is_searchable: bool,
|
||||
search_query: String,
|
||||
search_placeholder: Option<String>,
|
||||
filtered_indices: Vec<usize>,
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Box<dyn Renderable>,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let mut header = params.header;
|
||||
if params.title.is_some() || params.subtitle.is_some() {
|
||||
let title = params.title.map(|title| Line::from(title.bold()));
|
||||
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
|
||||
header = Box::new(ColumnRenderable::with([
|
||||
header,
|
||||
Box::new(title),
|
||||
Box::new(subtitle),
|
||||
]));
|
||||
}
|
||||
let mut s = Self {
|
||||
footer_hint: params.footer_hint,
|
||||
items: params.items,
|
||||
state: ScrollState::new(),
|
||||
complete: false,
|
||||
app_event_tx,
|
||||
is_searchable: params.is_searchable,
|
||||
search_query: String::new(),
|
||||
search_placeholder: if params.is_searchable {
|
||||
params.search_placeholder
|
||||
} else {
|
||||
None
|
||||
},
|
||||
filtered_indices: Vec::new(),
|
||||
last_selected_actual_idx: None,
|
||||
header,
|
||||
};
|
||||
s.apply_filter();
|
||||
s
|
||||
}
|
||||
|
||||
fn visible_len(&self) -> usize {
|
||||
self.filtered_indices.len()
|
||||
}
|
||||
|
||||
fn max_visible_rows(len: usize) -> usize {
|
||||
MAX_POPUP_ROWS.min(len.max(1))
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let previously_selected = self
|
||||
.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied())
|
||||
.or_else(|| {
|
||||
(!self.is_searchable)
|
||||
.then(|| self.items.iter().position(|item| item.is_current))
|
||||
.flatten()
|
||||
});
|
||||
|
||||
if self.is_searchable && !self.search_query.is_empty() {
|
||||
let query_lower = self.search_query.to_lowercase();
|
||||
self.filtered_indices = self
|
||||
.items
|
||||
.iter()
|
||||
.positions(|item| {
|
||||
item.search_value
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
self.filtered_indices = (0..self.items.len()).collect();
|
||||
}
|
||||
|
||||
let len = self.filtered_indices.len();
|
||||
self.state.selected_idx = self
|
||||
.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| {
|
||||
self.filtered_indices
|
||||
.get(visible_idx)
|
||||
.and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx))
|
||||
})
|
||||
.or_else(|| {
|
||||
previously_selected.and_then(|actual_idx| {
|
||||
self.filtered_indices
|
||||
.iter()
|
||||
.position(|idx| *idx == actual_idx)
|
||||
})
|
||||
})
|
||||
.or_else(|| (len > 0).then_some(0));
|
||||
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn build_rows(&self) -> Vec<GenericDisplayRow> {
|
||||
self.filtered_indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(visible_idx, actual_idx)| {
|
||||
self.items.get(*actual_idx).map(|item| {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '›' } else { ' ' };
|
||||
let name = item.name.as_str();
|
||||
let name_with_marker = if item.is_current {
|
||||
format!("{name} (current)")
|
||||
} else {
|
||||
item.name.clone()
|
||||
};
|
||||
let n = visible_idx + 1;
|
||||
let display_name = if self.is_searchable {
|
||||
// The number keys don't work when search is enabled (since we let the
|
||||
// numbers be used for the search query).
|
||||
format!("{prefix} {name_with_marker}")
|
||||
} else {
|
||||
format!("{prefix} {n}. {name_with_marker}")
|
||||
};
|
||||
let description = is_selected
|
||||
.then(|| item.selected_description.clone())
|
||||
.flatten()
|
||||
.or_else(|| item.description.clone());
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
display_shortcut: item.display_shortcut,
|
||||
match_indices: None,
|
||||
is_current: item.is_current,
|
||||
description,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let len = self.visible_len();
|
||||
self.state.move_up_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let len = self.visible_len();
|
||||
self.state.move_down_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
if let Some(idx) = self.state.selected_idx
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||
&& let Some(item) = self.items.get(*actual_idx)
|
||||
{
|
||||
self.last_selected_actual_idx = Some(*actual_idx);
|
||||
for act in &item.actions {
|
||||
act(&self.app_event_tx);
|
||||
}
|
||||
if item.dismiss_on_select {
|
||||
self.complete = true;
|
||||
}
|
||||
} else {
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_search_query(&mut self, query: String) {
|
||||
self.search_query = query;
|
||||
self.apply_filter();
|
||||
}
|
||||
|
||||
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
|
||||
self.last_selected_actual_idx.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} if self.is_searchable => {
|
||||
self.search_query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if self.is_searchable
|
||||
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
self.search_query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if !self.is_searchable
|
||||
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
if let Some(idx) = c
|
||||
.to_digit(10)
|
||||
.map(|d| d as usize)
|
||||
.and_then(|d| d.checked_sub(1))
|
||||
&& idx < self.items.len()
|
||||
{
|
||||
self.state.selected_idx = Some(idx);
|
||||
self.accept();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ListSelectionView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
let rows = self.build_rows();
|
||||
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
let mut height = self.header.desired_height(width.saturating_sub(4));
|
||||
height = height.saturating_add(rows_height + 3);
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
if self.footer_hint.is_some() {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
height
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let [content_area, footer_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
Block::default()
|
||||
.style(user_message_style())
|
||||
.render(content_area, buf);
|
||||
|
||||
let header_height = self
|
||||
.header
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
.desired_height(content_area.width.saturating_sub(4));
|
||||
let rows = self.build_rows();
|
||||
let rows_height =
|
||||
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
|
||||
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||
Constraint::Max(header_height),
|
||||
Constraint::Max(1),
|
||||
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
|
||||
Constraint::Length(rows_height),
|
||||
])
|
||||
.areas(content_area.inset(Insets::vh(1, 2)));
|
||||
|
||||
if header_area.height < header_height {
|
||||
let [header_area, elision_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
|
||||
self.header.render(header_area, buf);
|
||||
Paragraph::new(vec![
|
||||
Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(),
|
||||
])
|
||||
.render(elision_area, buf);
|
||||
} else {
|
||||
self.header.render(header_area, buf);
|
||||
}
|
||||
|
||||
if self.is_searchable {
|
||||
Line::from(self.search_query.clone()).render(search_area, buf);
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
self.search_placeholder
|
||||
.as_ref()
|
||||
.map(|placeholder| placeholder.clone().dim())
|
||||
.unwrap_or_else(|| "".into())
|
||||
} else {
|
||||
self.search_query.clone().into()
|
||||
};
|
||||
Line::from(query_span).render(search_area, buf);
|
||||
}
|
||||
|
||||
if list_area.height > 0 {
|
||||
let list_area = Rect {
|
||||
x: list_area.x - 2,
|
||||
y: list_area.y,
|
||||
width: list_area.width + 2,
|
||||
height: list_area.height,
|
||||
};
|
||||
render_rows(
|
||||
list_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
list_area.height as usize,
|
||||
"no matches",
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(hint) = &self.footer_hint {
|
||||
let hint_area = Rect {
|
||||
x: footer_area.x + 2,
|
||||
y: footer_area.y,
|
||||
width: footer_area.width.saturating_sub(2),
|
||||
height: footer_area.height,
|
||||
};
|
||||
hint.clone().dim().render(hint_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
description: Some("LLMX can read files".to_string()),
|
||||
is_current: true,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Full Access".to_string(),
|
||||
description: Some("LLMX can edit files".to_string()),
|
||||
is_current: false,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_lines(view: &ListSelectionView) -> String {
|
||||
let width = 48;
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..area.width {
|
||||
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_blank_line_between_title_and_items_without_subtitle() {
|
||||
let view = make_selection_view(None);
|
||||
assert_snapshot!(
|
||||
"list_selection_spacing_without_subtitle",
|
||||
render_lines(&view)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_blank_line_between_subtitle_and_items() {
|
||||
let view = make_selection_view(Some("Switch between LLMX approval presets"));
|
||||
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_search_query_line_when_enabled() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let items = vec![SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
description: Some("LLMX can read files".to_string()),
|
||||
is_current: false,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}];
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search branches".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
view.set_search_query("filters".to_string());
|
||||
|
||||
let lines = render_lines(&view);
|
||||
assert!(
|
||||
lines.contains("filters"),
|
||||
"expected search query line to include rendered query, got {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
758
llmx-rs/tui/src/bottom_pane/mod.rs
Normal file
758
llmx-rs/tui/src/bottom_pane/mod.rs
Normal file
@@ -0,0 +1,758 @@
|
||||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use llmx_file_search::FileMatch;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_overlay;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
pub(crate) use approval_overlay::ApprovalRequest;
|
||||
mod bottom_pane_view;
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
pub mod custom_prompt_view;
|
||||
mod file_search_popup;
|
||||
mod footer;
|
||||
mod list_selection_view;
|
||||
mod prompt_args;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod feedback_view;
|
||||
pub(crate) use feedback_view::feedback_selection_params;
|
||||
pub(crate) use feedback_view::feedback_upload_consent_params;
|
||||
mod paste_burst;
|
||||
pub mod popup_consts;
|
||||
mod queued_user_messages;
|
||||
mod scroll_state;
|
||||
mod selection_popup_common;
|
||||
mod textarea;
|
||||
pub(crate) use feedback_view::FeedbackNoteView;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CancellationEvent {
|
||||
Handled,
|
||||
NotHandled,
|
||||
}
|
||||
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
use llmx_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane {
|
||||
/// Composer is retained even when a BottomPaneView is displayed so the
|
||||
/// input state is retained when the view is closed.
|
||||
composer: ChatComposer,
|
||||
|
||||
/// Stack of views displayed instead of the composer (e.g. popups/modals).
|
||||
view_stack: Vec<Box<dyn BottomPaneView>>,
|
||||
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
|
||||
/// Inline status indicator shown above the composer while a task is running.
|
||||
status: Option<StatusIndicatorWidget>,
|
||||
/// Queued user messages to show above the composer while a turn is running.
|
||||
queued_user_messages: QueuedUserMessages,
|
||||
context_window_percent: Option<i64>,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) has_input_focus: bool,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) placeholder_text: String,
|
||||
pub(crate) disable_paste_burst: bool,
|
||||
}
|
||||
|
||||
impl BottomPane {
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
let enhanced_keys_supported = params.enhanced_keys_supported;
|
||||
Self {
|
||||
composer: ChatComposer::new(
|
||||
params.has_input_focus,
|
||||
params.app_event_tx.clone(),
|
||||
enhanced_keys_supported,
|
||||
params.placeholder_text,
|
||||
params.disable_paste_burst,
|
||||
),
|
||||
view_stack: Vec::new(),
|
||||
app_event_tx: params.app_event_tx,
|
||||
frame_requester: params.frame_requester,
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
status: None,
|
||||
queued_user_messages: QueuedUserMessages::new(),
|
||||
esc_backtrack_hint: false,
|
||||
context_window_percent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
fn active_view(&self) -> Option<&dyn BottomPaneView> {
|
||||
self.view_stack.last().map(std::convert::AsRef::as_ref)
|
||||
}
|
||||
|
||||
fn push_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.view_stack.push(view);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
// If a modal/view is active, handle it here; otherwise forward to composer.
|
||||
if let Some(view) = self.view_stack.last_mut() {
|
||||
if key_event.code == KeyCode::Esc
|
||||
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
|
||||
&& view.is_complete()
|
||||
{
|
||||
self.view_stack.pop();
|
||||
self.on_active_view_complete();
|
||||
} else {
|
||||
view.handle_key_event(key_event);
|
||||
if view.is_complete() {
|
||||
self.view_stack.clear();
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
} else {
|
||||
// If a task is running and a status line is visible, allow Esc to
|
||||
// send an interrupt even while the composer has focus.
|
||||
if matches!(key_event.code, crossterm::event::KeyCode::Esc)
|
||||
&& self.is_task_running
|
||||
&& let Some(status) = &self.status
|
||||
{
|
||||
// Send Op::Interrupt
|
||||
status.interrupt();
|
||||
self.request_redraw();
|
||||
return InputResult::None;
|
||||
}
|
||||
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
if self.composer.is_in_paste_burst() {
|
||||
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
|
||||
}
|
||||
input_result
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
/// chance to consume the event (e.g. to dismiss itself).
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(view) = self.view_stack.last_mut() {
|
||||
let event = view.on_ctrl_c();
|
||||
if matches!(event, CancellationEvent::Handled) {
|
||||
if view.is_complete() {
|
||||
self.view_stack.pop();
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
self.show_ctrl_c_quit_hint();
|
||||
}
|
||||
event
|
||||
} else if self.composer_is_empty() {
|
||||
CancellationEvent::NotHandled
|
||||
} else {
|
||||
self.view_stack.pop();
|
||||
self.clear_composer_for_ctrl_c();
|
||||
self.show_ctrl_c_quit_hint();
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, pasted: String) {
|
||||
if let Some(view) = self.view_stack.last_mut() {
|
||||
let needs_redraw = view.handle_paste(pasted);
|
||||
if view.is_complete() {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
let needs_redraw = self.composer.handle_paste(pasted);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.composer.insert_str(text);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Replace the composer text with `text`.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.composer.set_text_content(text);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
|
||||
self.composer.clear_for_ctrl_c();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Get the current composer text (for tests and programmatic checks).
|
||||
pub(crate) fn composer_text(&self) -> String {
|
||||
self.composer.current_text()
|
||||
}
|
||||
|
||||
/// Update the animated header shown to the left of the brackets in the
|
||||
/// status indicator (defaults to "Working"). No-ops if the status
|
||||
/// indicator is not active.
|
||||
pub(crate) fn update_status_header(&mut self, header: String) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.update_header(header);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(true, self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
|
||||
if self.ctrl_c_quit_hint {
|
||||
self.ctrl_c_quit_hint = false;
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(false, self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
self.ctrl_c_quit_hint
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_indicator_visible(&self) -> bool {
|
||||
self.status.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.esc_backtrack_hint = true;
|
||||
self.composer.set_esc_backtrack_hint(true);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_esc_backtrack_hint(&mut self) {
|
||||
if self.esc_backtrack_hint {
|
||||
self.esc_backtrack_hint = false;
|
||||
self.composer.set_esc_backtrack_hint(false);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
// esc_backtrack_hint_visible removed; hints are controlled internally.
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
self.composer.set_task_running(running);
|
||||
|
||||
if running {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(true);
|
||||
}
|
||||
self.request_redraw();
|
||||
} else {
|
||||
// Hide the status indicator when a task completes, but keep other modal views.
|
||||
self.hide_status_indicator();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the status indicator while leaving task-running state untouched.
|
||||
pub(crate) fn hide_status_indicator(&mut self) {
|
||||
if self.status.take().is_some() {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_status_indicator(&mut self) {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
));
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(visible);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_context_window_percent(&mut self, percent: Option<i64>) {
|
||||
if self.context_window_percent == percent {
|
||||
return;
|
||||
}
|
||||
|
||||
self.context_window_percent = percent;
|
||||
self.composer.set_context_window_percent(percent);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Show a generic list selection view with the provided items.
|
||||
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
/// Update the queued messages preview shown above the composer.
|
||||
pub(crate) fn set_queued_user_messages(&mut self, queued: Vec<String>) {
|
||||
self.queued_user_messages.messages = queued;
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update custom prompts available for the slash popup.
|
||||
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||||
self.composer.set_custom_prompts(prompts);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
self.composer.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn is_task_running(&self) -> bool {
|
||||
self.is_task_running
|
||||
}
|
||||
|
||||
/// Return true when the pane is in the regular composer state without any
|
||||
/// overlays or popups and not running a task. This is the safe context to
|
||||
/// use Esc-Esc for backtracking from the main view.
|
||||
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
|
||||
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
}
|
||||
|
||||
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.push_view(view);
|
||||
}
|
||||
|
||||
/// Called when the agent requests user approval.
|
||||
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
||||
let request = if let Some(view) = self.view_stack.last_mut() {
|
||||
match view.try_consume_approval_request(request) {
|
||||
Some(request) => request,
|
||||
None => {
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request
|
||||
};
|
||||
|
||||
// Otherwise create a new approval modal overlay.
|
||||
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone());
|
||||
self.pause_status_timer_for_modal();
|
||||
self.push_view(Box::new(modal));
|
||||
}
|
||||
|
||||
fn on_active_view_complete(&mut self) {
|
||||
self.resume_status_timer_after_modal();
|
||||
}
|
||||
|
||||
fn pause_status_timer_for_modal(&mut self) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.pause_timer();
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_status_timer_after_modal(&mut self) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.resume_timer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Height (terminal rows) required by the current bottom pane.
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn request_redraw_in(&self, dur: Duration) {
|
||||
self.frame_requester.schedule_frame_in(dur);
|
||||
}
|
||||
|
||||
// --- History helpers ---
|
||||
|
||||
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||||
self.composer.set_history_metadata(log_id, entry_count);
|
||||
}
|
||||
|
||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
self.composer.flush_paste_burst_if_due()
|
||||
}
|
||||
|
||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||
self.composer.is_in_paste_burst()
|
||||
}
|
||||
|
||||
pub(crate) fn on_history_entry_response(
|
||||
&mut self,
|
||||
log_id: u64,
|
||||
offset: usize,
|
||||
entry: Option<String>,
|
||||
) {
|
||||
let updated = self
|
||||
.composer
|
||||
.on_history_entry_response(log_id, offset, entry);
|
||||
|
||||
if updated {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
self.composer.on_file_search_result(query, matches);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
if self.view_stack.is_empty() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
self.composer.take_recent_submission_images()
|
||||
}
|
||||
|
||||
fn as_renderable(&'_ self) -> RenderableItem<'_> {
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
} else {
|
||||
let mut flex = FlexRenderable::new();
|
||||
if let Some(status) = &self.status {
|
||||
flex.push(0, RenderableItem::Borrowed(status));
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
|
||||
if self.status.is_some() || !self.queued_user_messages.messages.is_empty() {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
let mut flex2 = FlexRenderable::new();
|
||||
flex2.push(1, RenderableItem::Owned(flex.into()));
|
||||
flex2.push(0, RenderableItem::Borrowed(&self.composer));
|
||||
RenderableItem::Owned(Box::new(flex2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for BottomPane {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_renderable().render(area, buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_renderable().desired_height(width)
|
||||
}
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_renderable().cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn snapshot_buffer(buf: &Buffer) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for y in 0..buf.area().height {
|
||||
let mut row = String::new();
|
||||
for x in 0..buf.area().width {
|
||||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
lines.push(row);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_snapshot(pane: &BottomPane, area: Rect) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
pane.render(area, &mut buf);
|
||||
snapshot_buffer(&buf)
|
||||
}
|
||||
|
||||
fn exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "1".to_string(),
|
||||
command: vec!["echo".into(), "ok".into()],
|
||||
reason: None,
|
||||
risk: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
pane.push_approval_request(exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
assert!(pane.ctrl_c_quit_hint_visible());
|
||||
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
|
||||
}
|
||||
|
||||
// live ring removed; related tests deleted.
|
||||
|
||||
#[test]
|
||||
fn overlay_not_shown_above_approval_modal() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Create an approval modal (active view).
|
||||
pane.push_approval_request(exec_request());
|
||||
|
||||
// Render and verify the top row does not include an overlay.
|
||||
let area = Rect::new(0, 0, 60, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pane.render(area, &mut buf);
|
||||
|
||||
let mut r0 = String::new();
|
||||
for x in 0..area.width {
|
||||
r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
!r0.contains("Working"),
|
||||
"overlay should not render above modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_shown_after_denied_while_task_running() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Start a running task so the status indicator is active above the composer.
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Push an approval modal (e.g., command approval) which should hide the status view.
|
||||
pane.push_approval_request(exec_request());
|
||||
|
||||
// Simulate pressing 'n' (No) on the modal.
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||
|
||||
// After denial, since the task is still running, the status indicator should be
|
||||
// visible above the composer. The modal should be gone.
|
||||
assert!(
|
||||
pane.view_stack.is_empty(),
|
||||
"no active modal view after denial"
|
||||
);
|
||||
|
||||
// Render and ensure the top row includes the Working header and a composer line below.
|
||||
// Give the animation thread a moment to tick.
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pane.render(area, &mut buf);
|
||||
let mut row0 = String::new();
|
||||
for x in 0..area.width {
|
||||
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header after denial on row 0: {row0:?}"
|
||||
);
|
||||
|
||||
// Composer placeholder should be visible somewhere below.
|
||||
let mut found_composer = false;
|
||||
for y in 1..area.height {
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
if row.contains("Ask LLMX") {
|
||||
found_composer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_composer,
|
||||
"expected composer visible under status line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_indicator_visible_during_command_execution() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Begin a task: show initial status.
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Use a height that allows the status line to be visible above the composer.
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pane.render(area, &mut buf);
|
||||
|
||||
let bufs = snapshot_buffer(&buf);
|
||||
assert!(bufs.contains("• Working"), "expected Working header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_composer_fill_height_without_bottom_padding() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Activate spinner (status view replaces composer) with no live ring.
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Use height == desired_height; expect spacer + status + composer rows without trailing padding.
|
||||
let height = pane.desired_height(30);
|
||||
assert!(
|
||||
height >= 3,
|
||||
"expected at least 3 rows to render spacer, status, and composer; got {height}"
|
||||
);
|
||||
let area = Rect::new(0, 0, 30, height);
|
||||
assert_snapshot!(
|
||||
"status_and_composer_fill_height_without_bottom_padding",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_messages_visible_when_status_hidden_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
pane.hide_status_indicator();
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
assert_snapshot!(
|
||||
"queued_messages_visible_when_status_hidden_snapshot",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_queued_messages_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask LLMX to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
assert_snapshot!(
|
||||
"status_and_queued_messages_snapshot",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
}
|
||||
}
|
||||
267
llmx-rs/tui/src/bottom_pane/paste_burst.rs
Normal file
267
llmx-rs/tui/src/bottom_pane/paste_burst.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
// Heuristic thresholds for detecting paste-like input bursts.
|
||||
// Detect quickly to avoid showing typed prefix before paste is recognized
|
||||
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
||||
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
||||
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PasteBurst {
|
||||
last_plain_char_time: Option<Instant>,
|
||||
consecutive_plain_char_burst: u16,
|
||||
burst_window_until: Option<Instant>,
|
||||
buffer: String,
|
||||
active: bool,
|
||||
// Hold first fast char briefly to avoid rendering flicker
|
||||
pending_first_char: Option<(char, Instant)>,
|
||||
}
|
||||
|
||||
pub(crate) enum CharDecision {
|
||||
/// Start buffering and retroactively capture some already-inserted chars.
|
||||
BeginBuffer { retro_chars: u16 },
|
||||
/// We are currently buffering; append the current char into the buffer.
|
||||
BufferAppend,
|
||||
/// Do not insert/render this char yet; temporarily save the first fast
|
||||
/// char while we wait to see if a paste-like burst follows.
|
||||
RetainFirstChar,
|
||||
/// Begin buffering using the previously saved first char (no retro grab needed).
|
||||
BeginBufferFromPending,
|
||||
}
|
||||
|
||||
pub(crate) struct RetroGrab {
|
||||
pub start_byte: usize,
|
||||
pub grabbed: String,
|
||||
}
|
||||
|
||||
pub(crate) enum FlushResult {
|
||||
Paste(String),
|
||||
Typed(char),
|
||||
None,
|
||||
}
|
||||
|
||||
impl PasteBurst {
|
||||
/// Recommended delay to wait between simulated keypresses (or before
|
||||
/// scheduling a UI tick) so that a pending fast keystroke is flushed
|
||||
/// out of the burst detector as normal typed input.
|
||||
///
|
||||
/// Primarily used by tests and by the TUI to reliably cross the
|
||||
/// paste-burst timing threshold.
|
||||
pub fn recommended_flush_delay() -> Duration {
|
||||
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
|
||||
}
|
||||
|
||||
/// Entry point: decide how to treat a plain char with current timing.
|
||||
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
|
||||
match self.last_plain_char_time {
|
||||
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
||||
self.consecutive_plain_char_burst =
|
||||
self.consecutive_plain_char_burst.saturating_add(1)
|
||||
}
|
||||
_ => self.consecutive_plain_char_burst = 1,
|
||||
}
|
||||
self.last_plain_char_time = Some(now);
|
||||
|
||||
if self.active {
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return CharDecision::BufferAppend;
|
||||
}
|
||||
|
||||
// If we already held a first char and receive a second fast char,
|
||||
// start buffering without retro-grabbing (we never rendered the first).
|
||||
if let Some((held, held_at)) = self.pending_first_char
|
||||
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
|
||||
{
|
||||
self.active = true;
|
||||
// take() to clear pending; we already captured the held char above
|
||||
let _ = self.pending_first_char.take();
|
||||
self.buffer.push(held);
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return CharDecision::BeginBufferFromPending;
|
||||
}
|
||||
|
||||
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
||||
return CharDecision::BeginBuffer {
|
||||
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
|
||||
};
|
||||
}
|
||||
|
||||
// Save the first fast char very briefly to see if a burst follows.
|
||||
self.pending_first_char = Some((ch, now));
|
||||
CharDecision::RetainFirstChar
|
||||
}
|
||||
|
||||
/// Flush the buffered burst if the inter-key timeout has elapsed.
|
||||
///
|
||||
/// Returns Some(String) when either:
|
||||
/// - We were actively buffering paste-like input and the buffer is now
|
||||
/// emitted as a single pasted string; or
|
||||
/// - We had saved a single fast first-char with no subsequent burst and we
|
||||
/// now emit that char as normal typed input.
|
||||
///
|
||||
/// Returns None if the timeout has not elapsed or there is nothing to flush.
|
||||
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
|
||||
let timed_out = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
||||
if timed_out && self.is_active_internal() {
|
||||
self.active = false;
|
||||
let out = std::mem::take(&mut self.buffer);
|
||||
FlushResult::Paste(out)
|
||||
} else if timed_out {
|
||||
// If we were saving a single fast char and no burst followed,
|
||||
// flush it as normal typed input.
|
||||
if let Some((ch, _at)) = self.pending_first_char.take() {
|
||||
FlushResult::Typed(ch)
|
||||
} else {
|
||||
FlushResult::None
|
||||
}
|
||||
} else {
|
||||
FlushResult::None
|
||||
}
|
||||
}
|
||||
|
||||
/// While bursting: accumulate a newline into the buffer instead of
|
||||
/// submitting the textarea.
|
||||
///
|
||||
/// Returns true if a newline was appended (we are in a burst context),
|
||||
/// false otherwise.
|
||||
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
|
||||
if self.is_active() {
|
||||
self.buffer.push('\n');
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide if Enter should insert a newline (burst context) vs submit.
|
||||
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
|
||||
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
|
||||
self.is_active() || in_burst_window
|
||||
}
|
||||
|
||||
/// Keep the burst window alive.
|
||||
pub fn extend_window(&mut self, now: Instant) {
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
/// Begin buffering with retroactively grabbed text.
|
||||
pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
|
||||
if !grabbed.is_empty() {
|
||||
self.buffer.push_str(&grabbed);
|
||||
}
|
||||
self.active = true;
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
/// Append a char into the burst buffer.
|
||||
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
|
||||
self.buffer.push(ch);
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
/// Try to append a char into the burst buffer only if a burst is already active.
|
||||
///
|
||||
/// Returns true when the char was captured into the existing burst, false otherwise.
|
||||
pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
|
||||
if self.active || !self.buffer.is_empty() {
|
||||
self.append_char_to_buffer(ch, now);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether to begin buffering by retroactively capturing recent
|
||||
/// chars from the slice before the cursor.
|
||||
///
|
||||
/// Heuristic: if the retro-grabbed slice contains any whitespace or is
|
||||
/// sufficiently long (>= 16 characters), treat it as paste-like to avoid
|
||||
/// rendering the typed prefix momentarily before the paste is recognized.
|
||||
/// This favors responsiveness and prevents flicker for typical pastes
|
||||
/// (URLs, file paths, multiline text) while not triggering on short words.
|
||||
///
|
||||
/// Returns Some(RetroGrab) with the start byte and grabbed text when we
|
||||
/// decide to buffer retroactively; otherwise None.
|
||||
pub fn decide_begin_buffer(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
before: &str,
|
||||
retro_chars: usize,
|
||||
) -> Option<RetroGrab> {
|
||||
let start_byte = retro_start_index(before, retro_chars);
|
||||
let grabbed = before[start_byte..].to_string();
|
||||
let looks_pastey =
|
||||
grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
|
||||
if looks_pastey {
|
||||
// Note: caller is responsible for removing this slice from UI text.
|
||||
self.begin_with_retro_grabbed(grabbed.clone(), now);
|
||||
Some(RetroGrab {
|
||||
start_byte,
|
||||
grabbed,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Before applying modified/non-char input: flush buffered burst immediately.
|
||||
pub fn flush_before_modified_input(&mut self) -> Option<String> {
|
||||
if !self.is_active() {
|
||||
return None;
|
||||
}
|
||||
self.active = false;
|
||||
let mut out = std::mem::take(&mut self.buffer);
|
||||
if let Some((ch, _at)) = self.pending_first_char.take() {
|
||||
out.push(ch);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Clear only the timing window and any pending first-char.
|
||||
///
|
||||
/// Does not emit or clear the buffered text itself; callers should have
|
||||
/// already flushed (if needed) via one of the flush methods above.
|
||||
pub fn clear_window_after_non_char(&mut self) {
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.last_plain_char_time = None;
|
||||
self.burst_window_until = None;
|
||||
self.active = false;
|
||||
self.pending_first_char = None;
|
||||
}
|
||||
|
||||
/// Returns true if we are in any paste-burst related transient state
|
||||
/// (actively buffering, have a non-empty buffer, or have saved the first
|
||||
/// fast char while waiting for a potential burst).
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active_internal() || self.pending_first_char.is_some()
|
||||
}
|
||||
|
||||
fn is_active_internal(&self) -> bool {
|
||||
self.active || !self.buffer.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear_after_explicit_paste(&mut self) {
|
||||
self.last_plain_char_time = None;
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.burst_window_until = None;
|
||||
self.active = false;
|
||||
self.buffer.clear();
|
||||
self.pending_first_char = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
|
||||
if retro_chars == 0 {
|
||||
return before.len();
|
||||
}
|
||||
before
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(retro_chars.saturating_sub(1))
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
21
llmx-rs/tui/src/bottom_pane/popup_consts.rs
Normal file
21
llmx-rs/tui/src/bottom_pane/popup_consts.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Shared popup-related constants for bottom pane widgets.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::key_hint;
|
||||
|
||||
/// Maximum number of rows any popup should attempt to display.
|
||||
/// Keep this consistent across all popups for a uniform feel.
|
||||
pub(crate) const MAX_POPUP_ROWS: usize = 8;
|
||||
|
||||
/// Standard footer hint text used by popups.
|
||||
pub(crate) fn standard_popup_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to confirm or ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to go back".into(),
|
||||
])
|
||||
}
|
||||
406
llmx-rs/tui/src/bottom_pane/prompt_args.rs
Normal file
406
llmx-rs/tui/src/bottom_pane/prompt_args.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
use lazy_static::lazy_static;
|
||||
use llmx_protocol::custom_prompts::CustomPrompt;
|
||||
use llmx_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use regex_lite::Regex;
|
||||
use shlex::Shlex;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
lazy_static! {
|
||||
static ref PROMPT_ARG_REGEX: Regex =
|
||||
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptArgsError {
|
||||
MissingAssignment { token: String },
|
||||
MissingKey { token: String },
|
||||
}
|
||||
|
||||
impl PromptArgsError {
|
||||
fn describe(&self, command: &str) -> String {
|
||||
match self {
|
||||
PromptArgsError::MissingAssignment { token } => format!(
|
||||
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
|
||||
),
|
||||
PromptArgsError::MissingKey { token } => {
|
||||
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptExpansionError {
|
||||
Args {
|
||||
command: String,
|
||||
error: PromptArgsError,
|
||||
},
|
||||
MissingArgs {
|
||||
command: String,
|
||||
missing: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PromptExpansionError {
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
PromptExpansionError::Args { command, error } => error.describe(command),
|
||||
PromptExpansionError::MissingArgs { command, missing } => {
|
||||
let list = missing.join(", ");
|
||||
format!(
|
||||
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a first-line slash command of the form `/name <rest>`.
|
||||
/// Returns `(name, rest_after_name)` if the line begins with `/` and contains
|
||||
/// a non-empty name; otherwise returns `None`.
|
||||
pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> {
|
||||
let stripped = line.strip_prefix('/')?;
|
||||
let mut name_end = stripped.len();
|
||||
for (idx, ch) in stripped.char_indices() {
|
||||
if ch.is_whitespace() {
|
||||
name_end = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let name = &stripped[..name_end];
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rest = stripped[name_end..].trim_start();
|
||||
Some((name, rest))
|
||||
}
|
||||
|
||||
/// Parse positional arguments using shlex semantics (supports quoted tokens).
|
||||
pub fn parse_positional_args(rest: &str) -> Vec<String> {
|
||||
Shlex::new(rest).collect()
|
||||
}
|
||||
|
||||
/// Extracts the unique placeholder variable names from a prompt template.
|
||||
///
|
||||
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
|
||||
/// (for example `$USER`). The function returns the variable names without
|
||||
/// the leading `$`, de-duplicated and in the order of first appearance.
|
||||
pub fn prompt_argument_names(content: &str) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
|
||||
continue;
|
||||
}
|
||||
let name = &content[m.start() + 1..m.end()];
|
||||
// Exclude special positional aggregate token from named args.
|
||||
if name == "ARGUMENTS" {
|
||||
continue;
|
||||
}
|
||||
let name = name.to_string();
|
||||
if seen.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Parses the `key=value` pairs that follow a custom prompt name.
|
||||
///
|
||||
/// The input is split using shlex rules, so quoted values are supported
|
||||
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
|
||||
/// arguments, or an error if a token is missing `=` or if the key is empty.
|
||||
pub fn parse_prompt_inputs(rest: &str) -> Result<HashMap<String, String>, PromptArgsError> {
|
||||
let mut map = HashMap::new();
|
||||
if rest.trim().is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
for token in Shlex::new(rest) {
|
||||
let Some((key, value)) = token.split_once('=') else {
|
||||
return Err(PromptArgsError::MissingAssignment { token });
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(PromptArgsError::MissingKey { token });
|
||||
}
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
|
||||
///
|
||||
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
|
||||
/// the function returns `Ok(None)`. On success it returns
|
||||
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
|
||||
pub fn expand_custom_prompt(
|
||||
text: &str,
|
||||
custom_prompts: &[CustomPrompt],
|
||||
) -> Result<Option<String>, PromptExpansionError> {
|
||||
let Some((name, rest)) = parse_slash_name(text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Only handle custom prompts when using the explicit prompts prefix with a colon.
|
||||
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
|
||||
Some(prompt) => prompt,
|
||||
None => return Ok(None),
|
||||
};
|
||||
// If there are named placeholders, expect key=value inputs.
|
||||
let required = prompt_argument_names(&prompt.content);
|
||||
if !required.is_empty() {
|
||||
let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args {
|
||||
command: format!("/{name}"),
|
||||
error,
|
||||
})?;
|
||||
let missing: Vec<String> = required
|
||||
.into_iter()
|
||||
.filter(|k| !inputs.contains_key(k))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(PromptExpansionError::MissingArgs {
|
||||
command: format!("/{name}"),
|
||||
missing,
|
||||
});
|
||||
}
|
||||
let content = &prompt.content;
|
||||
let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| {
|
||||
if let Some(matched) = caps.get(0)
|
||||
&& matched.start() > 0
|
||||
&& content.as_bytes()[matched.start() - 1] == b'$'
|
||||
{
|
||||
return matched.as_str().to_string();
|
||||
}
|
||||
let whole = &caps[0];
|
||||
let key = &whole[1..];
|
||||
inputs
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| whole.to_string())
|
||||
});
|
||||
return Ok(Some(replaced.into_owned()));
|
||||
}
|
||||
|
||||
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
|
||||
let pos_args: Vec<String> = Shlex::new(rest).collect();
|
||||
let expanded = expand_numeric_placeholders(&prompt.content, &pos_args);
|
||||
Ok(Some(expanded))
|
||||
}
|
||||
|
||||
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
|
||||
pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
|
||||
if content.contains("$ARGUMENTS") {
|
||||
return true;
|
||||
}
|
||||
let bytes = content.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'$' {
|
||||
let b1 = bytes[i + 1];
|
||||
if (b'1'..=b'9').contains(&b1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
|
||||
/// Returns empty when the command name does not match or when there are no args.
|
||||
pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec<String> {
|
||||
let trimmed = line.trim_start();
|
||||
let Some(rest) = trimmed.strip_prefix('/') else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Require the explicit prompts prefix for custom prompt invocations.
|
||||
let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut parts = after_prefix.splitn(2, char::is_whitespace);
|
||||
let cmd = parts.next().unwrap_or("");
|
||||
if cmd != prompt_name {
|
||||
return Vec::new();
|
||||
}
|
||||
let args_str = parts.next().unwrap_or("").trim();
|
||||
if args_str.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
parse_positional_args(args_str)
|
||||
}
|
||||
|
||||
/// If the prompt only uses numeric placeholders and the first line contains
|
||||
/// positional args for it, expand and return Some(expanded); otherwise None.
|
||||
pub fn expand_if_numeric_with_positional_args(
|
||||
prompt: &CustomPrompt,
|
||||
first_line: &str,
|
||||
) -> Option<String> {
|
||||
if !prompt_argument_names(&prompt.content).is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !prompt_has_numeric_placeholders(&prompt.content) {
|
||||
return None;
|
||||
}
|
||||
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name);
|
||||
if args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(expand_numeric_placeholders(&prompt.content, &args))
|
||||
}
|
||||
|
||||
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
|
||||
pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
let mut i = 0;
|
||||
let mut cached_joined_args: Option<String> = None;
|
||||
while let Some(off) = content[i..].find('$') {
|
||||
let j = i + off;
|
||||
out.push_str(&content[i..j]);
|
||||
let rest = &content[j..];
|
||||
let bytes = rest.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
match bytes[1] {
|
||||
b'$' => {
|
||||
out.push_str("$$");
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
b'1'..=b'9' => {
|
||||
let idx = (bytes[1] - b'1') as usize;
|
||||
if let Some(val) = args.get(idx) {
|
||||
out.push_str(val);
|
||||
}
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
|
||||
if !args.is_empty() {
|
||||
let joined = cached_joined_args.get_or_insert_with(|| args.join(" "));
|
||||
out.push_str(joined);
|
||||
}
|
||||
i = j + 1 + "ARGUMENTS".len();
|
||||
continue;
|
||||
}
|
||||
out.push('$');
|
||||
i = j + 1;
|
||||
}
|
||||
out.push_str(&content[i..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Constructs a command text for a custom prompt with arguments.
|
||||
/// Returns the text and the cursor position (inside the first double quote).
|
||||
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
|
||||
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
|
||||
let mut cursor: usize = text.len();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
text.push_str(format!(" {arg}=\"\"").as_str());
|
||||
if i == 0 {
|
||||
cursor = text.len() - 1; // inside first ""
|
||||
}
|
||||
}
|
||||
(text, cursor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_arguments_basic() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out =
|
||||
expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap();
|
||||
assert_eq!(out, Some("Review Alice changes on main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_values_ok() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt(
|
||||
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
|
||||
&prompts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_arg_token_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.contains("expected key=value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_args_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.to_lowercase().contains("missing required args"));
|
||||
assert!(err.contains("BRANCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_is_ignored() {
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER and $REAL"),
|
||||
vec!["REAL".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_remains_literal() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "literal $$USER".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap();
|
||||
assert_eq!(out, Some("literal $$USER".to_string()));
|
||||
}
|
||||
}
|
||||
157
llmx-rs/tui/src/bottom_pane/queued_user_messages.rs
Normal file
157
llmx-rs/tui/src/bottom_pane/queued_user_messages.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
|
||||
/// Widget that displays a list of user messages queued while a turn is in progress.
|
||||
pub(crate) struct QueuedUserMessages {
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
impl QueuedUserMessages {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
||||
if self.messages.is_empty() || width < 4 {
|
||||
return Box::new(());
|
||||
}
|
||||
|
||||
let mut lines = vec![];
|
||||
|
||||
for message in &self.messages {
|
||||
let wrapped = word_wrap_lines(
|
||||
message.lines().map(|line| line.dim().italic()),
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(" ↳ ".dim()))
|
||||
.subsequent_indent(Line::from(" ")),
|
||||
);
|
||||
let len = wrapped.len();
|
||||
for line in wrapped.into_iter().take(3) {
|
||||
lines.push(line);
|
||||
}
|
||||
if len > 3 {
|
||||
lines.push(Line::from(" …".dim().italic()));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
key_hint::alt(KeyCode::Up).into(),
|
||||
" edit".into(),
|
||||
])
|
||||
.dim(),
|
||||
);
|
||||
|
||||
Paragraph::new(lines).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for QueuedUserMessages {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.as_renderable(area.width).render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_renderable(width).desired_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn desired_height_empty() {
|
||||
let queue = QueuedUserMessages::new();
|
||||
assert_eq!(queue.desired_height(40), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desired_height_one_message() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue.messages.push("Hello, world!".to_string());
|
||||
assert_eq!(queue.desired_height(40), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_one_message() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue.messages.push("Hello, world!".to_string());
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!("render_one_message", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_two_messages() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue.messages.push("Hello, world!".to_string());
|
||||
queue.messages.push("This is another message".to_string());
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!("render_two_messages", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_more_than_three_messages() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue.messages.push("Hello, world!".to_string());
|
||||
queue.messages.push("This is another message".to_string());
|
||||
queue.messages.push("This is a third message".to_string());
|
||||
queue.messages.push("This is a fourth message".to_string());
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!("render_more_than_three_messages", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_wrapped_message() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue
|
||||
.messages
|
||||
.push("This is a longer message that should be wrapped".to_string());
|
||||
queue.messages.push("This is another message".to_string());
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!("render_wrapped_message", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_many_line_message() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue
|
||||
.messages
|
||||
.push("This is\na message\nwith many\nlines".to_string());
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!("render_many_line_message", format!("{buf:?}"));
|
||||
}
|
||||
}
|
||||
115
llmx-rs/tui/src/bottom_pane/scroll_state.rs
Normal file
115
llmx-rs/tui/src/bottom_pane/scroll_state.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
/// Generic scroll/selection state for a vertical list menu.
|
||||
///
|
||||
/// Encapsulates the common behavior of a selectable list that supports:
|
||||
/// - Optional selection (None when list is empty)
|
||||
/// - Wrap-around navigation on Up/Down
|
||||
/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct ScrollState {
|
||||
pub selected_idx: Option<usize>,
|
||||
pub scroll_top: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selected_idx: None,
|
||||
scroll_top: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset selection and scroll.
|
||||
pub fn reset(&mut self) {
|
||||
self.selected_idx = None;
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
|
||||
/// Clamp selection to be within the [0, len-1] range, or None when empty.
|
||||
pub fn clamp_selection(&mut self, len: usize) {
|
||||
self.selected_idx = match len {
|
||||
0 => None,
|
||||
_ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
|
||||
};
|
||||
if len == 0 {
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up by one, wrapping to the bottom when necessary.
|
||||
pub fn move_up_wrap(&mut self, len: usize) {
|
||||
if len == 0 {
|
||||
self.selected_idx = None;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
self.selected_idx = Some(match self.selected_idx {
|
||||
Some(idx) if idx > 0 => idx - 1,
|
||||
Some(_) => len - 1,
|
||||
None => 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Move selection down by one, wrapping to the top when necessary.
|
||||
pub fn move_down_wrap(&mut self, len: usize) {
|
||||
if len == 0 {
|
||||
self.selected_idx = None;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
self.selected_idx = Some(match self.selected_idx {
|
||||
Some(idx) if idx + 1 < len => idx + 1,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
|
||||
/// the window of `visible_rows`.
|
||||
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
|
||||
if len == 0 || visible_rows == 0 {
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
if let Some(sel) = self.selected_idx {
|
||||
if sel < self.scroll_top {
|
||||
self.scroll_top = sel;
|
||||
} else {
|
||||
let bottom = self.scroll_top + visible_rows - 1;
|
||||
if sel > bottom {
|
||||
self.scroll_top = sel + 1 - visible_rows;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ScrollState;
|
||||
|
||||
#[test]
|
||||
fn wrap_navigation_and_visibility() {
|
||||
let mut s = ScrollState::new();
|
||||
let len = 10;
|
||||
let vis = 5;
|
||||
|
||||
s.clamp_selection(len);
|
||||
assert_eq!(s.selected_idx, Some(0));
|
||||
s.ensure_visible(len, vis);
|
||||
assert_eq!(s.scroll_top, 0);
|
||||
|
||||
s.move_up_wrap(len);
|
||||
s.ensure_visible(len, vis);
|
||||
assert_eq!(s.selected_idx, Some(len - 1));
|
||||
match s.selected_idx {
|
||||
Some(sel) => assert!(s.scroll_top <= sel),
|
||||
None => panic!("expected Some(selected_idx) after wrap"),
|
||||
}
|
||||
|
||||
s.move_down_wrap(len);
|
||||
s.ensure_visible(len, vis);
|
||||
assert_eq!(s.selected_idx, Some(0));
|
||||
assert_eq!(s.scroll_top, 0);
|
||||
}
|
||||
}
|
||||
265
llmx-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal file
265
llmx-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
// Note: Table-based layout previously used Constraint; the manual renderer
|
||||
// below no longer requires it.
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::key_hint::KeyBinding;
|
||||
|
||||
use super::scroll_state::ScrollState;
|
||||
|
||||
/// A generic representation of a display row for selection popups.
|
||||
pub(crate) struct GenericDisplayRow {
|
||||
pub name: String,
|
||||
pub display_shortcut: Option<KeyBinding>,
|
||||
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
||||
pub is_current: bool,
|
||||
pub description: Option<String>, // optional grey text after the name
|
||||
}
|
||||
|
||||
/// Compute a shared description-column start based on the widest visible name
|
||||
/// plus two spaces of padding. Ensures at least one column is left for the
|
||||
/// description.
|
||||
fn compute_desc_col(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
start_idx: usize,
|
||||
visible_items: usize,
|
||||
content_width: u16,
|
||||
) -> usize {
|
||||
let visible_range = start_idx..(start_idx + visible_items);
|
||||
let max_name_width = rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| visible_range.contains(i))
|
||||
.map(|(_, r)| Line::from(r.name.clone()).width())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut desc_col = max_name_width.saturating_add(2);
|
||||
if (desc_col as u16) >= content_width {
|
||||
desc_col = content_width.saturating_sub(1) as usize;
|
||||
}
|
||||
desc_col
|
||||
}
|
||||
|
||||
/// Build the full display line for a row with the description padded to start
|
||||
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
||||
/// dims the description.
|
||||
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
// Enforce single-line name: allow at most desc_col - 2 cells for name,
|
||||
// reserving two spaces before the description column.
|
||||
let name_limit = desc_col.saturating_sub(2);
|
||||
|
||||
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
||||
let mut used_width = 0usize;
|
||||
let mut truncated = false;
|
||||
|
||||
if let Some(idxs) = row.match_indices.as_ref() {
|
||||
let mut idx_iter = idxs.iter().peekable();
|
||||
for (char_idx, ch) in row.name.chars().enumerate() {
|
||||
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used_width + ch_w > name_limit {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
used_width += ch_w;
|
||||
|
||||
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
||||
idx_iter.next();
|
||||
name_spans.push(ch.to_string().bold());
|
||||
} else {
|
||||
name_spans.push(ch.to_string().into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for ch in row.name.chars() {
|
||||
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used_width + ch_w > name_limit {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
used_width += ch_w;
|
||||
name_spans.push(ch.to_string().into());
|
||||
}
|
||||
}
|
||||
|
||||
if truncated {
|
||||
// If there is at least one cell available, add an ellipsis.
|
||||
// When name_limit is 0, we still show an ellipsis to indicate truncation.
|
||||
name_spans.push("…".into());
|
||||
}
|
||||
|
||||
let this_name_width = Line::from(name_spans.clone()).width();
|
||||
let mut full_spans: Vec<Span> = name_spans;
|
||||
if let Some(display_shortcut) = row.display_shortcut {
|
||||
full_spans.push(" (".into());
|
||||
full_spans.push(display_shortcut.into());
|
||||
full_spans.push(")".into());
|
||||
}
|
||||
if let Some(desc) = row.description.as_ref() {
|
||||
let gap = desc_col.saturating_sub(this_name_width);
|
||||
if gap > 0 {
|
||||
full_spans.push(" ".repeat(gap).into());
|
||||
}
|
||||
full_spans.push(desc.clone().dim());
|
||||
}
|
||||
Line::from(full_spans)
|
||||
}
|
||||
|
||||
/// Render a list of rows using the provided ScrollState, with shared styling
|
||||
/// and behavior for selection popups.
|
||||
pub(crate) fn render_rows(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
rows_all: &[GenericDisplayRow],
|
||||
state: &ScrollState,
|
||||
max_results: usize,
|
||||
empty_message: &str,
|
||||
) {
|
||||
if rows_all.is_empty() {
|
||||
if area.height > 0 {
|
||||
Line::from(empty_message.dim().italic()).render(area, buf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which logical rows (items) are visible given the selection and
|
||||
// the max_results clamp. Scrolling is still item-based for simplicity.
|
||||
let visible_items = max_results
|
||||
.min(rows_all.len())
|
||||
.min(area.height.max(1) as usize);
|
||||
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
|
||||
|
||||
// Render items, wrapping descriptions and aligning wrapped lines under the
|
||||
// shared description column. Stop when we run out of vertical space.
|
||||
let mut cur_y = area.y;
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
{
|
||||
if cur_y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
display_shortcut,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
} = row;
|
||||
|
||||
let mut full_line = build_full_line(
|
||||
&GenericDisplayRow {
|
||||
name: name.clone(),
|
||||
match_indices: match_indices.clone(),
|
||||
display_shortcut: *display_shortcut,
|
||||
is_current: *_is_current,
|
||||
description: description.clone(),
|
||||
},
|
||||
desc_col,
|
||||
);
|
||||
if Some(i) == state.selected_idx {
|
||||
// Match previous behavior: cyan + bold for the selected row.
|
||||
// Reset the style first to avoid inheriting dim from keyboard shortcuts.
|
||||
full_line.spans.iter_mut().for_each(|span| {
|
||||
span.style = Style::default().fg(Color::Cyan).bold();
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap with subsequent indent aligned to the description column.
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let options = RtOptions::new(area.width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
let wrapped = word_wrap_line(&full_line, options);
|
||||
|
||||
// Render the wrapped lines.
|
||||
for line in wrapped {
|
||||
if cur_y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
line.render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: cur_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cur_y = cur_y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the number of terminal rows required to render up to `max_results`
|
||||
/// items from `rows_all` given the current scroll/selection state and the
|
||||
/// available `width`. Accounts for description wrapping and alignment so the
|
||||
/// caller can allocate sufficient vertical space.
|
||||
pub(crate) fn measure_rows_height(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
state: &ScrollState,
|
||||
max_results: usize,
|
||||
width: u16,
|
||||
) -> u16 {
|
||||
if rows_all.is_empty() {
|
||||
return 1; // placeholder "no matches" line
|
||||
}
|
||||
|
||||
let content_width = width.saturating_sub(1).max(1);
|
||||
|
||||
let visible_items = max_results.min(rows_all.len());
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
|
||||
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let mut total: u16 = 0;
|
||||
for row in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
.map(|(_, r)| r)
|
||||
{
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
let opts = RtOptions::new(content_width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16);
|
||||
}
|
||||
total.max(1)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› [Pasted Content 1002 chars][Pasted Content 1004 chars] "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left · ? for shortcuts "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to interrupt "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to quit "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc esc to edit previous message "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc again to edit previous message "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc esc to edit previous message "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› h "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc again to edit previous message "
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask LLMX to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" / for commands shift + enter for newline "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" esc again to edit previous message ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› [Pasted Content 1005 chars] "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /mo "
|
||||
" "
|
||||
" /model choose what model and reasoning effort to use "
|
||||
" /mention mention a file "
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› short "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (bad result)
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (good result)
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
expression: rendered
|
||||
---
|
||||
▌ Tell us more (other)
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
expression: rendered
|
||||
---
|
||||
Do you want to upload logs before reporting issue?
|
||||
|
||||
Logs may include the full conversation history of this Codex process
|
||||
These logs are retained for 90 days and are used solely for troubles
|
||||
|
||||
You can review the exact content of the logs before they’re uploaded
|
||||
<LOG_PATH>
|
||||
|
||||
|
||||
› 1. Yes Share the current Codex session logs with the team for
|
||||
troubleshooting.
|
||||
2. No
|
||||
3. Cancel
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ctrl + c again to quit "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ctrl + c again to interrupt "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" esc esc to edit previous message "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" esc again to edit previous message "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 72% context left · ? for shortcuts "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts "
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" / for commands shift + enter for newline "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" esc again to edit previous message ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
expression: render_lines(&view)
|
||||
---
|
||||
|
||||
Select Approval Mode
|
||||
Switch between LLMX approval presets
|
||||
|
||||
› 1. Read Only (current) LLMX can read files
|
||||
2. Full Access LLMX can edit files
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
expression: render_lines(&view)
|
||||
---
|
||||
|
||||
Select Approval Mode
|
||||
|
||||
› 1. Read Only (current) LLMX can read files
|
||||
2. Full Access LLMX can edit files
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/message_queue.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 5 },
|
||||
content: [
|
||||
" ↳ This is ",
|
||||
" a message ",
|
||||
" with many ",
|
||||
" … ",
|
||||
" alt + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/message_queue.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 2 },
|
||||
content: [
|
||||
" ↳ Hello, world! ",
|
||||
" alt + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/message_queue.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 3 },
|
||||
content: [
|
||||
" ↳ Hello, world! ",
|
||||
" ↳ This is another message ",
|
||||
" alt + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/message_queue.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 4 },
|
||||
content: [
|
||||
" ↳ This is a longer message that should",
|
||||
" be wrapped ",
|
||||
" ↳ This is another message ",
|
||||
" alt + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 5 },
|
||||
content: [
|
||||
" ↳ This is ",
|
||||
" a message ",
|
||||
" with many ",
|
||||
" … ",
|
||||
" ⌥ + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 5 },
|
||||
content: [
|
||||
" ↳ Hello, world! ",
|
||||
" ↳ This is another message ",
|
||||
" ↳ This is a third message ",
|
||||
" ↳ This is a fourth message ",
|
||||
" ⌥ + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 2 },
|
||||
content: [
|
||||
" ↳ Hello, world! ",
|
||||
" ⌥ + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 3 },
|
||||
content: [
|
||||
" ↳ Hello, world! ",
|
||||
" ↳ This is another message ",
|
||||
" ⌥ + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 4 },
|
||||
content: [
|
||||
" ↳ This is a longer message that should",
|
||||
" be wrapped ",
|
||||
" ↳ This is another message ",
|
||||
" ⌥ + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask LLMX to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interru
|
||||
|
||||
|
||||
› Ask LLMX to do anything
|
||||
|
||||
100% context left · ? for sh
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask LLMX to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area1)"
|
||||
---
|
||||
› Ask Codex to do a
|
||||
1975
llmx-rs/tui/src/bottom_pane/textarea.rs
Normal file
1975
llmx-rs/tui/src/bottom_pane/textarea.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user