feat(layout): fullscreen + interactive pane resizing
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run

Add a window-management layer so the chat, roster and sandbox-terminal
panes can be resized live and fullscreened, with named presets for
recall.

- New layout.rs: single source of truth for the body split (pty_pct,
  roster_width) and a Zoom state (Normal/Term/Chat), persisted to
  layouts/<slug>.toml like themes. Both ui::draw and app::sbx_dims read
  from it, so resizing just mutates state and clears announced_dims —
  the per-tick loop re-syncs and broadcasts the new PTY grid.
- F4 cycles terminal/chat fullscreen (F-keys aren't forwarded to the
  shell, so nothing is stolen from in-shell apps).
- Interactive editing: click a pane (or F5 to cycle terminal -> chat ->
  roster) to select it; arrows then resize it live, Esc/Enter finishes.
  The selected pane gets a bold accent border and an ✎ title marker.
  ui::pane_at hit-tests against the same body_areas rects draw() paints.
- /layout slimmed to presets only (save/load/list/rm/reset); the old
  numeric pty/chat/roster and full/chatfull/normal verbs are replaced by
  the interactive flow.
- Help menu updated: LAYOUT cluster + KEYS line document F4, click/F5,
  arrows and Esc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-07 12:53:44 -07:00
parent c9cdd2feca
commit 37d5916b2e
4 changed files with 597 additions and 29 deletions
+212 -6
View File
@@ -1,6 +1,7 @@
//! TUI application state, network event model, and the async run loop.
use crate::ft;
use crate::layout::Layout;
use crate::net::{self, Session};
use crate::sbx;
use crate::theme::Theme;
@@ -10,7 +11,7 @@ use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
KeyModifiers, MouseEventKind,
KeyModifiers, MouseButton, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
@@ -159,6 +160,15 @@ pub struct VboxPicker {
pub selected: usize,
}
/// A resizable region of the window, for interactive layout editing. Selected by
/// clicking it (or cycling with F5); once selected, arrow keys resize it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Chat,
Roster,
Terminal,
}
pub struct App {
pub me: String,
pub lines: Vec<ChatLine>,
@@ -211,6 +221,13 @@ pub struct App {
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
/// can revoke the right ACL entry.
pub agent_name: Option<String>,
/// Window layout: chat/terminal split, roster width, fullscreen zoom. Read
/// by `ui::draw` and `sbx_dims`; mutated by F4/F5 + interactive editing.
pub layout: Layout,
/// Interactive layout editing: the pane currently selected for resizing
/// (click it or cycle with F5), or None when not editing. When set, arrow
/// keys resize this pane instead of scrolling.
pub focused_pane: Option<Pane>,
}
impl App {
@@ -244,9 +261,25 @@ impl App {
spin: 0,
agent_sbx_allow: false,
agent_name: None,
layout: Layout::default(),
focused_pane: None,
}
}
/// Cycle the interactive-edit selection: Terminal → Chat → Roster → off.
/// Bound to F5; clicking a pane jumps straight to it instead. The Terminal
/// step is skipped when no sandbox is up (nothing to resize there).
fn cycle_focus(&mut self) {
let has_term = self.sandbox.is_some();
self.focused_pane = match self.focused_pane {
None if has_term => Some(Pane::Terminal),
None => Some(Pane::Chat),
Some(Pane::Terminal) => Some(Pane::Chat),
Some(Pane::Chat) => Some(Pane::Roster),
Some(Pane::Roster) => None,
};
}
/// Append a chat line. Holds the viewport steady if scrolled up, and caps
/// the in-memory backlog.
fn push_line(&mut self, l: ChatLine) {
@@ -463,9 +496,32 @@ impl App {
}
}
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
/// Human label for a resizable pane (used in layout-editing hints).
fn pane_label(p: Pane) -> &'static str {
match p {
Pane::Chat => "chat",
Pane::Roster => "roster",
Pane::Terminal => "terminal",
}
}
/// Join saved-preset names for display, or "none" when there are none.
fn once_or_none(names: Vec<String>) -> String {
if names.is_empty() {
"none".to_string()
} else {
names.join(" · ")
}
}
/// PTY grid (rows, cols) for the sandbox terminal. `pty_pct` is the terminal's
/// share of the body height (see `layout::Layout`); it must match the split
/// `ui::draw` paints, so they stay in lock-step via `app.layout`. Width is the
/// full body width — the terminal pane always spans the frame, only its height
/// changes with the split.
fn sbx_dims(term_w: u16, term_h: u16, pty_pct: u16) -> (u16, u16) {
let body_h = term_h.saturating_sub(4);
let sbx_h = (body_h as u32 * 55 / 100) as u16;
let sbx_h = (body_h as u32 * pty_pct as u32 / 100) as u16;
(
sbx_h.saturating_sub(2).max(1),
term_w.saturating_sub(2).max(1),
@@ -842,6 +898,10 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
let mut app = App::new(session.username.clone());
app.password = params.password.clone();
// Seed the roster width from the active vestment so a `--theme` with a wider
// roster is honoured; from here on the live layout owns it (so /theme swaps
// don't stomp a width the user has set with /layout).
app.layout.roster_width = theme.roster_width;
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(50));
@@ -863,7 +923,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
if broker.is_some() {
if let Ok(sz) = term.size() {
let dims = sbx_dims(sz.width, sz.height);
let dims = sbx_dims(sz.width, sz.height, app.layout.effective_pty_pct());
if announced_dims != Some(dims) {
announced_dims = Some(dims);
if let Some(sb) = &broker {
@@ -1043,6 +1103,64 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if k.code == KeyCode::F(4) {
// Fullscreen the terminal (cycle terminal → chat → split).
// Caught before the drive branch so it works mid-session;
// F-keys aren't forwarded to the shell anyway (key_to_pty).
if app.sandbox.is_some() {
app.layout.cycle_zoom();
announced_dims = None; // re-sync PTY to the new height
app.sys(format!("{}", app.layout.describe()));
} else {
app.sys("no sandbox to fullscreen — /sbx launch first");
}
} else if k.code == KeyCode::F(5) {
// Enter/cycle interactive layout editing: pick a pane to
// resize (Terminal → Chat → Roster → off). Clicking a pane
// selects it directly; arrows then resize it.
app.cycle_focus();
match app.focused_pane {
Some(p) => app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
)),
None => app.sys("⛧ layout editing off"),
}
} else if app.focused_pane.is_some() && !app.driving {
// Interactive layout editing: a pane is selected (via click
// or F5). Arrows resize it live; Esc/Enter finishes. Sits
// before the driving branch but is gated on !driving so the
// shell still gets its keys while you hold the PTY.
let pane = app.focused_pane.unwrap();
match (pane, k.code) {
(_, KeyCode::Esc) | (_, KeyCode::Enter) => {
app.focused_pane = None;
app.sys(format!("{}", app.layout.describe()));
}
// Terminal taller / chat shorter (and the mirror image
// when the chat pane is the one selected).
(Pane::Terminal, KeyCode::Up) | (Pane::Chat, KeyCode::Down) => {
app.layout.grow_pty(3);
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Terminal, KeyCode::Down) | (Pane::Chat, KeyCode::Up) => {
app.layout.shrink_pty(3);
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Left) => {
app.layout.roster_width =
app.layout.roster_width.saturating_sub(2);
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Right) => {
app.layout.roster_width =
(app.layout.roster_width + 2).min(Layout::MAX_ROSTER);
app.sys(format!("{}", app.layout.describe()));
}
_ => {} // ignore other keys while editing
}
} else if app.driving {
// Esc is NOT a release key here — vim & friends need it,
// so it's forwarded to the PTY like any other key (see
@@ -1127,6 +1245,27 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
app.chat_scroll = app.chat_scroll.saturating_sub(3);
}
}
// Left-click a pane to select it for interactive resize
// (then arrows adjust it; Esc finishes). Skipped while
// driving the shell or with an overlay up, so clicks there
// don't hijack the terminal/help/picker.
MouseEventKind::Down(MouseButton::Left)
if !app.driving && !app.show_help && app.vbox_picker.is_none() =>
{
if let Ok(sz) = term.size() {
if let Some(p) =
ui::pane_at(sz.width, sz.height, &app, m.column, m.row)
{
app.focused_pane = Some(p);
app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
));
} else {
app.focused_pane = None;
}
}
}
_ => {}
}
}
@@ -1516,6 +1655,73 @@ fn handle_command(
)),
}
}
} else if let Some(rest) = line.strip_prefix("/layout") {
// Named layout presets only — live resizing is interactive now (F4
// fullscreen, F5 or click a pane then arrows). `/layout` keeps just the
// memorable verbs for saving/recalling arrangements, plus `reset`.
// Loading a preset clears announced_dims so the run loop re-syncs (and
// broadcasts) the live PTY size on the next tick.
let rest = rest.trim();
let (cmd, arg) = rest
.split_once(char::is_whitespace)
.map(|(c, a)| (c, a.trim()))
.unwrap_or((rest, ""));
let mut resized = false;
match cmd {
"" => {
app.sys(format!("⛧ layout: {}", app.layout.describe()));
app.sys(" resize live: F4 fullscreen · F5 or click a pane, then arrows · Esc done");
app.sys(format!(
" presets: {} — /layout save <name> · load <name> · rm <name>",
once_or_none(Layout::available())
));
}
"reset" | "default" => {
let roster = app.layout.roster_width;
app.layout = Layout::default();
app.layout.roster_width = roster; // keep your roster choice on reset
resized = true;
app.sys(format!("⛧ layout reset — {}", app.layout.describe()));
}
"save" if !arg.is_empty() => match app.layout.save(arg) {
Ok(slug) => app.sys(format!(
"⛧ saved layout '{slug}' — re-apply anytime with /layout load {slug}"
)),
Err(e) => app.err(format!("couldn't save layout: {e}")),
},
"load" | "apply" if !arg.is_empty() => match Layout::by_name(arg) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("⛧ loaded layout '{arg}' — {}", app.layout.describe()));
}
Err(_) => app.err(format!(
"no saved layout '{arg}' — saved: {}",
once_or_none(Layout::available())
)),
},
"list" | "presets" | "ls" => {
app.sys(format!("⛧ saved layouts: {}", once_or_none(Layout::available())));
}
"rm" | "delete" | "del" if !arg.is_empty() => match Layout::remove(arg) {
Ok(()) => app.sys(format!("⛧ deleted layout '{arg}'")),
Err(e) => app.err(format!("{e}")),
},
// Bare `/layout <name>` → load a saved preset if it exists.
name => match Layout::by_name(name) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("⛧ loaded layout '{name}' — {}", app.layout.describe()));
}
Err(_) => app.sys(
"usage: /layout [save <name>|load <name>|list|rm <name>|reset] — resize live with F4/F5 or click",
),
},
}
if resized {
*announced_dims = None; // force the run loop to re-sync the PTY size
}
} else if line == "/drive" {
// Mobile-friendly alternative to F2 (no function key needed).
if app.sandbox.is_none() {
@@ -1656,7 +1862,7 @@ fn handle_command(
// install also leaves its daemon up).
let install_first = !installed;
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
@@ -1762,7 +1968,7 @@ fn handle_command(
// (stopped, preserved) instance and re-attaches its shell.
let label = label.to_string();
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();