fix(cli-rs): refresh the home, prompt and sidebar footers on a resize

This commit is contained in:
Simon Klee
2026-11-06 06:38:53 +01:00
Unverified
parent 49f93667a0
commit 0ddcfc2792
6 changed files with 91 additions and 20 deletions
@@ -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<Tui> 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);
@@ -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 {
@@ -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<Tui>,
slot: std::rc::Rc<opencode_plugin::SlotMount<Tui>>,
}
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(&center).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 }
@@ -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)));
@@ -28,6 +28,7 @@ mod plugins;
mod prompt;
mod queue;
mod reconnect;
mod resize;
mod review_ms1;
mod review_ms34;
mod root;
@@ -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");
}