Phase 1: Repository & Infrastructure Setup
- Renamed directories: codex-rs -> llmx-rs, codex-cli -> llmx-cli
- Updated package.json files:
- Root: llmx-monorepo
- CLI: @llmx/llmx
- SDK: @llmx/llmx-sdk
- Updated pnpm workspace configuration
- Renamed binary: codex.js -> llmx.js
- Updated environment variables: CODEX_* -> LLMX_*
- Changed repository URLs to valknar/llmx
🤖 Generated with Claude Code
This commit is contained in:
71
llmx-rs/tui/src/additional_dirs.rs
Normal file
71
llmx-rs/tui/src/additional_dirs.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Returns a warning describing why `--add-dir` entries will be ignored for the
|
||||
/// resolved sandbox policy. The caller is responsible for presenting the
|
||||
/// warning to the user (for example, printing to stderr).
|
||||
pub fn add_dir_warning_message(
|
||||
additional_dirs: &[PathBuf],
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> Option<String> {
|
||||
if additional_dirs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None,
|
||||
SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_warning(additional_dirs: &[PathBuf]) -> String {
|
||||
let joined_paths = additional_dirs
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::add_dir_warning_message;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_workspace_write() {
|
||||
let sandbox = SandboxPolicy::new_workspace_write_policy();
|
||||
let dirs = vec![PathBuf::from("/tmp/example")];
|
||||
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_danger_full_access() {
|
||||
let sandbox = SandboxPolicy::DangerFullAccess;
|
||||
let dirs = vec![PathBuf::from("/tmp/example")];
|
||||
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_for_read_only() {
|
||||
let sandbox = SandboxPolicy::ReadOnly;
|
||||
let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")];
|
||||
let message = add_dir_warning_message(&dirs, &sandbox)
|
||||
.expect("expected warning for read-only sandbox");
|
||||
assert_eq!(
|
||||
message,
|
||||
"Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_additional_dirs() {
|
||||
let sandbox = SandboxPolicy::ReadOnly;
|
||||
let dirs: Vec<PathBuf> = Vec::new();
|
||||
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
|
||||
}
|
||||
}
|
||||
840
llmx-rs/tui/src/app.rs
Normal file
840
llmx-rs/tui/src/app.rs
Normal file
@@ -0,0 +1,840 @@
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::ConversationId;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppExitInfo {
|
||||
pub token_usage: TokenUsage,
|
||||
pub conversation_id: Option<ConversationId>,
|
||||
pub update_action: Option<UpdateAction>,
|
||||
}
|
||||
|
||||
pub(crate) struct App {
|
||||
pub(crate) server: Arc<ConversationManager>,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) chat_widget: ChatWidget,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) active_profile: Option<String>,
|
||||
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
||||
has_emitted_history_lines: bool,
|
||||
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
|
||||
/// Controls the animation thread that sends CommitTick events.
|
||||
pub(crate) commit_anim_running: Arc<AtomicBool>,
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||||
/// Set when the user confirms an update; propagated on exit.
|
||||
pub(crate) pending_update_action: Option<UpdateAction>,
|
||||
|
||||
// One-shot suppression of the next world-writable scan after user confirmation.
|
||||
skip_world_writable_scan_once: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run(
|
||||
tui: &mut tui::Tui,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
config: Config,
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
resume_selection: ResumeSelection,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
) -> Result<AppExitInfo> {
|
||||
use tokio_stream::StreamExt;
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
|
||||
let conversation_manager = Arc::new(ConversationManager::new(
|
||||
auth_manager.clone(),
|
||||
SessionSource::Cli,
|
||||
));
|
||||
|
||||
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
||||
|
||||
let chat_widget = match resume_selection {
|
||||
ResumeSelection::StartFresh | ResumeSelection::Exit => {
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
feedback: feedback.clone(),
|
||||
};
|
||||
ChatWidget::new(init, conversation_manager.clone())
|
||||
}
|
||||
ResumeSelection::Resume(path) => {
|
||||
let resumed = conversation_manager
|
||||
.resume_conversation_from_rollout(
|
||||
config.clone(),
|
||||
path.clone(),
|
||||
auth_manager.clone(),
|
||||
)
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!("Failed to resume session from {}", path.display())
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
feedback: feedback.clone(),
|
||||
};
|
||||
ChatWidget::new_from_existing(
|
||||
init,
|
||||
resumed.conversation,
|
||||
resumed.session_configured,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
#[cfg(not(debug_assertions))]
|
||||
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
||||
|
||||
let mut app = Self {
|
||||
server: conversation_manager,
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
auth_manager: auth_manager.clone(),
|
||||
config,
|
||||
active_profile,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
feedback: feedback.clone(),
|
||||
pending_update_action: None,
|
||||
skip_world_writable_scan_once: false,
|
||||
};
|
||||
|
||||
// On startup, if Auto mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let should_check = codex_core::get_platform_sandbox().is_some()
|
||||
&& matches!(
|
||||
app.config.sandbox_policy,
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly
|
||||
)
|
||||
&& !app
|
||||
.config
|
||||
.notices
|
||||
.hide_world_writable_warning
|
||||
.unwrap_or(false);
|
||||
if should_check {
|
||||
let cwd = app.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
||||
let tx = app.app_event_tx.clone();
|
||||
let logs_base_dir = app.config.codex_home.clone();
|
||||
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
if let Some(latest_version) = upgrade_version {
|
||||
app.handle_event(
|
||||
tui,
|
||||
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
|
||||
latest_version,
|
||||
crate::update_action::get_update_action(),
|
||||
))),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
|
||||
while select! {
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
app.handle_event(tui, event).await?
|
||||
}
|
||||
Some(event) = tui_events.next() => {
|
||||
app.handle_tui_event(tui, event).await?
|
||||
}
|
||||
} {}
|
||||
tui.terminal.clear()?;
|
||||
Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
conversation_id: app.chat_widget.conversation_id(),
|
||||
update_action: app.pending_update_action,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_tui_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if self.overlay.is_some() {
|
||||
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
|
||||
} else {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
self.handle_key_event(tui, key_event).await;
|
||||
}
|
||||
TuiEvent::Paste(pasted) => {
|
||||
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
|
||||
// but tui-textarea expects \n. Normalize CR to LF.
|
||||
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
if self
|
||||
.chat_widget
|
||||
.handle_paste_burst_tick(tui.frame_requester())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
tui.draw(
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||
|frame| {
|
||||
self.chat_widget.render(frame.area(), frame.buffer);
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: self.config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
feedback: self.feedback.clone(),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let cell: Arc<dyn HistoryCell> = cell.into();
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.insert_cell(cell.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
if !display.is_empty() {
|
||||
// Only insert a separating blank line for new cells that are not
|
||||
// part of an ongoing stream. Streaming continuations should not
|
||||
// accrue extra blank lines between chunks.
|
||||
if !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StartCommitAnimation => {
|
||||
if self
|
||||
.commit_anim_running
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let tx = self.app_event_tx.clone();
|
||||
let running = self.commit_anim_running.clone();
|
||||
thread::spawn(move || {
|
||||
while running.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
tx.send(AppEvent::CommitTick);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEvent::StopCommitAnimation => {
|
||||
self.commit_anim_running.store(false, Ordering::Release);
|
||||
}
|
||||
AppEvent::CommitTick => {
|
||||
self.chat_widget.on_commit_tick();
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ConversationHistory(ev) => {
|
||||
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(false);
|
||||
}
|
||||
AppEvent::CodexOp(op) => self.chat_widget.submit_op(op),
|
||||
AppEvent::DiffResult(text) => {
|
||||
// Clear the in-progress state in the bottom pane
|
||||
self.chat_widget.on_diff_complete();
|
||||
// Enter alternate screen using TUI helper and build pager lines
|
||||
let _ = tui.enter_alt_screen();
|
||||
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
|
||||
vec!["No changes detected.".italic().into()]
|
||||
} else {
|
||||
text.lines().map(ansi_escape_line).collect()
|
||||
};
|
||||
self.overlay = Some(Overlay::new_static_with_lines(
|
||||
pager_lines,
|
||||
"D I F F".to_string(),
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
self.chat_widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.on_update_reasoning_effort(effort);
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(&model);
|
||||
self.config.model = model.clone();
|
||||
if let Some(family) = find_family_for_model(&model) {
|
||||
self.config.model_family = family;
|
||||
}
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
self.chat_widget.open_reasoning_popup(model);
|
||||
}
|
||||
AppEvent::OpenFullAccessConfirmation { preset } => {
|
||||
self.chat_widget.open_full_access_confirmation(preset);
|
||||
}
|
||||
AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
} => {
|
||||
self.chat_widget.open_world_writable_warning_confirmation(
|
||||
preset,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
);
|
||||
}
|
||||
AppEvent::OpenFeedbackNote {
|
||||
category,
|
||||
include_logs,
|
||||
} => {
|
||||
self.chat_widget.open_feedback_note(category, include_logs);
|
||||
}
|
||||
AppEvent::OpenFeedbackConsent { category } => {
|
||||
self.chat_widget.open_feedback_consent(category);
|
||||
}
|
||||
AppEvent::ShowWindowsAutoModeInstructions => {
|
||||
self.chat_widget.open_windows_auto_mode_instructions();
|
||||
}
|
||||
AppEvent::PersistModelSelection { model, effort } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_profile(profile)
|
||||
.set_model(Some(model.as_str()), effort)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let effort_label = effort
|
||||
.map(|eff| format!(" with {eff} reasoning"))
|
||||
.unwrap_or_else(|| " with default reasoning".to_string());
|
||||
if let Some(profile) = profile {
|
||||
self.chat_widget.add_info_message(
|
||||
format!(
|
||||
"Model changed to {model}{effort_label} for {profile} profile"
|
||||
),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Model changed to {model}{effort_label}"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist model selection"
|
||||
);
|
||||
if let Some(profile) = profile {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save model for profile `{profile}`: {err}"
|
||||
));
|
||||
} else {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save default model: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.chat_widget.set_approval_policy(policy);
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let policy_is_workspace_write_or_ro = matches!(
|
||||
policy,
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly
|
||||
);
|
||||
|
||||
self.chat_widget.set_sandbox_policy(policy);
|
||||
|
||||
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// One-shot suppression if the user just confirmed continue.
|
||||
if self.skip_world_writable_scan_once {
|
||||
self.skip_world_writable_scan_once = false;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let should_check = codex_core::get_platform_sandbox().is_some()
|
||||
&& policy_is_workspace_write_or_ro
|
||||
&& !self.chat_widget.world_writable_warning_hidden();
|
||||
if should_check {
|
||||
let cwd = self.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> =
|
||||
std::env::vars().collect();
|
||||
let tx = self.app_event_tx.clone();
|
||||
let logs_base_dir = self.config.codex_home.clone();
|
||||
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SkipNextWorldWritableScan => {
|
||||
self.skip_world_writable_scan_once = true;
|
||||
}
|
||||
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
|
||||
self.chat_widget.set_full_access_warning_acknowledged(ack);
|
||||
}
|
||||
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
|
||||
self.chat_widget
|
||||
.set_world_writable_warning_acknowledged(ack);
|
||||
}
|
||||
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
|
||||
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
|
||||
}
|
||||
AppEvent::PersistFullAccessWarningAcknowledged => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_hide_full_access_warning(true)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist full access warning acknowledgement"
|
||||
);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save full access confirmation preference: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
AppEvent::PersistWorldWritableWarningAcknowledged => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_hide_world_writable_warning(true)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist world-writable warning acknowledgement"
|
||||
);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save Auto mode warning preference: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
AppEvent::PersistRateLimitSwitchPromptHidden => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_hide_rate_limit_model_nudge(true)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist rate limit switch prompt preference"
|
||||
);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save rate limit reminder preference: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
AppEvent::OpenApprovalsPopup => {
|
||||
self.chat_widget.open_approvals_popup();
|
||||
}
|
||||
AppEvent::OpenReviewBranchPicker(cwd) => {
|
||||
self.chat_widget.show_review_branch_picker(&cwd).await;
|
||||
}
|
||||
AppEvent::OpenReviewCommitPicker(cwd) => {
|
||||
self.chat_widget.show_review_commit_picker(&cwd).await;
|
||||
}
|
||||
AppEvent::OpenReviewCustomPrompt => {
|
||||
self.chat_widget.show_review_custom_prompt();
|
||||
}
|
||||
AppEvent::FullScreenApprovalRequest(request) => match request {
|
||||
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
||||
let _ = tui.enter_alt_screen();
|
||||
let diff_summary = DiffSummary::new(changes, cwd);
|
||||
self.overlay = Some(Overlay::new_static_with_renderables(
|
||||
vec![diff_summary.into()],
|
||||
"P A T C H".to_string(),
|
||||
));
|
||||
}
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
let _ = tui.enter_alt_screen();
|
||||
let full_cmd = strip_bash_lc_and_escape(&command);
|
||||
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
||||
self.overlay = Some(Overlay::new_static_with_lines(
|
||||
full_cmd_lines,
|
||||
"E X E C".to_string(),
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||||
self.chat_widget.token_usage()
|
||||
}
|
||||
|
||||
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
self.config.model_reasoning_effort = effort;
|
||||
}
|
||||
|
||||
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
// Esc primes/advances backtracking only in normal (not working) mode
|
||||
// with the composer focused and empty. In any other state, forward
|
||||
// Esc so the active UI (e.g. status indicator, modals, popups)
|
||||
// handles it.
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
if self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
{
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
} else {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
}
|
||||
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.backtrack.primed
|
||||
&& self.backtrack.nth_user_message != usize::MAX
|
||||
&& self.chat_widget.composer_is_empty() =>
|
||||
{
|
||||
// Delegate to helper for clarity; preserves behavior.
|
||||
self.confirm_backtrack_from_main();
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
// Any non-Esc key press should cancel a primed backtrack.
|
||||
// This avoids stale "Esc-primed" state after the user starts typing
|
||||
// (even if they later backspace to empty).
|
||||
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
// Ignore Release key events.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn spawn_world_writable_scan(
|
||||
cwd: PathBuf,
|
||||
env_map: std::collections::HashMap<String, String>,
|
||||
logs_base_dir: PathBuf,
|
||||
tx: AppEventSender,
|
||||
) {
|
||||
#[inline]
|
||||
fn normalize_windows_path_for_display(p: &std::path::Path) -> String {
|
||||
let canon = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
canon.display().to_string().replace('/', "\\")
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_windows_sandbox::preflight_audit_everyone_writable(
|
||||
&cwd,
|
||||
&env_map,
|
||||
Some(logs_base_dir.as_path()),
|
||||
);
|
||||
if let Ok(ref paths) = result
|
||||
&& !paths.is_empty()
|
||||
{
|
||||
let as_strings: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|p| normalize_windows_path_for_display(p))
|
||||
.collect();
|
||||
let sample_paths: Vec<String> = as_strings.iter().take(3).cloned().collect();
|
||||
let extra_count = if as_strings.len() > sample_paths.len() {
|
||||
as_strings.len() - sample_paths.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: None,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan: false,
|
||||
});
|
||||
} else if result.is_err() {
|
||||
// Scan failed: still warn, but with no examples and mark as failed.
|
||||
let sample_paths: Vec<String> = Vec::new();
|
||||
let extra_count = 0usize;
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: None,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_backtrack::user_count;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::ConversationId;
|
||||
use ratatui::prelude::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn make_test_app() -> App {
|
||||
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender();
|
||||
let config = chat_widget.config_ref().clone();
|
||||
|
||||
let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key(
|
||||
"Test API Key",
|
||||
)));
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
|
||||
App {
|
||||
server,
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
auth_manager,
|
||||
config,
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
pending_update_action: None,
|
||||
skip_world_writable_scan_once: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_reasoning_effort_updates_config() {
|
||||
let mut app = make_test_app();
|
||||
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium);
|
||||
app.chat_widget
|
||||
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
||||
|
||||
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
|
||||
assert_eq!(
|
||||
app.config.model_reasoning_effort,
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.config_ref().model_reasoning_effort,
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
let mut app = make_test_app();
|
||||
|
||||
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
message: text.to_string(),
|
||||
}) as Arc<dyn HistoryCell>
|
||||
};
|
||||
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from(text.to_string())],
|
||||
true,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
let make_header = |is_first| {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: ConversationId::new(),
|
||||
model: "gpt-test".to_string(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
app.chat_widget.config_ref(),
|
||||
event,
|
||||
is_first,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
// Simulate the transcript after trimming for a fork, replaying history, and
|
||||
// appending the edited turn. The session header separates the retained history
|
||||
// from the forked conversation's replayed turns.
|
||||
app.transcript_cells = vec![
|
||||
make_header(true),
|
||||
user_cell("first question"),
|
||||
agent_cell("answer first"),
|
||||
user_cell("follow-up"),
|
||||
agent_cell("answer follow-up"),
|
||||
make_header(false),
|
||||
user_cell("first question"),
|
||||
agent_cell("answer first"),
|
||||
user_cell("follow-up (edited)"),
|
||||
agent_cell("answer edited"),
|
||||
];
|
||||
|
||||
assert_eq!(user_count(&app.transcript_cells), 2);
|
||||
|
||||
app.backtrack.base_id = Some(ConversationId::new());
|
||||
app.backtrack.primed = true;
|
||||
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
||||
|
||||
app.confirm_backtrack_from_main();
|
||||
|
||||
let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack");
|
||||
assert_eq!(nth, 1);
|
||||
assert_eq!(prefill, "follow-up (edited)");
|
||||
}
|
||||
}
|
||||
512
llmx-rs/tui/src/app_backtrack.rs
Normal file
512
llmx-rs/tui/src/app_backtrack.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
use std::any::TypeId;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_protocol::ConversationId;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
|
||||
/// Aggregates all backtrack-related state used by the App.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BacktrackState {
|
||||
/// True when Esc has primed backtrack mode in the main view.
|
||||
pub(crate) primed: bool,
|
||||
/// Session id of the base conversation to fork from.
|
||||
pub(crate) base_id: Option<ConversationId>,
|
||||
/// Index in the transcript of the last user message.
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Pending fork request: (base_id, nth_user_message, prefill).
|
||||
pub(crate) pending: Option<(ConversationId, usize, String)>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Route overlay events when transcript overlay is active.
|
||||
/// - If backtrack preview is active: Esc steps selection; Enter confirms.
|
||||
/// - Otherwise: Esc begins preview; all other events forward to overlay.
|
||||
/// interactions (Esc to step target, Enter to confirm) and overlay lifecycle.
|
||||
pub(crate) async fn handle_backtrack_overlay_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
match event {
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_confirm_backtrack(tui);
|
||||
Ok(true)
|
||||
}
|
||||
// Catchall: forward any other events to the overlay widget.
|
||||
_ => {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
} else if let TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
// First Esc in transcript overlay: begin backtrack preview at latest user message.
|
||||
self.begin_overlay_backtrack_preview(tui);
|
||||
Ok(true)
|
||||
} else {
|
||||
// Not in backtrack mode: forward events to the overlay widget.
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle global Esc presses for backtracking when no overlay is present.
|
||||
pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {
|
||||
if !self.chat_widget.composer_is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.backtrack.primed {
|
||||
self.prime_backtrack();
|
||||
} else if self.overlay.is_none() {
|
||||
self.open_backtrack_preview(tui);
|
||||
} else if self.backtrack.overlay_preview_active {
|
||||
self.step_backtrack_and_highlight(tui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a backtrack and request conversation history from the agent.
|
||||
pub(crate) fn request_backtrack(
|
||||
&mut self,
|
||||
prefill: String,
|
||||
base_id: ConversationId,
|
||||
nth_user_message: usize,
|
||||
) {
|
||||
self.backtrack.pending = Some((base_id, nth_user_message, prefill));
|
||||
if let Some(path) = self.chat_widget.rollout_path() {
|
||||
let ev = ConversationPathResponseEvent {
|
||||
conversation_id: base_id,
|
||||
path,
|
||||
};
|
||||
self.app_event_tx
|
||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
||||
} else {
|
||||
tracing::error!("rollout path unavailable; cannot backtrack");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open transcript overlay (enters alternate screen and shows full transcript).
|
||||
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Close transcript overlay and restore normal UI.
|
||||
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.leave_alt_screen();
|
||||
let was_backtrack = self.backtrack.overlay_preview_active;
|
||||
if !self.deferred_history_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.deferred_history_lines);
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
self.overlay = None;
|
||||
self.backtrack.overlay_preview_active = false;
|
||||
if was_backtrack {
|
||||
// Ensure backtrack state is fully reset when overlay closes (e.g. via 'q').
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-render the full transcript into the terminal scrollback in one call.
|
||||
/// Useful when switching sessions to ensure prior history remains visible.
|
||||
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
|
||||
if !self.transcript_cells.is_empty() {
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
for cell in &self.transcript_cells {
|
||||
tui.insert_history_lines(cell.display_lines(width));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize backtrack state and show composer hint.
|
||||
fn prime_backtrack(&mut self) {
|
||||
self.backtrack.primed = true;
|
||||
self.backtrack.nth_user_message = usize::MAX;
|
||||
self.backtrack.base_id = self.chat_widget.conversation_id();
|
||||
self.chat_widget.show_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
/// Open overlay and begin backtrack preview flow (first step + highlight).
|
||||
fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) {
|
||||
self.open_transcript_overlay(tui);
|
||||
self.backtrack.overlay_preview_active = true;
|
||||
// Composer is hidden by overlay; clear its hint.
|
||||
self.chat_widget.clear_esc_backtrack_hint();
|
||||
self.step_backtrack_and_highlight(tui);
|
||||
}
|
||||
|
||||
/// When overlay is already open, begin preview mode and select latest user message.
|
||||
fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {
|
||||
self.backtrack.primed = true;
|
||||
self.backtrack.base_id = self.chat_widget.conversation_id();
|
||||
self.backtrack.overlay_preview_active = true;
|
||||
let count = user_count(&self.transcript_cells);
|
||||
if let Some(last) = count.checked_sub(1) {
|
||||
self.apply_backtrack_selection(last);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Step selection to the next older user message and update overlay.
|
||||
fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
|
||||
let count = user_count(&self.transcript_cells);
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_index = count.saturating_sub(1);
|
||||
let next_selection = if self.backtrack.nth_user_message == usize::MAX {
|
||||
last_index
|
||||
} else if self.backtrack.nth_user_message == 0 {
|
||||
0
|
||||
} else {
|
||||
self.backtrack
|
||||
.nth_user_message
|
||||
.saturating_sub(1)
|
||||
.min(last_index)
|
||||
};
|
||||
|
||||
self.apply_backtrack_selection(next_selection);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Apply a computed backtrack selection to the overlay and internal counter.
|
||||
fn apply_backtrack_selection(&mut self, nth_user_message: usize) {
|
||||
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
|
||||
self.backtrack.nth_user_message = nth_user_message;
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.set_highlight_cell(Some(cell_idx));
|
||||
}
|
||||
} else {
|
||||
self.backtrack.nth_user_message = usize::MAX;
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.set_highlight_cell(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward any event to the overlay and close it if done.
|
||||
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
if let Some(overlay) = &mut self.overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
if overlay.is_done() {
|
||||
self.close_transcript_overlay(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Enter in overlay backtrack preview: confirm selection and reset state.
|
||||
fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {
|
||||
let nth_user_message = self.backtrack.nth_user_message;
|
||||
if let Some(base_id) = self.backtrack.base_id {
|
||||
let prefill = nth_user_position(&self.transcript_cells, nth_user_message)
|
||||
.and_then(|idx| self.transcript_cells.get(idx))
|
||||
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
|
||||
.map(|c| c.message.clone())
|
||||
.unwrap_or_default();
|
||||
self.close_transcript_overlay(tui);
|
||||
self.request_backtrack(prefill, base_id, nth_user_message);
|
||||
}
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
|
||||
/// Handle Esc in overlay backtrack preview: step selection if armed, else forward.
|
||||
fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
if self.backtrack.base_id.is_some() {
|
||||
self.step_backtrack_and_highlight(tui);
|
||||
} else {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirm a primed backtrack from the main view (no overlay visible).
|
||||
/// Computes the prefill from the selected user message and requests history.
|
||||
pub(crate) fn confirm_backtrack_from_main(&mut self) {
|
||||
if let Some(base_id) = self.backtrack.base_id {
|
||||
let prefill =
|
||||
nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message)
|
||||
.and_then(|idx| self.transcript_cells.get(idx))
|
||||
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
|
||||
.map(|c| c.message.clone())
|
||||
.unwrap_or_default();
|
||||
self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message);
|
||||
}
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
|
||||
/// Clear all backtrack-related state and composer hints.
|
||||
pub(crate) fn reset_backtrack_state(&mut self) {
|
||||
self.backtrack.primed = false;
|
||||
self.backtrack.base_id = None;
|
||||
self.backtrack.nth_user_message = usize::MAX;
|
||||
// In case a hint is somehow still visible (e.g., race with overlay open/close).
|
||||
self.chat_widget.clear_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
/// Handle a ConversationHistory response while a backtrack is pending.
|
||||
/// If it matches the primed base session, fork and switch to the new conversation.
|
||||
pub(crate) async fn on_conversation_history_for_backtrack(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
ev: ConversationPathResponseEvent,
|
||||
) -> Result<()> {
|
||||
if let Some((base_id, _, _)) = self.backtrack.pending.as_ref()
|
||||
&& ev.conversation_id == *base_id
|
||||
&& let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take()
|
||||
{
|
||||
self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fork the conversation using provided history and switch UI/state accordingly.
|
||||
async fn fork_and_switch_to_new_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
ev: ConversationPathResponseEvent,
|
||||
nth_user_message: usize,
|
||||
prefill: String,
|
||||
) {
|
||||
let cfg = self.chat_widget.config_ref().clone();
|
||||
// Perform the fork via a thin wrapper for clarity/testability.
|
||||
let result = self
|
||||
.perform_fork(ev.path.clone(), nth_user_message, cfg.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(new_conv) => {
|
||||
self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill)
|
||||
}
|
||||
Err(e) => tracing::error!("error forking conversation: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around ConversationManager::fork_conversation.
|
||||
async fn perform_fork(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
nth_user_message: usize,
|
||||
cfg: codex_core::config::Config,
|
||||
) -> codex_core::error::Result<codex_core::NewConversation> {
|
||||
self.server
|
||||
.fork_conversation(nth_user_message, cfg, path)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Install a forked conversation into the ChatWidget and update UI to reflect selection.
|
||||
fn install_forked_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
cfg: codex_core::config::Config,
|
||||
new_conv: codex_core::NewConversation,
|
||||
nth_user_message: usize,
|
||||
prefill: &str,
|
||||
) {
|
||||
let conv = new_conv.conversation;
|
||||
let session_configured = new_conv.session_configured;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
feedback: self.feedback.clone(),
|
||||
};
|
||||
self.chat_widget =
|
||||
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
|
||||
// Trim transcript up to the selected user message and re-render it.
|
||||
self.trim_transcript_for_backtrack(nth_user_message);
|
||||
self.render_transcript_once(tui);
|
||||
if !prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(prefill.to_string());
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Trim transcript_cells to preserve only content up to the selected user message.
|
||||
fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) {
|
||||
trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message);
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_transcript_cells_to_nth_user(
|
||||
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
|
||||
nth_user_message: usize,
|
||||
) {
|
||||
if nth_user_message == usize::MAX {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
|
||||
transcript_cells.truncate(cut_idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn user_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {
|
||||
user_positions_iter(cells).count()
|
||||
}
|
||||
|
||||
fn nth_user_position(
|
||||
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
|
||||
nth: usize,
|
||||
) -> Option<usize> {
|
||||
user_positions_iter(cells)
|
||||
.enumerate()
|
||||
.find_map(|(i, idx)| (i == nth).then_some(idx))
|
||||
}
|
||||
|
||||
fn user_positions_iter(
|
||||
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
|
||||
) -> impl Iterator<Item = usize> + '_ {
|
||||
let session_start_type = TypeId::of::<SessionInfoCell>();
|
||||
let user_type = TypeId::of::<UserHistoryCell>();
|
||||
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();
|
||||
|
||||
let start = cells
|
||||
.iter()
|
||||
.rposition(|cell| type_of(cell) == session_start_type)
|
||||
.map_or(0, |idx| idx + 1);
|
||||
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start)
|
||||
.filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use ratatui::prelude::Line;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn trim_transcript_for_first_user_drops_user_and_newer_cells() {
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first user".to_string(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
|
||||
as Arc<dyn HistoryCell>,
|
||||
];
|
||||
trim_transcript_cells_to_nth_user(&mut cells, 0);
|
||||
|
||||
assert!(cells.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_transcript_preserves_cells_before_selected_user() {
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first".to_string(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
];
|
||||
trim_transcript_cells_to_nth_user(&mut cells, 0);
|
||||
|
||||
assert_eq!(cells.len(), 1);
|
||||
let agent = cells[0]
|
||||
.as_any()
|
||||
.downcast_ref::<AgentMessageCell>()
|
||||
.expect("agent cell");
|
||||
let agent_lines = agent.display_lines(u16::MAX);
|
||||
assert_eq!(agent_lines.len(), 1);
|
||||
let intro_text: String = agent_lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
assert_eq!(intro_text, "• intro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_transcript_for_later_user_keeps_prior_history() {
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first".to_string(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "second".to_string(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
];
|
||||
trim_transcript_cells_to_nth_user(&mut cells, 1);
|
||||
|
||||
assert_eq!(cells.len(), 3);
|
||||
let agent_intro = cells[0]
|
||||
.as_any()
|
||||
.downcast_ref::<AgentMessageCell>()
|
||||
.expect("intro agent");
|
||||
let intro_lines = agent_intro.display_lines(u16::MAX);
|
||||
let intro_text: String = intro_lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
assert_eq!(intro_text, "• intro");
|
||||
|
||||
let user_first = cells[1]
|
||||
.as_any()
|
||||
.downcast_ref::<UserHistoryCell>()
|
||||
.expect("first user");
|
||||
assert_eq!(user_first.message, "first");
|
||||
|
||||
let agent_between = cells[2]
|
||||
.as_any()
|
||||
.downcast_ref::<AgentMessageCell>()
|
||||
.expect("between agent");
|
||||
let between_lines = agent_between.display_lines(u16::MAX);
|
||||
let between_text: String = between_lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
assert_eq!(between_text, " between");
|
||||
}
|
||||
}
|
||||
160
llmx-rs/tui/src/app_event.rs
Normal file
160
llmx-rs/tui/src/app_event.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
|
||||
/// Start a new session.
|
||||
NewSession,
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
|
||||
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(codex_core::protocol::Op),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
/// is at most one in-flight search.
|
||||
StartFileSearch(String),
|
||||
|
||||
/// Result of a completed asynchronous file search. The `query` echoes the
|
||||
/// original search term so the UI can decide whether the results are
|
||||
/// still relevant.
|
||||
FileSearchResult {
|
||||
query: String,
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
StartCommitAnimation,
|
||||
StopCommitAnimation,
|
||||
CommitTick,
|
||||
|
||||
/// Update the current reasoning effort in the running app and widget.
|
||||
UpdateReasoningEffort(Option<ReasoningEffort>),
|
||||
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Persist the selected model and reasoning effort to the appropriate config.
|
||||
PersistModelSelection {
|
||||
model: String,
|
||||
effort: Option<ReasoningEffort>,
|
||||
},
|
||||
|
||||
/// Open the reasoning selection popup after picking a model.
|
||||
OpenReasoningPopup {
|
||||
model: ModelPreset,
|
||||
},
|
||||
|
||||
/// Open the confirmation prompt before enabling full access mode.
|
||||
OpenFullAccessConfirmation {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Open the Windows world-writable directories warning.
|
||||
/// If `preset` is `Some`, the confirmation will apply the provided
|
||||
/// approval/sandbox configuration on Continue; if `None`, it performs no
|
||||
/// policy change and only acknowledges/dismisses the warning.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWorldWritableWarningConfirmation {
|
||||
preset: Option<ApprovalPreset>,
|
||||
/// Up to 3 sample world-writable directories to display in the warning.
|
||||
sample_paths: Vec<String>,
|
||||
/// If there are more than `sample_paths`, this carries the remaining count.
|
||||
extra_count: usize,
|
||||
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
|
||||
failed_scan: bool,
|
||||
},
|
||||
|
||||
/// Show Windows Subsystem for Linux setup instructions for auto mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
ShowWindowsAutoModeInstructions,
|
||||
|
||||
/// Update the current approval policy in the running app and widget.
|
||||
UpdateAskForApprovalPolicy(AskForApproval),
|
||||
|
||||
/// Update the current sandbox policy in the running app and widget.
|
||||
UpdateSandboxPolicy(SandboxPolicy),
|
||||
|
||||
/// Update whether the full access warning prompt has been acknowledged.
|
||||
UpdateFullAccessWarningAcknowledged(bool),
|
||||
|
||||
/// Update whether the world-writable directories warning has been acknowledged.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
UpdateWorldWritableWarningAcknowledged(bool),
|
||||
|
||||
/// Update whether the rate limit switch prompt has been acknowledged for the session.
|
||||
UpdateRateLimitSwitchPromptHidden(bool),
|
||||
|
||||
/// Persist the acknowledgement flag for the full access warning prompt.
|
||||
PersistFullAccessWarningAcknowledged,
|
||||
|
||||
/// Persist the acknowledgement flag for the world-writable directories warning.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
PersistWorldWritableWarningAcknowledged,
|
||||
|
||||
/// Persist the acknowledgement flag for the rate limit switch prompt.
|
||||
PersistRateLimitSwitchPromptHidden,
|
||||
|
||||
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
SkipNextWorldWritableScan,
|
||||
|
||||
/// Re-open the approval presets popup.
|
||||
OpenApprovalsPopup,
|
||||
|
||||
/// Forwarded conversation history snapshot from the current conversation.
|
||||
ConversationHistory(ConversationPathResponseEvent),
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
/// Open the commit picker option from the review popup.
|
||||
OpenReviewCommitPicker(PathBuf),
|
||||
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Open the approval popup.
|
||||
FullScreenApprovalRequest(ApprovalRequest),
|
||||
|
||||
/// Open the feedback note entry overlay after the user selects a category.
|
||||
OpenFeedbackNote {
|
||||
category: FeedbackCategory,
|
||||
include_logs: bool,
|
||||
},
|
||||
|
||||
/// Open the upload consent popup for feedback after selecting a category.
|
||||
OpenFeedbackConsent {
|
||||
category: FeedbackCategory,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FeedbackCategory {
|
||||
BadResult,
|
||||
GoodResult,
|
||||
Bug,
|
||||
Other,
|
||||
}
|
||||
28
llmx-rs/tui/src/app_event_sender.rs
Normal file
28
llmx-rs/tui/src/app_event_sender.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::session_log;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppEventSender {
|
||||
pub app_event_tx: UnboundedSender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppEventSender {
|
||||
pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
|
||||
Self { app_event_tx }
|
||||
}
|
||||
|
||||
/// Send an event to the app event channel. If it fails, we swallow the
|
||||
/// error and log it.
|
||||
pub(crate) fn send(&self, event: AppEvent) {
|
||||
// Record inbound events for high-fidelity session replay.
|
||||
// Avoid double-logging Ops; those are logged at the point of submission.
|
||||
if !matches!(event, AppEvent::CodexOp(_)) {
|
||||
session_log::log_inbound_app_event(&event);
|
||||
}
|
||||
if let Err(e) = self.app_event_tx.send(event) {
|
||||
tracing::error!("failed to send event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
111
llmx-rs/tui/src/ascii_animation.rs
Normal file
111
llmx-rs/tui/src/ascii_animation.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use std::convert::TryFrom;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use rand::Rng as _;
|
||||
|
||||
use crate::frames::ALL_VARIANTS;
|
||||
use crate::frames::FRAME_TICK_DEFAULT;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
/// Drives ASCII art animations shared across popups and onboarding widgets.
|
||||
pub(crate) struct AsciiAnimation {
|
||||
request_frame: FrameRequester,
|
||||
variants: &'static [&'static [&'static str]],
|
||||
variant_idx: usize,
|
||||
frame_tick: Duration,
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl AsciiAnimation {
|
||||
pub(crate) fn new(request_frame: FrameRequester) -> Self {
|
||||
Self::with_variants(request_frame, ALL_VARIANTS, 0)
|
||||
}
|
||||
|
||||
pub(crate) fn with_variants(
|
||||
request_frame: FrameRequester,
|
||||
variants: &'static [&'static [&'static str]],
|
||||
variant_idx: usize,
|
||||
) -> Self {
|
||||
assert!(
|
||||
!variants.is_empty(),
|
||||
"AsciiAnimation requires at least one animation variant",
|
||||
);
|
||||
let clamped_idx = variant_idx.min(variants.len() - 1);
|
||||
Self {
|
||||
request_frame,
|
||||
variants,
|
||||
variant_idx: clamped_idx,
|
||||
frame_tick: FRAME_TICK_DEFAULT,
|
||||
start: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn schedule_next_frame(&self) {
|
||||
let tick_ms = self.frame_tick.as_millis();
|
||||
if tick_ms == 0 {
|
||||
self.request_frame.schedule_frame();
|
||||
return;
|
||||
}
|
||||
let elapsed_ms = self.start.elapsed().as_millis();
|
||||
let rem_ms = elapsed_ms % tick_ms;
|
||||
let delay_ms = if rem_ms == 0 {
|
||||
tick_ms
|
||||
} else {
|
||||
tick_ms - rem_ms
|
||||
};
|
||||
if let Ok(delay_ms_u64) = u64::try_from(delay_ms) {
|
||||
self.request_frame
|
||||
.schedule_frame_in(Duration::from_millis(delay_ms_u64));
|
||||
} else {
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_frame(&self) -> &'static str {
|
||||
let frames = self.frames();
|
||||
if frames.is_empty() {
|
||||
return "";
|
||||
}
|
||||
let tick_ms = self.frame_tick.as_millis();
|
||||
if tick_ms == 0 {
|
||||
return frames[0];
|
||||
}
|
||||
let elapsed_ms = self.start.elapsed().as_millis();
|
||||
let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize;
|
||||
frames[idx]
|
||||
}
|
||||
|
||||
pub(crate) fn pick_random_variant(&mut self) -> bool {
|
||||
if self.variants.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
let mut rng = rand::rng();
|
||||
let mut next = self.variant_idx;
|
||||
while next == self.variant_idx {
|
||||
next = rng.random_range(0..self.variants.len());
|
||||
}
|
||||
self.variant_idx = next;
|
||||
self.request_frame.schedule_frame();
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn request_frame(&self) {
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn frames(&self) -> &'static [&'static str] {
|
||||
self.variants[self.variant_idx]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_tick_must_be_nonzero() {
|
||||
assert!(FRAME_TICK_DEFAULT.as_millis() > 0);
|
||||
}
|
||||
}
|
||||
15
llmx-rs/tui/src/bin/md-events.rs
Normal file
15
llmx-rs/tui/src/bin/md-events.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use std::io::Read;
|
||||
use std::io::{self};
|
||||
|
||||
fn main() {
|
||||
let mut input = String::new();
|
||||
if let Err(err) = io::stdin().read_to_string(&mut input) {
|
||||
eprintln!("failed to read stdin: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let parser = pulldown_cmark::Parser::new(&input);
|
||||
for event in parser {
|
||||
println!("{event:?}");
|
||||
}
|
||||
}
|
||||
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 codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::protocol::SandboxCommandAssessment;
|
||||
use codex_core::protocol::SandboxRiskLevel;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
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::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::CodexOp(Op::ExecApproval {
|
||||
id: id.to_string(),
|
||||
decision,
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(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 Codex 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 Codex 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 CodexOp message in the queue.
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if matches!(ev, AppEvent::CodexOp(_)) {
|
||||
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 codex 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::CodexOp(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 codex_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::CodexOp(op));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use codex_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::CodexOp with the correct GetHistoryEntryRequest was sent.
|
||||
let event = rx.try_recv().expect("expected AppEvent to be sent");
|
||||
let AppEvent::CodexOp(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 CodexOp event for offset 1.
|
||||
let event2 = rx.try_recv().expect("expected second event");
|
||||
let AppEvent::CodexOp(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 codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_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/openai/codex/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: codex_feedback::CodexLogSnapshot,
|
||||
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: codex_feedback::CodexLogSnapshot,
|
||||
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(), "codex-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 Codex 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 = codex_feedback::CodexFeedback::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 codex_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("Codex can read files".to_string()),
|
||||
is_current: true,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Full Access".to_string(),
|
||||
description: Some("Codex 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 Codex 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("Codex 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 codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
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 codex_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 Codex 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 Codex 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 Codex 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 Codex") {
|
||||
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 Codex 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 Codex 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 Codex 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 Codex 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 codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use lazy_static::lazy_static;
|
||||
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 Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left · ? for shortcuts "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to interrupt "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to quit "
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex 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 Codex 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 Codex 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 Codex 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 Codex 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 Codex approval presets
|
||||
|
||||
› 1. Read Only (current) Codex can read files
|
||||
2. Full Access Codex 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) Codex can read files
|
||||
2. Full Access Codex 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 Codex 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 Codex 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 Codex 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
2908
llmx-rs/tui/src/chatwidget.rs
Normal file
2908
llmx-rs/tui/src/chatwidget.rs
Normal file
File diff suppressed because it is too large
Load Diff
99
llmx-rs/tui/src/chatwidget/agent.rs
Normal file
99
llmx-rs/tui/src/chatwidget/agent.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Op;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
/// Spawn the agent bootstrapper and op forwarding loop, returning the
|
||||
/// `UnboundedSender<Op>` used by the UI to submit operations.
|
||||
pub(crate) fn spawn_agent(
|
||||
config: Config,
|
||||
app_event_tx: AppEventSender,
|
||||
server: Arc<ConversationManager>,
|
||||
) -> UnboundedSender<Op> {
|
||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||
|
||||
let app_event_tx_clone = app_event_tx;
|
||||
tokio::spawn(async move {
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
session_configured,
|
||||
} = match server.new_conversation(config).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// TODO: surface this error to the user.
|
||||
tracing::error!("failed to initialize codex: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
|
||||
let ev = codex_core::protocol::Event {
|
||||
// The `id` does not matter for rendering, so we can use a fake value.
|
||||
id: "".to_string(),
|
||||
msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured),
|
||||
};
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
|
||||
|
||||
let conversation_clone = conversation.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(op) = codex_op_rx.recv().await {
|
||||
let id = conversation_clone.submit(op).await;
|
||||
if let Err(e) = id {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Ok(event) = conversation.next_event().await {
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(event));
|
||||
}
|
||||
});
|
||||
|
||||
codex_op_tx
|
||||
}
|
||||
|
||||
/// Spawn agent loops for an existing conversation (e.g., a forked conversation).
|
||||
/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent
|
||||
/// events and accepts Ops for submission.
|
||||
pub(crate) fn spawn_agent_from_existing(
|
||||
conversation: std::sync::Arc<CodexConversation>,
|
||||
session_configured: codex_core::protocol::SessionConfiguredEvent,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> UnboundedSender<Op> {
|
||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||
|
||||
let app_event_tx_clone = app_event_tx;
|
||||
tokio::spawn(async move {
|
||||
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
|
||||
let ev = codex_core::protocol::Event {
|
||||
id: "".to_string(),
|
||||
msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured),
|
||||
};
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
|
||||
|
||||
let conversation_clone = conversation.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(op) = codex_op_rx.recv().await {
|
||||
let id = conversation_clone.submit(op).await;
|
||||
if let Err(e) = id {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Ok(event) = conversation.next_event().await {
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(event));
|
||||
}
|
||||
});
|
||||
|
||||
codex_op_tx
|
||||
}
|
||||
89
llmx-rs/tui/src/chatwidget/interrupts.rs
Normal file
89
llmx-rs/tui/src/chatwidget/interrupts.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
|
||||
use super::ChatWidget;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum QueuedInterrupt {
|
||||
ExecApproval(String, ExecApprovalRequestEvent),
|
||||
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
|
||||
ExecBegin(ExecCommandBeginEvent),
|
||||
ExecEnd(ExecCommandEndEvent),
|
||||
McpBegin(McpToolCallBeginEvent),
|
||||
McpEnd(McpToolCallEndEvent),
|
||||
PatchEnd(PatchApplyEndEvent),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct InterruptManager {
|
||||
queue: VecDeque<QueuedInterrupt>,
|
||||
}
|
||||
|
||||
impl InterruptManager {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_apply_patch_approval(
|
||||
&mut self,
|
||||
id: String,
|
||||
ev: ApplyPatchApprovalRequestEvent,
|
||||
) {
|
||||
self.queue
|
||||
.push_back(QueuedInterrupt::ApplyPatchApproval(id, ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::McpBegin(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::McpEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) {
|
||||
while let Some(q) = self.queue.pop_front() {
|
||||
match q {
|
||||
QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev),
|
||||
QueuedInterrupt::ApplyPatchApproval(id, ev) => {
|
||||
chat.handle_apply_patch_approval_now(id, ev)
|
||||
}
|
||||
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
|
||||
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
|
||||
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
|
||||
QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
|
||||
QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
llmx-rs/tui/src/chatwidget/session_header.rs
Normal file
16
llmx-rs/tui/src/chatwidget/session_header.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub(crate) struct SessionHeader {
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl SessionHeader {
|
||||
pub(crate) fn new(model: String) -> Self {
|
||||
Self { model }
|
||||
}
|
||||
|
||||
/// Updates the header's model text.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
if self.model != model {
|
||||
self.model = model.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&approved_lines)
|
||||
---
|
||||
• Added foo.txt (+1 -0)
|
||||
1 +hello
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
Would you like to run the following command?
|
||||
|
||||
Reason: this is a test reason such as one that would be produced by the
|
||||
model
|
||||
|
||||
$ echo hello world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for this command (a)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
Would you like to run the following command?
|
||||
|
||||
$ echo hello world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for this command (a)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
Would you like to make the following edits?
|
||||
|
||||
Reason: The model wants to apply changes
|
||||
|
||||
README.md (+2 -0)
|
||||
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Approval Mode
|
||||
|
||||
› 1. Read Only (current) Codex can read files and answer questions. Codex
|
||||
requires approval to make edits, run commands, or
|
||||
access network.
|
||||
2. Auto Codex can read files, make edits, and run commands
|
||||
in the workspace. Codex requires approval to work
|
||||
outside the workspace or access network.
|
||||
3. Full Access Codex can read files, make edits, and run commands
|
||||
with network access, without approval. Exercise
|
||||
caution.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Approval Mode
|
||||
|
||||
› 1. Read Only (current) Codex can read files and answer questions. Codex
|
||||
requires approval to make edits, run commands, or
|
||||
access network.
|
||||
2. Auto Codex can read files, make edits, and run commands
|
||||
in the workspace. Codex requires approval to work
|
||||
outside the workspace or access network.
|
||||
Requires Windows Subsystem for Linux (WSL). Show
|
||||
installation instructions...
|
||||
3. Full Access Codex can read files, make edits, and run commands
|
||||
with network access, without approval. Exercise
|
||||
caution.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "lines[start_idx..].join(\"\\n\")"
|
||||
---
|
||||
• I need to check the codex-rs repository to explain why the project's binaries
|
||||
are large. The user is likely seeking specifics about the setup: are Rust
|
||||
builds static, what features are enabled, and is debug information included?
|
||||
It could be due to static linking, included OpenSSL, or how panic handling
|
||||
is set up. I should look into the Cargo.toml file to confirm features and
|
||||
profiles without needing to edit any code. Let's get started on this!
|
||||
|
||||
─ Worked for 0s ────────────────────────────────────────────────────────────────
|
||||
|
||||
• I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||
dependencies that impact binary size. Then I’ll summarize the main causes.
|
||||
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read Cargo.toml
|
||||
|
||||
• I'm reviewing the workspace's release profile, which has settings like
|
||||
lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it
|
||||
seems that "strip = symbols" only removes symbols, not debug information. In
|
||||
Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I
|
||||
should check whether using these results in a smaller binary. I’ll also skim
|
||||
through crates for heavy dependencies in Cargo.toml, including cli, core,
|
||||
exec, linux-sandbox, tui, login, ollama, and mcp.
|
||||
|
||||
• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
||||
│ file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
|
||||
│ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
|
||||
│ … +1 lines
|
||||
└ --- ansi-escape/Cargo.toml
|
||||
[package]
|
||||
… +243 lines
|
||||
] }
|
||||
tracing = { version
|
||||
|
||||
• Explored
|
||||
└ Read Cargo.toml
|
||||
|
||||
• I’m evaluating how binary size is influenced by various factors. There are
|
||||
many binaries, such as codex and codex-exec, which include dependencies by
|
||||
default, pulling in features like Tokio and hyper. For instance, reqwest
|
||||
includes default features like TLS through rustls. If default features remain
|
||||
enabled, it inflates the size with many additional components. I notice
|
||||
that for musl targets, using vendored builds of OpenSSL could significantly
|
||||
increase binary size. This is quite the intricate web of dependencies!
|
||||
|
||||
Analyzing musl and binary size
|
||||
|
||||
I’m considering the implications of building for musl with static linking.
|
||||
In this case, OpenSSL might come into play through some crates, but reqwest
|
||||
defaults to using rustls, although openssl-sys vendored could increase
|
||||
binary size. It’s interesting to think about whether they’re targeting musl,
|
||||
especially since it seems they might often be building for Mac and Linux with
|
||||
dynamic linking instead.
|
||||
|
||||
I also realize there’s a difference between debug and release builds — release
|
||||
should be smaller due to optimizations, but if they’re defaulting to debug
|
||||
builds, that could lead to much larger binaries.
|
||||
|
||||
Assessing crate impacts on size
|
||||
|
||||
I need to be precise when analyzing binary sizes from various crates. Some
|
||||
crates, like ratatui-image, include heavy features such as libjpeg decoders,
|
||||
which can really bulk things up. Then there are the tree-sitter crates that
|
||||
contain large static grammar tables, adding significant size. The starlark
|
||||
crate and tracing-subscriber also contribute to size increases.
|
||||
|
||||
With static linking per binary being standard in Rust, it leads to multiple
|
||||
copies of the same dependencies unless cdylibs are used. That makes the
|
||||
compile times longer, but using LTO helps reduce bloat somewhat through
|
||||
deduplication.
|
||||
|
||||
Evaluating binary size factors
|
||||
|
||||
Having multiple binaries indeed increases the overall size significantly.
|
||||
I'm considering options like upgrading to "opt-level = 'z'" or using
|
||||
"strip=debuginfo" for reducing size. It's interesting that while the profile
|
||||
only sets symbols for stripping, not debuginfo, this could inflate sizes if
|
||||
retained in release builds.
|
||||
|
||||
The distinction between "symbols" and "debuginfo" is critical; "symbols"
|
||||
keeps debug information which may not align with the goal of minimizing
|
||||
size. I’d like to verify with the Rust documentation, but my network access
|
||||
is restricted.
|
||||
|
||||
Explaining large binary sizes
|
||||
|
||||
I’m analyzing why binaries in this project are so large. The primary reasons
|
||||
include monolithic static linking, where each binary includes the full
|
||||
dependency graph without shared libraries. Heavy dependencies such as reqwest,
|
||||
hyper, and TLS components also contribute significantly to size, along with
|
||||
image decoders and other libraries.
|
||||
|
||||
Using MUSL targets means vendored OpenSSL can drastically increase the
|
||||
size due to static linking. Furthermore, if the release profile keeps debug
|
||||
information due to using "strip = symbols," that can lead to larger binaries
|
||||
as well. It’s a mix of several factors, including panic strategies and
|
||||
platform-specific linking behavior.
|
||||
|
||||
Identifying binary size contributors
|
||||
|
||||
I’m considering why binaries are still large even without static linking. The
|
||||
tracing-subscriber crate potentially adds weight because it includes regex,
|
||||
though it can sometimes use a lighter version. References from files like
|
||||
"core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses
|
||||
the image crate.
|
||||
|
||||
To present this clearly, I’ll format the final answer with two main sections:
|
||||
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
|
||||
reducing size, but I want to stay focused on answering the user's question.
|
||||
|
||||
─ Worked for 0s ────────────────────────────────────────────────────────────────
|
||||
|
||||
• Here’s what’s driving size in this workspace’s binaries.
|
||||
|
||||
Main Causes
|
||||
|
||||
- Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp-
|
||||
server, etc.) statically links its full dependency graph, so common code isn’t
|
||||
shared at runtime across executables.
|
||||
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
|
||||
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
|
||||
and ollama you enable reqwest with json/stream, which still pulls a large
|
||||
transitive set.
|
||||
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
|
||||
crossterm, and ratatui-image, which together contribute significant code size
|
||||
(decoders, pixel ops, terminal backends).
|
||||
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
|
||||
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
|
||||
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process,
|
||||
signal) across many crates inflate code size even if only subsets are used
|
||||
per bin.
|
||||
- Panic + backtraces: Default panic = unwind and backtrace support keep
|
||||
unwinding tables and symbols that add weight.
|
||||
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-
|
||||
sys with vendored, compiling OpenSSL into the binary—this adds multiple
|
||||
megabytes per executable.
|
||||
|
||||
Build-Mode Notes
|
||||
|
||||
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
|
||||
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
|
||||
contributor; if you build in release with that setting, binaries can still
|
||||
be large.
|
||||
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
|
||||
assertions—outputs are much larger than cargo build --release.
|
||||
|
||||
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-
|
||||
level = "z", panic abort, tighter tokio/reqwest features) and estimate impact
|
||||
per binary.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
• I’m going to search the repo for where “Change Approved” is rendered to update
|
||||
that view.
|
||||
|
||||
• Explored
|
||||
└ Search Change Approved
|
||||
Read diff_render.rs
|
||||
|
||||
• Investigating rendering code (0s • esc to interrupt)
|
||||
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
100% context left
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: visual
|
||||
---
|
||||
• -- Indented code block (4 spaces)
|
||||
SELECT *
|
||||
FROM "users"
|
||||
WHERE "email" LIKE '%@example.com';
|
||||
|
||||
```sh
|
||||
printf 'fenced within fenced\n'
|
||||
```
|
||||
|
||||
{
|
||||
// comment allowed in jsonc
|
||||
"path": "C:\\Program Files\\App",
|
||||
"regex": "^foo.*(bar)?$"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
↳ Hello, world! 0
|
||||
↳ Hello, world! 1
|
||||
↳ Hello, world! 2
|
||||
↳ Hello, world! 3
|
||||
↳ Hello, world! 4
|
||||
↳ Hello, world! 5
|
||||
↳ Hello, world! 6
|
||||
↳ Hello, world! 7
|
||||
↳ Hello, world! 8
|
||||
↳ Hello, world! 9
|
||||
↳ Hello, world! 10
|
||||
↳ Hello, world! 11
|
||||
↳ Hello, world! 12
|
||||
↳ Hello, world! 13
|
||||
↳ Hello, world! 14
|
||||
↳ Hello, world! 15
|
||||
↳ Hello, world! 16
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Here is the result.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob
|
||||
---
|
||||
■ '/model' is disabled while a task is in progress.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 495
|
||||
expression: lines_to_single_string(&aborted_long)
|
||||
---
|
||||
✗ You canceled the request to run echo
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&aborted_multi)
|
||||
---
|
||||
✗ You canceled the request to run echo line1 ...
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&decision)
|
||||
---
|
||||
✔ You approved codex to run echo hello world this time
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 14 },
|
||||
content: [
|
||||
" ",
|
||||
" ",
|
||||
" Would you like to run the following command? ",
|
||||
" ",
|
||||
" Reason: this is a test reason such as one that would be produced by the ",
|
||||
" model ",
|
||||
" ",
|
||||
" $ echo hello world ",
|
||||
" ",
|
||||
"› 1. Yes, proceed (y) ",
|
||||
" 2. Yes, and don't ask again for this command (a) ",
|
||||
" 3. No, and tell Codex what to do differently (esc) ",
|
||||
" ",
|
||||
" Press enter to confirm or esc to cancel ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
|
||||
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
|
||||
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob1
|
||||
---
|
||||
• Exploring
|
||||
└ List ls -la
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob2
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob3
|
||||
---
|
||||
• Exploring
|
||||
└ List ls -la
|
||||
Read foo.txt
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob4
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read foo.txt
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob5
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read foo.txt
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob6
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read foo.txt, bar.txt
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
How was this?
|
||||
|
||||
› 1. bug Crash, error message, hang, or broken UI/behavior.
|
||||
2. bad result Output was off-target, incorrect, incomplete, or unhelpful.
|
||||
3. good result Helpful, correct, high‑quality, or delightful result worth
|
||||
celebrating.
|
||||
4. other Slowness, feature suggestion, UX feedback, or anything else.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
The following files will be sent:
|
||||
• codex-logs.log
|
||||
|
||||
› 1. Yes Share the current Codex session logs with the team for
|
||||
troubleshooting.
|
||||
2. No
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user