fix: restore cursor after inline terminal menus

This commit is contained in:
2026-09-03 02:34:27 +09:00
parent 8344921b65
commit fc05bf9711
6 changed files with 182 additions and 133 deletions
+7 -27
View File
@@ -7,15 +7,15 @@ use client::{
list_backend_workers, restore_backend_worker, list_backend_workers, restore_backend_worker,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend; use ratatui::Frame;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use crate::backend_workspace_picker::select_backend_workspace; use crate::backend_workspace_picker::select_backend_workspace;
use crate::console; use crate::console;
use crate::inline_terminal::with_inline_terminal;
const MAX_ROWS: usize = 10; const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
@@ -127,7 +127,9 @@ fn pick_worker(
workers.truncate(MAX_ROWS); workers.truncate(MAX_ROWS);
let mut state = BackendWorkerPickerState::new(target, workers); let mut state = BackendWorkerPickerState::new(target, workers);
let mut terminal = make_inline_terminal()?; with_inline_terminal(
VIEWPORT_LINES,
|terminal| -> Result<_, Box<dyn std::error::Error>> {
loop { loop {
terminal.draw(|frame| draw(frame, &state))?; terminal.draw(|frame| draw(frame, &state))?;
match poll_event()? { match poll_event()? {
@@ -135,23 +137,22 @@ fn pick_worker(
Some(Action::Up) => state.previous(), Some(Action::Up) => state.previous(),
Some(Action::Down) => state.next(), Some(Action::Down) => state.next(),
Some(Action::Submit) => { Some(Action::Submit) => {
close_viewport(&mut terminal)?;
return Ok(WorkerPickerResult::Selected( return Ok(WorkerPickerResult::Selected(
state.selected_worker().clone(), state.selected_worker().clone(),
)); ));
} }
Some(Action::SwitchWorkspace) => { Some(Action::SwitchWorkspace) => {
close_viewport(&mut terminal)?;
return Ok(WorkerPickerResult::SwitchWorkspace); return Ok(WorkerPickerResult::SwitchWorkspace);
} }
Some(Action::Cancel) => { Some(Action::Cancel) => {
close_viewport(&mut terminal)?;
return Err(Box::new(io::Error::other( return Err(Box::new(io::Error::other(
"Backend worker picker cancelled", "Backend worker picker cancelled",
))); )));
} }
} }
} }
},
)
} }
struct BackendWorkerPickerState { struct BackendWorkerPickerState {
@@ -184,27 +185,6 @@ impl BackendWorkerPickerState {
} }
} }
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
use std::io::Write;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
}
enum Action { enum Action {
Up, Up,
Down, Down,
+109
View File
@@ -0,0 +1,109 @@
use std::io::{self, Stdout, Write};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::{TerminalOptions, Viewport};
pub(crate) type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
struct InlineTerminalGuard {
terminal: InlineTerminal,
closed: bool,
}
impl InlineTerminalGuard {
fn open(height: u16) -> io::Result<Self> {
let terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(height),
},
)?;
Ok(Self {
terminal,
closed: false,
})
}
fn close(&mut self) -> io::Result<()> {
if self.closed {
return Ok(());
}
self.closed = true;
let area = self.terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
let cursor_result = self.terminal.set_cursor_position((0, last_row));
let output_result = write_viewport_terminator(&mut io::stdout());
cursor_result?;
output_result
}
}
impl Drop for InlineTerminalGuard {
fn drop(&mut self) {
let _ = self.close();
}
}
pub(crate) fn with_inline_terminal<T, E>(
height: u16,
run: impl FnOnce(&mut InlineTerminal) -> Result<T, E>,
) -> Result<T, E>
where
E: From<io::Error>,
{
let mut guard = InlineTerminalGuard::open(height).map_err(E::from)?;
let result = run(&mut guard.terminal);
let close_result = guard.close();
match result {
Ok(value) => {
close_result.map_err(E::from)?;
Ok(value)
}
Err(error) => Err(error),
}
}
fn write_viewport_terminator(output: &mut impl Write) -> io::Result<()> {
output.write_all(b"\r\n")?;
output.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewport_terminator_moves_following_output_to_a_fresh_line() {
let mut output = Vec::new();
write_viewport_terminator(&mut output).unwrap();
assert_eq!(output, b"\r\n");
}
#[test]
fn inline_viewport_construction_is_owned_by_this_module() {
fn assert_shared_owner(path: &std::path::Path) {
for entry in std::fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
assert_shared_owner(&path);
} else if path.extension().and_then(|value| value.to_str()) == Some("rs")
&& path.file_name().and_then(|value| value.to_str())
!= Some("inline_terminal.rs")
{
let source = std::fs::read_to_string(&path).unwrap();
assert!(
!source.contains("Viewport::Inline"),
"{} constructs an inline viewport outside its shared owner",
path.display()
);
}
}
}
assert_shared_owner(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"));
}
}
+5 -34
View File
@@ -1,17 +1,17 @@
use std::io::{self, Stdout, Write};
use std::process::ExitCode; use std::process::ExitCode;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend; use ratatui::Frame;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use secrets::{SecretStore, SecretValue}; use secrets::{SecretStore, SecretValue};
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum Mode { enum Mode {
Normal, Normal,
@@ -235,7 +235,6 @@ pub async fn launch() -> ExitCode {
} }
type UiResult<T> = Result<T, Box<dyn std::error::Error>>; type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
const MAX_ROWS: usize = 10; const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5;
@@ -270,37 +269,9 @@ impl Drop for RawModeGuard {
fn run(store: SecretStore) -> UiResult<()> { fn run(store: SecretStore) -> UiResult<()> {
enable_raw_mode()?; enable_raw_mode()?;
let guard = RawModeGuard::new(); let guard = RawModeGuard::new();
let mut terminal = make_inline_terminal()?; let result = with_inline_terminal(VIEWPORT_LINES, |terminal| run_loop(terminal, store));
let result = run_loop(&mut terminal, store);
let close_result = close_viewport(&mut terminal);
drop(terminal);
guard.restore(); guard.restore();
result?; result
close_result?;
Ok(())
}
fn make_inline_terminal() -> io::Result<InlineTerminal> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
/// Park the cursor at the very bottom of the inline viewport and emit one
/// newline before dropping the terminal. This matches the resume picker and
/// keeps the shell prompt (or a later inline viewport) from drawing over rows.
fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
} }
fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> { fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> {
+1
View File
@@ -10,6 +10,7 @@ mod composer_keys;
mod console; mod console;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
mod e2e_observer; mod e2e_observer;
mod inline_terminal;
mod input; mod input;
pub mod keys; pub mod keys;
mod markdown; mod markdown;
+5 -11
View File
@@ -3,15 +3,14 @@ use std::time::Duration;
use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target}; use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::{Color, Line, Modifier, Span, Style}; use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport};
use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore}; use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
use thiserror::Error; use thiserror::Error;
use crate::inline_terminal::with_inline_terminal;
const LIMIT: usize = 100; const LIMIT: usize = 100;
pub(crate) fn pick( pub(crate) fn pick(
@@ -57,13 +56,7 @@ fn run_picker(
records: Vec<StandaloneWorkerRecord>, records: Vec<StandaloneWorkerRecord>,
) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> { ) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> {
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20); let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
let mut terminal = Terminal::with_options( with_inline_terminal(height, |terminal| {
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.map_err(StandalonePickerError::Io)?;
let mut selected = 0usize; let mut selected = 0usize;
loop { loop {
terminal terminal
@@ -92,6 +85,7 @@ fn run_picker(
_ => {} _ => {}
} }
} }
})
} }
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) { fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) {
@@ -149,7 +143,7 @@ pub(crate) enum StandalonePickerError {
)] )]
NoWorkers { include_all: bool }, NoWorkers { include_all: bool },
#[error("standalone Worker picker I/O failed: {0}")] #[error("standalone Worker picker I/O failed: {0}")]
Io(#[source] io::Error), Io(#[from] io::Error),
} }
#[cfg(test)] #[cfg(test)]
+6 -12
View File
@@ -1,17 +1,17 @@
use std::io::{self, Stdout}; use std::io;
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery; use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use thiserror::Error; use thiserror::Error;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
const VIEWPORT_HEIGHT: u16 = 6; const VIEWPORT_HEIGHT: u16 = 6;
const FALLBACK_WORKER_NAME: &str = "worker"; const FALLBACK_WORKER_NAME: &str = "worker";
@@ -182,15 +182,16 @@ pub(crate) fn select(
return Err(StandaloneSpawnError::NoProfiles); return Err(StandaloneSpawnError::NoProfiles);
} }
let terminal = open_inline_terminal()?; with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
run_picker( run_picker(
terminal, terminal,
SpawnForm::new(worker_name, default_worker_name, choices), SpawnForm::new(worker_name, default_worker_name, choices),
) )
})
} }
fn run_picker( fn run_picker(
mut terminal: Terminal<CrosstermBackend<Stdout>>, terminal: &mut InlineTerminal,
mut form: SpawnForm, mut form: SpawnForm,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> { ) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
loop { loop {
@@ -222,13 +223,6 @@ fn run_picker(
} }
} }
fn open_inline_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
let options = ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT),
};
Terminal::with_options(CrosstermBackend::new(io::stdout()), options)
}
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> { fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
registry registry
.entries() .entries()