1909 lines
59 KiB
Rust
1909 lines
59 KiB
Rust
use std::future::Future;
|
|
use std::io;
|
|
use std::path::PathBuf;
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use crossterm::event::{
|
|
self, DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyEvent,
|
|
KeyModifiers, MouseEvent, MouseEventKind,
|
|
};
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
|
use protocol::{Method, PodStatus};
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use session_store::SegmentId;
|
|
use tokio::sync::mpsc;
|
|
|
|
use client::{PodClient, PodRuntimeCommand};
|
|
|
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
|
use crate::picker::PickerOutcome;
|
|
use crate::spawn::{SpawnOutcome, SpawnReady};
|
|
use crate::{multi_pod, picker, spawn, ui};
|
|
|
|
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
|
|
|
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
|
if let Some(p) = override_path {
|
|
return p;
|
|
}
|
|
manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| {
|
|
PathBuf::from("/tmp")
|
|
.join("yoi")
|
|
.join(pod_name)
|
|
.join("sock")
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn run_pod_name(
|
|
pod_name: String,
|
|
socket_override: Option<PathBuf>,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
|
let mut terminal = enter_fullscreen()?;
|
|
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let ready = match spawn::run_pod_name(pod_name, runtime_command.clone()).await? {
|
|
SpawnOutcome::Ready(r) => r,
|
|
SpawnOutcome::Cancelled => return Ok(()),
|
|
};
|
|
let mut terminal = enter_fullscreen()?;
|
|
terminal.clear()?;
|
|
let result = run_ready_pod(&mut terminal, ready, runtime_command).await;
|
|
let _ = leave_fullscreen(&mut terminal);
|
|
result
|
|
}
|
|
|
|
async fn run_connected_pod(
|
|
terminal: &mut FullscreenTerminal,
|
|
pod_name: String,
|
|
client: PodClient,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut app = App::new(pod_name);
|
|
app.connected = true;
|
|
run_loop(terminal, &mut app, client, runtime_command).await
|
|
}
|
|
|
|
async fn run_pod_name_nested(
|
|
terminal: &mut FullscreenTerminal,
|
|
request: multi_pod::OpenPodRequest,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let multi_pod::OpenPodRequest {
|
|
pod_name,
|
|
socket_override,
|
|
} = request;
|
|
|
|
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
|
|
return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await;
|
|
}
|
|
|
|
let ready =
|
|
spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command.clone()).await?;
|
|
run_ready_pod(terminal, ready, runtime_command).await
|
|
}
|
|
|
|
async fn spawn_pod_name_from_fullscreen(
|
|
terminal: &mut FullscreenTerminal,
|
|
pod_name: &str,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
|
|
leave_fullscreen(terminal)?;
|
|
let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await;
|
|
enter_fullscreen_existing(terminal)?;
|
|
terminal.clear()?;
|
|
|
|
match outcome? {
|
|
SpawnOutcome::Ready(ready) => Ok(ready),
|
|
SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)),
|
|
}
|
|
}
|
|
|
|
async fn try_connect_live_pod(
|
|
pod_name: &str,
|
|
socket_override: Option<PathBuf>,
|
|
) -> Option<PodClient> {
|
|
let preferred_socket = resolve_socket(pod_name, socket_override.clone());
|
|
connect_live_pod(pod_name, preferred_socket, socket_override.is_none())
|
|
.await
|
|
.map(|(_, client)| client)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct NestedOpenCancelled;
|
|
|
|
impl std::fmt::Display for NestedOpenCancelled {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("Pod open was cancelled")
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for NestedOpenCancelled {}
|
|
|
|
async fn run_ready_pod(
|
|
terminal: &mut FullscreenTerminal,
|
|
ready: SpawnReady,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let SpawnReady {
|
|
pod_name,
|
|
socket_path,
|
|
} = ready;
|
|
run(terminal, pod_name, &socket_path, runtime_command).await
|
|
}
|
|
|
|
async fn connect_live_pod(
|
|
pod_name: &str,
|
|
preferred_socket: PathBuf,
|
|
allow_registry_fallback: bool,
|
|
) -> Option<(PathBuf, PodClient)> {
|
|
if let Ok(client) = PodClient::connect(&preferred_socket).await {
|
|
return Some((preferred_socket, client));
|
|
}
|
|
|
|
if !allow_registry_fallback {
|
|
return None;
|
|
}
|
|
let registry_socket = picker::live_socket_for_pod(pod_name)?;
|
|
if registry_socket == preferred_socket {
|
|
return None;
|
|
}
|
|
PodClient::connect(®istry_socket)
|
|
.await
|
|
.ok()
|
|
.map(|client| (registry_socket, client))
|
|
}
|
|
|
|
pub(crate) async fn run_resume(
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
// Pick a Pod in its own inline viewport, dropping the viewport before
|
|
// attaching/restoring so each phase gets fresh vertical room.
|
|
let (pod_name, socket_override) = match picker::run().await? {
|
|
PickerOutcome::Picked {
|
|
pod_name,
|
|
socket_override,
|
|
} => (pod_name, socket_override),
|
|
PickerOutcome::Cancelled => return Ok(()),
|
|
};
|
|
run_pod_name(pod_name, socket_override, runtime_command).await
|
|
}
|
|
|
|
pub(crate) async fn run_panel(
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut app = multi_pod::load_app(runtime_command.clone()).await?;
|
|
let mut terminal = enter_fullscreen()?;
|
|
|
|
loop {
|
|
match multi_pod::run(&mut terminal, &mut app).await? {
|
|
multi_pod::MultiPodOutcome::Quit => {
|
|
let _ = leave_fullscreen(&mut terminal);
|
|
return Ok(());
|
|
}
|
|
multi_pod::MultiPodOutcome::Open(request) => {
|
|
let pod_name = request.pod_name.clone();
|
|
match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await {
|
|
Ok(()) => app.finish_open(&pod_name, Ok(())),
|
|
Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
|
|
app.finish_open(&pod_name, Err(error.as_ref()));
|
|
}
|
|
Err(error) => {
|
|
let _ = leave_fullscreen(&mut terminal);
|
|
return Err(error);
|
|
}
|
|
}
|
|
app.reload_or_notice().await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool {
|
|
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
|
|
}
|
|
|
|
pub(crate) async fn run_spawn(
|
|
resume_from: Option<SegmentId>,
|
|
profile: Option<String>,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let ready = match spawn::run(resume_from, profile, runtime_command.clone()).await? {
|
|
SpawnOutcome::Ready(r) => r,
|
|
SpawnOutcome::Cancelled => return Ok(()),
|
|
};
|
|
|
|
let SpawnReady {
|
|
pod_name,
|
|
socket_path,
|
|
} = ready;
|
|
|
|
let mut terminal = enter_fullscreen()?;
|
|
let result = run(&mut terminal, pod_name, &socket_path, runtime_command).await;
|
|
|
|
// Leave alt-screen explicitly before `main`'s terminal restore path.
|
|
let _ = execute!(
|
|
terminal.backend_mut(),
|
|
DisableMouseCapture,
|
|
LeaveAlternateScreen
|
|
);
|
|
|
|
result
|
|
}
|
|
|
|
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
Ok(Terminal::new(backend)?)
|
|
}
|
|
|
|
fn enter_fullscreen_existing(
|
|
terminal: &mut FullscreenTerminal,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
EnterAlternateScreen,
|
|
EnableMouseCapture
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> {
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
DisableMouseCapture,
|
|
LeaveAlternateScreen
|
|
)
|
|
}
|
|
|
|
async fn run(
|
|
terminal: &mut FullscreenTerminal,
|
|
pod_name: String,
|
|
socket_path: &std::path::Path,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut app = App::new(pod_name);
|
|
|
|
match PodClient::connect(socket_path).await {
|
|
Ok(client) => {
|
|
app.connected = true;
|
|
// The Pod sends `Event::Snapshot` automatically on connect;
|
|
// no explicit method call is required to fetch history.
|
|
run_loop(terminal, &mut app, client, runtime_command).await?;
|
|
}
|
|
Err(e) => {
|
|
app.push_error(format!(
|
|
"Failed to connect to {}: {e}",
|
|
socket_path.display()
|
|
));
|
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
|
run_disconnected(&mut app)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
type TerminalEventResult = io::Result<TermEvent>;
|
|
|
|
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64;
|
|
const POD_EVENT_DRAIN_LIMIT: usize = 32;
|
|
|
|
struct TerminalEventReader {
|
|
stop: Arc<AtomicBool>,
|
|
thread: Option<thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl TerminalEventReader {
|
|
fn spawn() -> io::Result<(Self, mpsc::UnboundedReceiver<TerminalEventResult>)> {
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let thread_stop = Arc::clone(&stop);
|
|
let thread = thread::Builder::new()
|
|
.name("yoi-tui-terminal-reader".to_string())
|
|
.spawn(move || read_terminal_events(thread_stop, tx))?;
|
|
|
|
Ok((
|
|
Self {
|
|
stop,
|
|
thread: Some(thread),
|
|
},
|
|
rx,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl Drop for TerminalEventReader {
|
|
fn drop(&mut self) {
|
|
self.stop.store(true, Ordering::Relaxed);
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<TerminalEventResult>) {
|
|
while !stop.load(Ordering::Relaxed) {
|
|
match event::poll(TERMINAL_POLL_INTERVAL) {
|
|
Ok(false) => {}
|
|
Ok(true) => {
|
|
let event = event::read();
|
|
let should_stop = event.is_err();
|
|
if tx.send(event).is_err() || should_stop {
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(Err(e));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum LoopInput<P> {
|
|
Terminal(TerminalEventResult),
|
|
Pod(Option<P>),
|
|
}
|
|
|
|
async fn next_loop_input<P, F>(
|
|
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
|
connected: bool,
|
|
pod_next: F,
|
|
) -> LoopInput<P>
|
|
where
|
|
F: Future<Output = Option<P>>,
|
|
{
|
|
tokio::select! {
|
|
biased;
|
|
|
|
term_event = term_rx.recv() => {
|
|
LoopInput::Terminal(term_event.unwrap_or_else(|| {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
"terminal event reader stopped",
|
|
))
|
|
}))
|
|
}
|
|
event = pod_next, if connected => LoopInput::Pod(event),
|
|
}
|
|
}
|
|
|
|
async fn drain_terminal_events(
|
|
app: &mut App,
|
|
client: &mut PodClient,
|
|
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
|
runtime_command: &PodRuntimeCommand,
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
let mut handled = false;
|
|
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
|
match term_rx.try_recv() {
|
|
Ok(event) => {
|
|
handled = true;
|
|
handle_terminal_event(app, client, event?, runtime_command).await?;
|
|
if app.quit {
|
|
break;
|
|
}
|
|
}
|
|
Err(mpsc::error::TryRecvError::Empty) => break,
|
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
|
return Err(Box::new(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
"terminal event reader stopped",
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(handled)
|
|
}
|
|
|
|
async fn drain_pod_events(
|
|
app: &mut App,
|
|
client: &mut PodClient,
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
let mut handled = false;
|
|
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
|
match client.try_next_event() {
|
|
Some(ev) => {
|
|
handled = true;
|
|
if let Some(method) = app.handle_pod_event(ev) {
|
|
client.send(&method).await?;
|
|
}
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
Ok(handled)
|
|
}
|
|
|
|
async fn run_loop(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
app: &mut App,
|
|
mut client: PodClient,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
|
|
|
terminal.draw(|f| ui::draw(f, app))?;
|
|
|
|
loop {
|
|
if app.quit {
|
|
break;
|
|
}
|
|
|
|
let handled_term_event =
|
|
drain_terminal_events(app, &mut client, &mut term_rx, &runtime_command).await?;
|
|
if app.quit {
|
|
break;
|
|
}
|
|
let handled_pod_event = drain_pod_events(app, &mut client).await?;
|
|
if handled_term_event || handled_pod_event {
|
|
terminal.draw(|f| ui::draw(f, app))?;
|
|
continue;
|
|
}
|
|
|
|
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
|
LoopInput::Terminal(term_event) => {
|
|
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
|
|
}
|
|
LoopInput::Pod(event) => match event {
|
|
Some(ev) => {
|
|
if let Some(method) = app.handle_pod_event(ev) {
|
|
client.send(&method).await?;
|
|
}
|
|
}
|
|
None => {
|
|
app.connected = false;
|
|
app.mark_orphan_compacts_incomplete();
|
|
app.push_error("Connection lost");
|
|
}
|
|
},
|
|
}
|
|
|
|
terminal.draw(|f| ui::draw(f, app))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_terminal_event(
|
|
app: &mut App,
|
|
client: &mut PodClient,
|
|
event: TermEvent,
|
|
_runtime_command: &PodRuntimeCommand,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
match event {
|
|
TermEvent::Key(key) => {
|
|
if let Some(method) = handle_key(app, key) {
|
|
client.send(&method).await?;
|
|
}
|
|
}
|
|
TermEvent::Mouse(mouse) => {
|
|
handle_mouse(app, mouse);
|
|
}
|
|
TermEvent::Paste(s) => {
|
|
app.insert_paste(s);
|
|
}
|
|
TermEvent::Resize(_, _) => {
|
|
// No-op: next draw repaints in full.
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
|
loop {
|
|
if event::poll(std::time::Duration::from_millis(100))?
|
|
&& let TermEvent::Key(key) = event::read()?
|
|
&& let KeyCode::Char('c') = key.code
|
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so
|
|
/// hand-rolling through long histories isn't tedious, but slow enough
|
|
/// that a single notch doesn't blow past the section the user is
|
|
/// looking for.
|
|
const WHEEL_LINES: usize = 3;
|
|
|
|
/// Lines to advance per PageUp / PageDown when the task side pane is
|
|
/// open. Calibrated so a couple of presses moves through one entry's
|
|
/// subject + description block.
|
|
const PANE_SCROLL_LINES: usize = 5;
|
|
|
|
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
|
match mouse.kind {
|
|
MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
|
|
MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
|
|
|
// Modifier-key bindings.
|
|
if let Some(method) = match key.code {
|
|
KeyCode::Up if shift => {
|
|
app.scroll.scroll_up(1);
|
|
Some(None)
|
|
}
|
|
KeyCode::Down if shift => {
|
|
app.scroll.scroll_down(1);
|
|
Some(None)
|
|
}
|
|
KeyCode::Home if ctrl => {
|
|
app.scroll.to_top();
|
|
Some(None)
|
|
}
|
|
KeyCode::End if ctrl => {
|
|
app.scroll.to_bottom();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char('[') if ctrl => {
|
|
app.scroll.jump_prev_turn();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char(']') if ctrl => {
|
|
app.scroll.jump_next_turn();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char('o') if ctrl => {
|
|
app.mode = app.mode.cycle();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char('t') if ctrl => {
|
|
app.toggle_task_pane();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => {
|
|
Some(app.request_rewind_picker())
|
|
}
|
|
KeyCode::Char('a') if ctrl => {
|
|
app.move_cursor_start();
|
|
Some(app.refresh_completion())
|
|
}
|
|
KeyCode::Left if ctrl || alt => {
|
|
app.move_cursor_word_left();
|
|
Some(app.refresh_completion())
|
|
}
|
|
KeyCode::Right if ctrl || alt => {
|
|
app.move_cursor_word_right();
|
|
Some(app.refresh_completion())
|
|
}
|
|
KeyCode::Backspace if ctrl || alt => {
|
|
app.delete_word_before_cursor();
|
|
Some(app.refresh_completion())
|
|
}
|
|
KeyCode::Char('w') if ctrl => {
|
|
app.delete_word_before_cursor();
|
|
Some(app.refresh_completion())
|
|
}
|
|
KeyCode::Char('u') if ctrl && app.is_command_mode() => {
|
|
app.clear_command_input();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char(c)
|
|
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
|
|
{
|
|
if app.restore_next_queued_input_to_composer() {
|
|
Some(app.refresh_completion())
|
|
} else {
|
|
Some(None)
|
|
}
|
|
}
|
|
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
|
|
app.clear_queued_inputs();
|
|
Some(None)
|
|
}
|
|
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
|
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
|
|
PodStatus::Running => {
|
|
app.clear_queued_inputs();
|
|
Some(Method::Cancel)
|
|
}
|
|
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
|
|
}),
|
|
KeyCode::Char('d') if ctrl => {
|
|
app.quit = true;
|
|
Some(None)
|
|
}
|
|
KeyCode::Enter if alt => {
|
|
if app.is_command_mode() {
|
|
Some(None)
|
|
} else {
|
|
app.insert_newline();
|
|
Some(app.refresh_completion())
|
|
}
|
|
}
|
|
_ => None,
|
|
} {
|
|
return method;
|
|
}
|
|
|
|
// Unbound Ctrl+Char keys are ignored before the text-input path so
|
|
// holding Ctrl while typing never inserts control characters.
|
|
if ctrl && matches!(key.code, KeyCode::Char(_)) {
|
|
return None;
|
|
}
|
|
|
|
// Scroll / navigation. PageUp / PageDown defaults to history; while
|
|
// the task pane is open it scrolls the pane instead so the user can
|
|
// browse past entries without first closing the pane.
|
|
match key.code {
|
|
KeyCode::PageUp => {
|
|
if app.task_pane_open {
|
|
app.scroll_task_pane_up(PANE_SCROLL_LINES);
|
|
} else {
|
|
app.scroll.page_up();
|
|
}
|
|
return None;
|
|
}
|
|
KeyCode::PageDown => {
|
|
if app.task_pane_open {
|
|
app.scroll_task_pane_down(PANE_SCROLL_LINES);
|
|
} else {
|
|
app.scroll.page_down();
|
|
}
|
|
return None;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if app.is_command_mode() {
|
|
return handle_command_key(app, key);
|
|
}
|
|
|
|
if app.rewind_picker.is_some() {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.close_rewind_picker();
|
|
return None;
|
|
}
|
|
KeyCode::Enter => return app.submit_rewind_picker(),
|
|
KeyCode::Up => {
|
|
app.rewind_picker_up();
|
|
return None;
|
|
}
|
|
KeyCode::Down => {
|
|
app.rewind_picker_down();
|
|
return None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Completion popup overrides — only when there's something to
|
|
// navigate / commit. An empty popup (request in flight) falls
|
|
// through to the default behaviour.
|
|
if app.completion.as_ref().is_some_and(|c| c.is_active()) {
|
|
match key.code {
|
|
KeyCode::Tab if !alt => {
|
|
// Insert the selected entry as raw text and let the
|
|
// re-triggered popup fetch fresh candidates (drill-in
|
|
// for directories, narrow-to-exact for files).
|
|
return app.apply_completion_text();
|
|
}
|
|
KeyCode::Enter if !alt => {
|
|
// While the popup has selectable entries, Enter
|
|
// commits the selection rather than submitting the
|
|
// message. The selected entry wins regardless of how
|
|
// much of its value the user has typed — Enter on a
|
|
// popup entry is "accept this suggestion". Directory
|
|
// entries are the exception: they fall through to
|
|
// text insertion so the popup re-fetches children
|
|
// for drill-in. After a successful chip we append a
|
|
// trailing space so the user can keep writing without
|
|
// a manual separator (the Space path already has the
|
|
// space the user typed, so it's not needed there).
|
|
if app.chipify_selected_completion_if_committable() {
|
|
app.insert_char(' ');
|
|
return None;
|
|
}
|
|
return app.apply_completion_text();
|
|
}
|
|
KeyCode::Up => {
|
|
app.move_completion_up();
|
|
return None;
|
|
}
|
|
KeyCode::Down => {
|
|
app.move_completion_down();
|
|
return None;
|
|
}
|
|
KeyCode::Esc => {
|
|
app.cancel_completion();
|
|
return None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
// Close the popup if it's still showing (covers the
|
|
// request-in-flight case where `is_active()` was false).
|
|
app.cancel_completion();
|
|
None
|
|
}
|
|
KeyCode::Enter => app.submit_input(),
|
|
KeyCode::Backspace => {
|
|
app.delete_char_before();
|
|
app.refresh_completion()
|
|
}
|
|
KeyCode::Delete => {
|
|
app.delete_char_after();
|
|
app.refresh_completion()
|
|
}
|
|
KeyCode::Left => {
|
|
app.move_cursor_left();
|
|
app.refresh_completion()
|
|
}
|
|
KeyCode::Right => {
|
|
app.move_cursor_right();
|
|
app.refresh_completion()
|
|
}
|
|
KeyCode::Up => {
|
|
if app.can_browse_input_history_older() && app.browse_input_history_older() {
|
|
app.refresh_completion()
|
|
} else {
|
|
app.move_cursor_up();
|
|
app.refresh_completion()
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if app.can_browse_input_history_newer() && app.browse_input_history_newer() {
|
|
app.refresh_completion()
|
|
} else {
|
|
app.move_cursor_down();
|
|
app.refresh_completion()
|
|
}
|
|
}
|
|
KeyCode::Home => {
|
|
app.move_cursor_home();
|
|
app.refresh_completion()
|
|
}
|
|
KeyCode::End => {
|
|
app.move_cursor_end();
|
|
app.refresh_completion()
|
|
}
|
|
KeyCode::Char(':') if !alt && app.input.is_empty() => {
|
|
app.enter_command_mode();
|
|
None
|
|
}
|
|
KeyCode::Char(c) => {
|
|
// Whitespace ends an in-flight completion token. Try the
|
|
// auto-confirm path first so an exact match (e.g. typed
|
|
// `@src/main.rs` matches the only popup entry) becomes a
|
|
// chip on the way out. Directories also commit here —
|
|
// ending with a space is an explicit "I want this dir"
|
|
// signal, not a drill-in.
|
|
if c.is_whitespace() {
|
|
app.chipify_completion_if_exact_match();
|
|
}
|
|
app.insert_char(c);
|
|
app.refresh_completion()
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.exit_command_mode();
|
|
None
|
|
}
|
|
KeyCode::Enter => app.submit_command_with_completion(),
|
|
KeyCode::Backspace => {
|
|
if app.command_text().is_empty() {
|
|
app.exit_command_mode();
|
|
} else {
|
|
app.delete_char_before();
|
|
}
|
|
None
|
|
}
|
|
KeyCode::Delete => {
|
|
app.delete_char_after();
|
|
None
|
|
}
|
|
KeyCode::Left => {
|
|
app.move_cursor_left();
|
|
None
|
|
}
|
|
KeyCode::Right => {
|
|
app.move_cursor_right();
|
|
None
|
|
}
|
|
KeyCode::Up => {
|
|
if app.command_completion_active() {
|
|
app.move_command_completion_up();
|
|
} else {
|
|
app.move_cursor_up();
|
|
}
|
|
None
|
|
}
|
|
KeyCode::Down => {
|
|
if app.command_completion_active() {
|
|
app.move_command_completion_down();
|
|
} else {
|
|
app.move_cursor_down();
|
|
}
|
|
None
|
|
}
|
|
KeyCode::Home => {
|
|
app.move_cursor_home();
|
|
None
|
|
}
|
|
KeyCode::End => {
|
|
app.move_cursor_end();
|
|
None
|
|
}
|
|
KeyCode::Tab => {
|
|
app.apply_command_completion();
|
|
None
|
|
}
|
|
KeyCode::Char(c) => {
|
|
if key
|
|
.modifiers
|
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
|
{
|
|
return None;
|
|
}
|
|
app.insert_char(c);
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
|
|
|
/// Running → send `Method::Pause`.
|
|
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
|
|
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
|
if app.pod_status == PodStatus::Running {
|
|
app.clear_queued_inputs();
|
|
return Some(Method::Pause);
|
|
}
|
|
if let Some(t) = app.quit_confirm
|
|
&& t.elapsed() < CONFIRM_TIMEOUT
|
|
{
|
|
app.quit_confirm = None;
|
|
app.quit = true;
|
|
return None;
|
|
}
|
|
app.quit_confirm = Some(std::time::Instant::now());
|
|
app.flash_actionbar_notice(
|
|
"Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).",
|
|
ActionbarNoticeLevel::Warn,
|
|
ActionbarNoticeSource::Tui,
|
|
CONFIRM_TIMEOUT,
|
|
);
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
|
|
|
#[tokio::test]
|
|
async fn terminal_event_is_selected_before_ready_pod_event() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
|
KeyCode::Char('x'),
|
|
KeyModifiers::NONE,
|
|
))))
|
|
.unwrap();
|
|
|
|
match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await {
|
|
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
|
assert_eq!(key.code, KeyCode::Char('x'));
|
|
}
|
|
_ => panic!("ready terminal input should win over a ready Pod event"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_event_is_preserved_after_pod_event_wins() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
|
|
LoopInput::Pod(Some(1)) => {}
|
|
_ => panic!("expected the first ready Pod event to win before any terminal input"),
|
|
}
|
|
|
|
tx.send(Ok(TermEvent::Key(KeyEvent::new(
|
|
KeyCode::Char('y'),
|
|
KeyModifiers::NONE,
|
|
))))
|
|
.unwrap();
|
|
|
|
match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await {
|
|
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
|
assert_eq!(key.code, KeyCode::Char('y'));
|
|
}
|
|
_ => panic!("queued terminal input should not be lost to subsequent Pod events"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn running_status_still_allows_text_editing() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.set_pod_status(PodStatus::Running);
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)).is_none());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
|
|
assert_eq!(
|
|
protocol::Segment::flatten_to_text(&app.input.submit_segments()),
|
|
"abc"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn running_enter_queues_instead_of_sending_run() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.set_pod_status(PodStatus::Running);
|
|
for c in "queued".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
|
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
assert_eq!(app.next_queued_input_preview(), Some("queued"));
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn queued_input_keybindings_restore_and_clear() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.set_pod_status(PodStatus::Running);
|
|
for c in "edit queued".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
assert_eq!(input_text(&app), "edit queued");
|
|
|
|
app.input.clear();
|
|
for c in "clear queued".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pause_and_cancel_clear_queued_input() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.set_pod_status(PodStatus::Running);
|
|
for c in "queued".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
|
|
let pause = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(matches!(pause, Some(Method::Pause)));
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
|
|
for c in "queued again".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
|
|
let cancel = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(matches!(cancel, Some(Method::Cancel)));
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn word_navigation_keys_edit_composer() {
|
|
let mut app = App::new("agent".to_string());
|
|
for c in "foo bar".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(input_text(&app), "foo _bar");
|
|
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)).is_none());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(input_text(&app), "foo _bar!");
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(input_text(&app), "foo ");
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_w_deletes_word_before_cursor() {
|
|
let mut app = App::new("agent".to_string());
|
|
for c in "foo bar baz".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(input_text(&app), "foo bar ");
|
|
}
|
|
|
|
#[test]
|
|
fn word_navigation_keys_edit_command_input() {
|
|
let mut app = App::new("agent".to_string());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
for c in "peer alpha beta".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(app.command_text(), "peer alpha _beta");
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(app.command_text(), "peer alpha beta");
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_enters_with_colon_and_esc_restores_composer() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.insert_char('d');
|
|
app.insert_char('r');
|
|
app.insert_char('a');
|
|
app.insert_char('f');
|
|
app.insert_char('t');
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "draft:");
|
|
|
|
app.input.clear();
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(app.is_command_mode());
|
|
for c in "help".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
assert_eq!(input_text(&app), "");
|
|
assert_eq!(app.command_text(), "help");
|
|
|
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none());
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_empty_backspace_restores_composer() {
|
|
let mut app = App::new("agent".to_string());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(app.is_command_mode());
|
|
assert_eq!(app.command_text(), "");
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_non_empty_backspace_keeps_command_mode() {
|
|
let mut app = App::new("agent".to_string());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(app.is_command_mode());
|
|
assert_eq!(app.command_text(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_command_is_not_sent_as_user_message() {
|
|
let mut app = App::new("agent".to_string());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
for c in "does-not-exist".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(method.is_none());
|
|
assert!(app.is_command_mode());
|
|
assert_eq!(input_text(&app), "");
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
assert!(app.blocks.iter().any(|block| match block {
|
|
crate::block::Block::Alert { message, .. } => message.contains("Unknown command"),
|
|
_ => false,
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn command_enter_dispatches_registry_without_run() {
|
|
let mut app = App::new("agent".to_string());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
for c in "noop".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(method.is_none());
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(app.blocks.iter().any(|block| match block {
|
|
crate::block::Block::Alert { message, .. } => message.contains("noop: no action"),
|
|
_ => false,
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn compact_command_sends_compact_method_without_run() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.connected = true;
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
for c in "compact".chars() {
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(matches!(method, Some(protocol::Method::Compact)));
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "");
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
assert!(app.blocks.iter().any(|block| match block {
|
|
crate::block::Block::Alert { message, .. } => message.contains("compact requested"),
|
|
_ => false,
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.set_pod_status(PodStatus::Idle);
|
|
|
|
let method = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
|
|
assert!(method.is_none());
|
|
assert!(!app.quit);
|
|
let notice = app
|
|
.current_actionbar_notice(std::time::Instant::now())
|
|
.expect("quit guard notice is active");
|
|
assert!(notice.text.contains("Pod keeps running"));
|
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
|
assert!(!has_alert(&app, "Pod keeps running"));
|
|
|
|
let method = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(method.is_none());
|
|
assert!(app.quit);
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_r_requests_rewind_picker_when_idle_or_paused() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.connected = true;
|
|
let idle = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
|
|
|
app.set_pod_status(PodStatus::Paused);
|
|
let paused = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(matches!(paused, Some(Method::ListRewindTargets)));
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_r_is_rejected_while_running() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.connected = true;
|
|
app.set_pod_status(PodStatus::Running);
|
|
|
|
let method = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
|
);
|
|
|
|
assert!(method.is_none());
|
|
assert!(has_alert(&app, "cannot rewind while the Pod is running"));
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_picker_close_returns_to_history_view() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.connected = true;
|
|
app.handle_pod_event(Event::RewindTargets {
|
|
head_entries: 1,
|
|
targets: vec![],
|
|
});
|
|
assert!(app.rewind_picker.is_none());
|
|
|
|
let method = handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(matches!(method, Some(Method::ListRewindTargets)));
|
|
app.handle_pod_event(Event::RewindTargets {
|
|
head_entries: 1,
|
|
targets: vec![],
|
|
});
|
|
assert!(app.rewind_picker.is_some());
|
|
|
|
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
|
|
assert!(method.is_none());
|
|
assert!(app.rewind_picker.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_applied_reseeds_display_and_restores_composer() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.handle_pod_event(Event::Snapshot {
|
|
greeting: test_greeting(),
|
|
entries: vec![],
|
|
status: PodStatus::Idle,
|
|
});
|
|
app.handle_pod_event(Event::RewindApplied {
|
|
entries: vec![],
|
|
input: vec![Segment::Text {
|
|
content: "retry this".into(),
|
|
}],
|
|
summary: protocol::RewindSummary {
|
|
truncated_to_entries: 0,
|
|
discarded_entries: 2,
|
|
tool_side_effect_warning: true,
|
|
},
|
|
});
|
|
|
|
assert_eq!(input_text(&app), "retry this");
|
|
assert!(app.rewind_picker.is_none());
|
|
assert!(has_alert(&app, "tool side effects"));
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_applied_keeps_non_empty_composer() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.handle_pod_event(Event::Snapshot {
|
|
greeting: test_greeting(),
|
|
entries: vec![],
|
|
status: PodStatus::Idle,
|
|
});
|
|
type_keys(&mut app, "draft");
|
|
|
|
app.handle_pod_event(Event::RewindApplied {
|
|
entries: vec![],
|
|
input: vec![Segment::Text {
|
|
content: "retry this".into(),
|
|
}],
|
|
summary: protocol::RewindSummary {
|
|
truncated_to_entries: 0,
|
|
discarded_entries: 2,
|
|
tool_side_effect_warning: false,
|
|
},
|
|
});
|
|
|
|
assert_eq!(input_text(&app), "draft");
|
|
assert!(has_alert(
|
|
&app,
|
|
"composer not overwritten because it was not empty"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
|
type_keys(&mut app, "draft");
|
|
assert!(app.submit_rewind_picker().is_none());
|
|
assert!(has_alert(&app, "composer is not empty"));
|
|
|
|
let mut app = App::new("agent".to_string());
|
|
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
|
app.set_pod_status(PodStatus::Paused);
|
|
assert!(app.submit_rewind_picker().is_none());
|
|
assert!(has_alert(
|
|
&app,
|
|
"cannot apply rewind while the Pod is paused"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rewind_picker_draw_does_not_overwrite_history_scroll_state() {
|
|
let mut app = App::new("agent".to_string());
|
|
app.scroll.top_offset = 3;
|
|
app.scroll.turn_starts = vec![0, 5, 9];
|
|
app.scroll.total_lines = 42;
|
|
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
|
let original_top_offset = app.scroll.top_offset;
|
|
let original_turn_starts = app.scroll.turn_starts.clone();
|
|
let original_total_lines = app.scroll.total_lines;
|
|
|
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|frame| crate::ui::draw(frame, &mut app))
|
|
.unwrap();
|
|
app.close_rewind_picker();
|
|
|
|
assert_eq!(app.scroll.top_offset, original_top_offset);
|
|
assert_eq!(app.scroll.turn_starts, original_turn_starts);
|
|
assert_eq!(app.scroll.total_lines, original_total_lines);
|
|
}
|
|
|
|
fn rewind_target() -> RewindTarget {
|
|
RewindTarget {
|
|
id: RewindTargetId {
|
|
segment_id: uuid::Uuid::nil(),
|
|
user_input_entry_index: 0,
|
|
},
|
|
expected_head_entries: 1,
|
|
truncate_entries: 0,
|
|
turn_index: 1,
|
|
timestamp_ms: Some(1),
|
|
preview: "retry this".into(),
|
|
eligible: true,
|
|
disabled_reason: None,
|
|
warning: None,
|
|
}
|
|
}
|
|
|
|
fn test_greeting() -> protocol::Greeting {
|
|
protocol::Greeting {
|
|
pod_name: "agent".into(),
|
|
cwd: "/tmp".into(),
|
|
provider: "test".into(),
|
|
model: "test".into(),
|
|
scope_summary: "".into(),
|
|
tools: vec![],
|
|
context_window: 0,
|
|
context_tokens: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_registry_suggestions_are_available() {
|
|
let mut app = App::new("agent".to_string());
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
app.command_suggestions()
|
|
.iter()
|
|
.any(|candidate| candidate.name == "help")
|
|
);
|
|
assert!(
|
|
handle_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)
|
|
)
|
|
.is_none()
|
|
);
|
|
let suggestions = app.command_suggestions();
|
|
assert_eq!(suggestions.len(), 1);
|
|
assert_eq!(suggestions[0].name, "noop");
|
|
}
|
|
|
|
#[test]
|
|
fn command_completion_tab_applies_unambiguous_candidate() {
|
|
let mut app = App::new("agent".to_string());
|
|
enter_command_mode(&mut app);
|
|
type_keys(&mut app, "no");
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
|
|
|
assert!(app.is_command_mode());
|
|
assert_eq!(app.command_text(), "noop ");
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn command_completion_enter_applies_and_executes_unambiguous_candidate() {
|
|
let mut app = App::new("agent".to_string());
|
|
enter_command_mode(&mut app);
|
|
type_keys(&mut app, "no");
|
|
|
|
let method = handle_key(&mut app, key(KeyCode::Enter));
|
|
|
|
assert!(method.is_none());
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(has_alert(&app, "noop: no action"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_completion_ambiguous_candidate_requires_selection_or_more_input() {
|
|
let mut app = App::new("agent".to_string());
|
|
register_test_command(&mut app, "open", "open", parse_no_args, "open executed");
|
|
register_test_command(
|
|
&mut app,
|
|
"options",
|
|
"options",
|
|
parse_no_args,
|
|
"options executed",
|
|
);
|
|
enter_command_mode(&mut app);
|
|
type_keys(&mut app, "o");
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
|
assert_eq!(app.command_text(), "o");
|
|
assert!(app.is_command_mode());
|
|
assert!(has_alert(&app, "Ambiguous command completion"));
|
|
|
|
let before = app.blocks.len();
|
|
let method = handle_key(&mut app, key(KeyCode::Enter));
|
|
assert!(method.is_none());
|
|
assert_eq!(app.command_text(), "o");
|
|
assert!(app.is_command_mode());
|
|
assert!(app.blocks.len() > before);
|
|
assert!(!has_alert(&app, "open executed"));
|
|
assert!(!has_alert(&app, "options executed"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_completion_selected_candidate_applies_on_enter() {
|
|
let mut app = App::new("agent".to_string());
|
|
register_test_command(&mut app, "open", "open", parse_no_args, "open executed");
|
|
register_test_command(
|
|
&mut app,
|
|
"options",
|
|
"options",
|
|
parse_no_args,
|
|
"options executed",
|
|
);
|
|
enter_command_mode(&mut app);
|
|
type_keys(&mut app, "o");
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
|
let method = handle_key(&mut app, key(KeyCode::Enter));
|
|
|
|
assert!(method.is_none());
|
|
assert!(!app.is_command_mode());
|
|
assert!(has_alert(&app, "open executed"));
|
|
assert!(!has_alert(&app, "options executed"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_completion_argument_required_keeps_command_mode_after_name_completion() {
|
|
let mut app = App::new("agent".to_string());
|
|
register_test_command(
|
|
&mut app,
|
|
"open",
|
|
"open <path>",
|
|
parse_required_arg,
|
|
"open executed",
|
|
);
|
|
enter_command_mode(&mut app);
|
|
type_keys(&mut app, "op");
|
|
|
|
let method = handle_key(&mut app, key(KeyCode::Enter));
|
|
|
|
assert!(method.is_none());
|
|
assert!(app.is_command_mode());
|
|
assert_eq!(app.command_text(), "open ");
|
|
assert!(has_alert(&app, "Invalid arguments. Usage: open <path>"));
|
|
assert!(!has_alert(&app, "open executed"));
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn command_completion_does_not_affect_normal_composer_without_popup() {
|
|
let mut app = App::new("agent".to_string());
|
|
type_keys(&mut app, "hello");
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
|
|
|
assert!(!app.is_command_mode());
|
|
assert_eq!(input_text(&app), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn up_at_start_with_empty_history_preserves_draft_without_browsing() {
|
|
let mut app = App::new("agent".to_string());
|
|
type_keys(&mut app, "draft");
|
|
app.move_cursor_start();
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
|
|
|
assert_eq!(input_text(&app), "draft");
|
|
assert!(!app.input_history_is_browsing());
|
|
}
|
|
|
|
#[test]
|
|
fn up_from_empty_composer_recalls_history_and_down_restores_empty_draft() {
|
|
let mut app = App::new("agent".to_string());
|
|
type_keys(&mut app, "first");
|
|
assert!(matches!(
|
|
handle_key(&mut app, key(KeyCode::Enter)),
|
|
Some(Method::Run { .. })
|
|
));
|
|
type_keys(&mut app, "second");
|
|
assert!(matches!(
|
|
handle_key(&mut app, key(KeyCode::Enter)),
|
|
Some(Method::Run { .. })
|
|
));
|
|
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
|
assert_eq!(input_text(&app), "second");
|
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
|
assert_eq!(input_text(&app), "first");
|
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
|
assert_eq!(input_text(&app), "second");
|
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn up_inside_multiline_preserves_existing_cursor_up_behavior() {
|
|
let mut app = App::new("agent".to_string());
|
|
type_keys(&mut app, "ab\ncd");
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
|
assert!(handle_key(&mut app, key(KeyCode::Char('X'))).is_none());
|
|
|
|
assert_eq!(input_text(&app), "abX\ncd");
|
|
}
|
|
|
|
#[test]
|
|
fn up_at_start_of_multiline_recalls_history() {
|
|
let mut app = App::new("agent".to_string());
|
|
type_keys(&mut app, "sent");
|
|
assert!(matches!(
|
|
handle_key(&mut app, key(KeyCode::Enter)),
|
|
Some(Method::Run { .. })
|
|
));
|
|
type_keys(&mut app, "draft\nbody");
|
|
app.move_cursor_start();
|
|
|
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
|
|
|
assert_eq!(input_text(&app), "sent");
|
|
}
|
|
|
|
fn enter_command_mode(app: &mut App) {
|
|
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
|
|
assert!(app.is_command_mode());
|
|
}
|
|
|
|
fn type_keys(app: &mut App, text: &str) {
|
|
for c in text.chars() {
|
|
assert!(handle_key(app, key(KeyCode::Char(c))).is_none());
|
|
}
|
|
}
|
|
|
|
fn key(code: KeyCode) -> KeyEvent {
|
|
KeyEvent::new(code, KeyModifiers::NONE)
|
|
}
|
|
|
|
fn has_alert(app: &App, needle: &str) -> bool {
|
|
app.blocks.iter().any(|block| match block {
|
|
crate::block::Block::Alert { message, .. } => message.contains(needle),
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
fn register_test_command(
|
|
app: &mut App,
|
|
name: &'static str,
|
|
usage: &'static str,
|
|
argument_parser: crate::command::ArgumentParser,
|
|
message: &'static str,
|
|
) {
|
|
app.command_registry.register(crate::command::CommandSpec {
|
|
name,
|
|
aliases: &[],
|
|
usage,
|
|
description: "test command",
|
|
argument_parser,
|
|
can_execute: test_command_available,
|
|
executor: test_command_executor,
|
|
});
|
|
TEST_COMMAND_MESSAGES.with(|messages| messages.borrow_mut().push((name, message)));
|
|
}
|
|
|
|
thread_local! {
|
|
static TEST_COMMAND_MESSAGES: std::cell::RefCell<Vec<(&'static str, &'static str)>> =
|
|
const { std::cell::RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
fn parse_no_args(
|
|
raw: &str,
|
|
) -> Result<crate::command::CommandArgs, crate::command::CommandDiagnostic> {
|
|
Ok(crate::command::CommandArgs::parse_whitespace(raw))
|
|
}
|
|
|
|
fn parse_required_arg(
|
|
raw: &str,
|
|
) -> Result<crate::command::CommandArgs, crate::command::CommandDiagnostic> {
|
|
let args = crate::command::CommandArgs::parse_whitespace(raw);
|
|
if args.argv().is_empty() {
|
|
return Err(crate::command::CommandDiagnostic::new(
|
|
"Invalid arguments. Usage: open <path>",
|
|
));
|
|
}
|
|
Ok(args)
|
|
}
|
|
|
|
fn test_command_available(
|
|
_environment: &crate::command::CommandEnvironment,
|
|
) -> Result<(), crate::command::CommandDiagnostic> {
|
|
Ok(())
|
|
}
|
|
|
|
fn test_command_executor(
|
|
invocation: crate::command::CommandInvocation<'_>,
|
|
) -> crate::command::CommandExecution {
|
|
let message = TEST_COMMAND_MESSAGES
|
|
.with(|messages| {
|
|
messages
|
|
.borrow()
|
|
.iter()
|
|
.find(|(name, _)| *name == invocation.command.name)
|
|
.map(|(_, message)| *message)
|
|
})
|
|
.unwrap_or("test command executed");
|
|
crate::command::CommandExecution::notice(message)
|
|
}
|
|
|
|
fn input_text(app: &App) -> String {
|
|
protocol::Segment::flatten_to_text(&app.input.submit_segments())
|
|
}
|
|
}
|