From 0ddcfc279286f38607f35efcf0b776804a857acc Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 6 Nov 2026 06:38:53 +0100 Subject: [PATCH] fix(cli-rs): refresh the home, prompt and sidebar footers on a resize --- .../crates/tui/src/plugins/home_footer.rs | 15 ++---- .../cli-rs/crates/tui/src/prompt/editor.rs | 25 +++++++++- packages/cli-rs/crates/tui/src/routes/home.rs | 10 ++-- .../crates/tui/src/routes/session/sidebar.rs | 11 ++++- packages/cli-rs/crates/tui/tests/all/main.rs | 1 + .../cli-rs/crates/tui/tests/all/resize.rs | 49 +++++++++++++++++++ 6 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 packages/cli-rs/crates/tui/tests/all/resize.rs diff --git a/packages/cli-rs/crates/tui/src/plugins/home_footer.rs b/packages/cli-rs/crates/tui/src/plugins/home_footer.rs index 231f8a857c0..e0b5c6ef75f 100644 --- a/packages/cli-rs/crates/tui/src/plugins/home_footer.rs +++ b/packages/cli-rs/crates/tui/src/plugins/home_footer.rs @@ -42,14 +42,6 @@ impl Footer { status: RefCell::default(), }); inner.refresh(tui); - // The visibility follows the terminal size (`useTerminalDimensions`). - let weak = Rc::downgrade(&inner); - let owner = tui.weak(); - inner.root.on_resize(move |_, _| { - if let (Some(inner), Some(tui)) = (weak.upgrade(), owner.upgrade()) { - inner.refresh(&tui); - } - }); Self(inner) } } @@ -125,10 +117,9 @@ impl Slotted for Footer { } fn sync(&self, tui: &Tui, changes: &Changes) { - // It reads the default location's MCP servers and the terminal size, which its root's resize misses - // when only the height changes. - let resized = self.0.shown.borrow().as_ref().is_some_and(|model| model.size != tui.app.size()); - if !changes.is_empty() && (resized || tui.data.read(|state| changes.location(state.default_location()))) { + // It reads the default location's MCP servers and the terminal size; the home syncs every change on a + // resize. + if tui.data.read(|state| changes.location(state.default_location())) { self.0.refresh(tui); } self.0.status.borrow().sync(tui, changes); diff --git a/packages/cli-rs/crates/tui/src/prompt/editor.rs b/packages/cli-rs/crates/tui/src/prompt/editor.rs index 9205da3e7eb..40a5a8654b4 100644 --- a/packages/cli-rs/crates/tui/src/prompt/editor.rs +++ b/packages/cli-rs/crates/tui/src/prompt/editor.rs @@ -412,12 +412,26 @@ pub fn mount(tui: &Tui, session: Option<&SessionID>) -> Prompt { } }); inner.layers.borrow_mut().push(mentions); - // `useCursorStyle` and the animations follow the config, as the metadata row's auto-accept mark does. + // `useCursorStyle` and the animations follow the config, as the metadata row's auto-accept mark does; the + // footer's shortcuts follow the keybinds. tui.settings.on_change(bind!(inner, |inner, tui| { let cursor = crate::features::system_commands::cursor(&tui.config()); inner.textarea.cursor_style(cursor.0, cursor.1); inner.staging.set_animations(opencode_ui::motion::enabled(tui.config().info.animations)); - inner.refresh(tui); + inner.relayout(tui); + })); + // The metadata row and the footer follow the terminal size (`useTerminalDimensions`). + let weak = Rc::downgrade(&inner); + let resize = tui.app.on_resize(move |_, _, _| { + if let Some(inner) = weak.upgrade() + && let Some(tui) = inner.tui.upgrade() + { + inner.relayout(&tui); + } + }); + let app = tui.app.clone(); + inner.layers.borrow_mut().push(Disposer::new(move || { + app.remove_handler(resize); })); // Home drops the last session's selection; a session follows its directory (`editor.reconnect`). match session { @@ -708,6 +722,13 @@ impl Inner { *spinner = Some(mounted); } + /// Repaints after a change outside the store, the terminal size or the settings, which the footer slots read too. + fn relayout(&self, tui: &Tui) { + self.refresh(tui); + let everything = Changes::everything(); + self.footer_slots.borrow().1.iter().for_each(|slot| slot.sync(tui, &everything)); + } + /// Mounts the `prompt.footer` slots again when their input changed (E.14). fn mount_footer(&self, tui: &Tui) { let input = opencode_plugin::SlotInput { diff --git a/packages/cli-rs/crates/tui/src/routes/home.rs b/packages/cli-rs/crates/tui/src/routes/home.rs index 16cf5486c95..a7b6617c3e1 100644 --- a/packages/cli-rs/crates/tui/src/routes/home.rs +++ b/packages/cli-rs/crates/tui/src/routes/home.rs @@ -23,7 +23,7 @@ pub struct Home { /// The global form overlay (E.9b); while it holds the keys, the prompt must not take the focus. forms: crate::features::home_forms::HomeForms, /// The `home.footer` slot's plugin claims (P.1). - slot: opencode_plugin::SlotMount, + slot: std::rc::Rc>, } impl Home { @@ -59,18 +59,19 @@ impl Home { .child(&composer) .child(&spacer()); let footer = View::new(app).column().width(Length::percent(100.0)).shrink(0.0); - let slot = crate::plugins::host::slot( + let slot = std::rc::Rc::new(crate::plugins::host::slot( tui, &footer, opencode_plugin::slot::HOME_FOOTER, &Default::default(), // The footer itself is the `opencode.home.footer` plugin's claim (E.14). |_| {}, - ); + )); let root = View::new(app).column().grow(1.0).basis(Length::Auto).min_height(0).child(¢er).child(&footer); let forms = crate::features::home_forms::mount(tui, &root); - // The logo and the side padding follow the terminal size (`useTerminalDimensions`). + // The logo, the side padding and the footer follow the terminal size (`useTerminalDimensions`). let size = std::cell::Cell::new((width, height)); + let footer_slot = slot.clone(); root.on_resize(bind!(tui, |tui, cx, _| { let (width, height) = cx.app().size(); if size.replace((width, height)) == (width, height) { @@ -78,6 +79,7 @@ impl Home { } center.padding_x(padding(width)); paint_logo(&tui.app, &logo, &tui.page(), width, height); + footer_slot.sync(&tui, &Changes::everything()); tui.app.request_redraw(); })); Self { root, prompt, forms, slot } diff --git a/packages/cli-rs/crates/tui/src/routes/session/sidebar.rs b/packages/cli-rs/crates/tui/src/routes/session/sidebar.rs index 3771f181b51..f904e5d7fe6 100644 --- a/packages/cli-rs/crates/tui/src/routes/session/sidebar.rs +++ b/packages/cli-rs/crates/tui/src/routes/session/sidebar.rs @@ -120,8 +120,15 @@ pub fn mount(tui: &Tui, row: &View, session: &SessionID) -> Sidebar { let (Some(inner), Some(tui)) = (weak.upgrade(), owner.upgrade()) else { return; }; - if size.replace(cx.app().size()) != cx.app().size() { - inner.layout(&tui); + if size.replace(cx.app().size()) == cx.app().size() { + return; + } + inner.layout(&tui); + // The footer's card follows the terminal height. + if inner.placed.get() != Place::Hidden + && let Some(footer) = inner.slots.borrow().last() + { + footer.sync(&tui, &Changes::everything()); } }); tui.settings.on_change(bind!(inner, |inner, tui| inner.layout(tui))); diff --git a/packages/cli-rs/crates/tui/tests/all/main.rs b/packages/cli-rs/crates/tui/tests/all/main.rs index 32f0acb4b02..d7b4e656ab4 100644 --- a/packages/cli-rs/crates/tui/tests/all/main.rs +++ b/packages/cli-rs/crates/tui/tests/all/main.rs @@ -28,6 +28,7 @@ mod plugins; mod prompt; mod queue; mod reconnect; +mod resize; mod review_ms1; mod review_ms34; mod root; diff --git a/packages/cli-rs/crates/tui/tests/all/resize.rs b/packages/cli-rs/crates/tui/tests/all/resize.rs new file mode 100644 index 00000000000..2b84b6644a6 --- /dev/null +++ b/packages/cli-rs/crates/tui/tests/all/resize.rs @@ -0,0 +1,49 @@ +//! Footers that follow the terminal size (`useTerminalDimensions`) refresh on a resize alone, with no store +//! change: the home footer, the prompt footer and the sidebar footer. + +use crate::support::{self, DIRECTORY, Fake, Harness}; + +use std::sync::Arc; + +use opencode_tui::{Deps, Route}; + +fn mount(width: u16, height: u16, route: Route) -> Harness { + let fake = Fake::new(|seen| support::base(seen, DIRECTORY)); + support::mount( + width, + height, + Deps { config: support::config(support::still()), route, ..support::deps(Arc::new(fake)) }, + ) +} + +#[test] +fn the_home_footer_hides_when_the_terminal_gets_short() { + let h = mount(80, 20, Route::Home); + h.wait_for("ctrl+p commands"); + h.wait_for("Model M"); + h.app.advance(std::time::Duration::from_millis(10)); + let screen = h.app.text(); + assert!(screen.lines().last().unwrap().trim_end().ends_with("local"), "{screen}"); + h.app.resize(80, 12); + h.wait("the home footer stays below 14 rows", || !h.app.text().contains("local")); +} + +#[test] +fn the_prompt_footer_shows_its_hints_when_the_terminal_gets_wide() { + let h = mount(36, 20, Route::Home); + h.wait_for("Model M"); + h.app.advance(std::time::Duration::from_millis(10)); + assert!(!h.app.text().contains("ctrl+p commands"), "{}", h.app.text()); + h.app.resize(80, 20); + h.wait_for("ctrl+p commands"); +} + +#[test] +fn the_sidebar_card_shows_when_the_terminal_gets_tall() { + let h = mount(140, 18, support::session_route("ses_a")); + h.wait_for("Fix the parser"); + h.app.advance(std::time::Duration::from_millis(10)); + assert!(!h.app.text().contains("Getting started"), "{}", h.app.text()); + h.app.resize(140, 26); + h.wait_for("Getting started"); +}