fix(cli): start a split or new tab's shell at the size the GUI will draw it

`tty7 split` spawned every shell at a stand-in 120x30, and the GUI resized
it a moment later. Whatever an agent sent straight away — the README's
orchestration loop does exactly that — ran at the wrong width, and the
resize started a new scrollback segment, so `tty7 capture` printed only
the prompt and none of the command's output.

The CLI now reads the parent pane's size from the server and spawns the
new pane at its share of it; `tab new`, `new` and `worktree new` add
the active tab's layout back up. An unreadable pane leaves the size to
the server, as before.
This commit is contained in:
l0ng-ai
2026-10-03 03:51:57 +02:00
parent e38f450d1e
commit 779e727044
3 changed files with 218 additions and 11 deletions
+31 -3
View File
@@ -34,7 +34,21 @@ pub trait Backend {
fn hello(&mut self) -> Result<ControlHelloOk>;
fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option<String>) -> Result<u64>;
/// Start a shell for `workspace`. `size` is the grid the GUI is going to
/// draw it at, when the caller can work that out: a shell spawned at the
/// wrong size runs whatever it is sent first at that size, and the resize
/// the GUI sends a moment later starts a new scrollback segment, so
/// `capture` would no longer show it.
fn spawn_shell(
&mut self,
workspace: WorkspaceId,
cwd: Option<String>,
size: Option<WinSize>,
) -> Result<u64>;
/// The pane's grid as the server last sized it, or `None` when the pane
/// cannot be read. Best effort: only ever used to size a new pane.
fn pane_size(&mut self, pane: u64) -> Option<WinSize>;
fn send_input(&mut self, pane: u64, bytes: Vec<u8>) -> Result<()>;
@@ -86,7 +100,7 @@ pub mod mock {
use tty7_core::daemon::control::{
CONTROL_VERSION, ControlEvent, ControlHelloOk, ControlRequest, ReplyOk, feature,
};
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs};
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs, WinSize};
use super::{Backend, CaptureSegment, RunSpec};
use crate::exec::{ExecEnd, ExecRun};
@@ -97,6 +111,8 @@ pub mod mock {
pub control_calls: Vec<ControlRequest>,
pub spawned: Vec<(WorkspaceId, Option<String>)>,
pub next_spawn_id: u64,
pub spawned_sizes: Vec<Option<WinSize>>,
pub pane_sizes: std::collections::HashMap<u64, WinSize>,
pub sent: Vec<(u64, Vec<u8>)>,
pub captured: Vec<(u64, bool)>,
pub capture_segments: Vec<CaptureSegment>,
@@ -127,6 +143,8 @@ pub mod mock {
control_calls: Vec::new(),
spawned: Vec::new(),
next_spawn_id: 6,
spawned_sizes: Vec::new(),
pane_sizes: std::collections::HashMap::new(),
sent: Vec::new(),
captured: Vec::new(),
capture_segments: Vec::new(),
@@ -194,13 +212,23 @@ pub mod mock {
})
}
fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option<String>) -> Result<u64> {
fn spawn_shell(
&mut self,
workspace: WorkspaceId,
cwd: Option<String>,
size: Option<WinSize>,
) -> Result<u64> {
self.spawned.push((workspace, cwd));
self.spawned_sizes.push(size);
let id = self.next_spawn_id;
self.next_spawn_id += 1;
Ok(id)
}
fn pane_size(&mut self, pane: u64) -> Option<WinSize> {
self.pane_sizes.get(&pane).copied()
}
fn send_input(&mut self, pane: u64, bytes: Vec<u8>) -> Result<()> {
self.sent.push((pane, bytes));
Ok(())
+35 -2
View File
@@ -21,6 +21,10 @@ const SESSION_SIZE: WinSize = WinSize {
cell_h: 16,
};
/// Reading a pane's size for a split must not hold the split up: a server
/// that is slow to answer gets the stand-in size, as before this was asked.
const PANE_SIZE_FIRST_WAIT: Duration = Duration::from_secs(2);
const PANE_SIZE_SETTLE: Duration = Duration::from_millis(100);
const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10);
const REPLAY_SETTLE: Duration = Duration::from_millis(300);
/// A backstop on reading the replay: it ends on the first live output or a
@@ -133,13 +137,18 @@ impl Backend for RealBackend {
Ok(self.control_client()?.hello().clone())
}
fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option<String>) -> Result<u64> {
fn spawn_shell(
&mut self,
workspace: WorkspaceId,
cwd: Option<String>,
size: Option<WinSize>,
) -> Result<u64> {
let workspace = workspace.to_string();
let session = self
.pane_client()?
.spawn(
cwd.map(PathBuf::from),
SESSION_SIZE,
size.unwrap_or(SESSION_SIZE),
None,
Some(workspace.clone()),
Some(workspace),
@@ -150,6 +159,30 @@ impl Backend for RealBackend {
Ok(pane)
}
fn pane_size(&mut self, pane: u64) -> Option<WinSize> {
let mut session = self.pane_client().ok()?.observe(pane, SESSION_SIZE).ok()?;
// The ring replays as `Size`, `Snapshot` pairs, oldest first, and a
// segment only starts on a resize — so the last `Size` before anything
// else arrives is the size the pane has now. The replay is queued in
// one go, which is why a short quiet spell is enough to call it over.
let _ = session.set_recv_timeout(Some(PANE_SIZE_FIRST_WAIT));
let mut size = None;
loop {
match session.recv() {
Ok(DaemonMsg::Size(seen)) => {
size = Some(seen);
let _ = session.set_recv_timeout(Some(PANE_SIZE_SETTLE));
}
Ok(DaemonMsg::Snapshot(_)) => {
let _ = session.set_recv_timeout(Some(PANE_SIZE_SETTLE));
}
_ => break,
}
}
let _ = session.detach();
size.filter(|s| s.cols > 0 && s.rows > 0)
}
fn send_input(&mut self, pane: u64, bytes: Vec<u8>) -> Result<()> {
self.pane_client()?
.send_input(pane, &bytes)
+152 -6
View File
@@ -2,11 +2,11 @@ use anyhow::{Context as _, Result, bail};
use serde_json::{Value, json};
use std::time::Duration;
use tty7_core::core::agent_hooks::{HookAgent, HooksState};
use tty7_core::core::machine::{Axis, Machine, PaneSeed, Workspace};
use tty7_core::core::machine::{Axis, Machine, PaneNode, PaneSeed, Workspace};
use tty7_core::core::session::WorkspaceId;
use tty7_core::core::tab_view::tab_views_of;
use tty7_core::daemon::control::{CONTROL_VERSION, ControlEvent, ControlRequest, ReplyOk};
use tty7_core::daemon::protocol::PROTOCOL_VERSION;
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, WinSize};
use crate::address::{self, Context, WorkspaceAddress};
use crate::backend::{Backend, RunSpec};
@@ -376,6 +376,9 @@ fn ws_detach(ws: &str, backend: &mut dyn Backend) -> Result<Outcome> {
}
fn new_workspace(path: Option<String>, open: bool, backend: &mut dyn Backend) -> Result<Outcome> {
let size = fetch_machine(backend)
.ok()
.and_then(|machine| last_tab_grid(&machine, backend));
let ws = match backend.control(ControlRequest::WorkspaceCreate {
name: None,
workspace: None,
@@ -383,7 +386,7 @@ fn new_workspace(path: Option<String>, open: bool, backend: &mut dyn Backend) ->
ReplyOk::WorkspaceTree(ws) => *ws,
other => bail!("the server answered WorkspaceCreate with {other:?}"),
};
let pane = backend.spawn_shell(ws.id, path.clone())?;
let pane = backend.spawn_shell(ws.id, path.clone(), size)?;
backend.control(ControlRequest::TabCreate {
workspace: ws.id,
at: None,
@@ -509,7 +512,10 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu
} else {
Axis::Vertical
};
let new = backend.spawn_shell(workspace, cwd.clone())?;
let size = backend
.pane_size(pane)
.map(|parent| split_share(parent, axis, args.ratio));
let new = backend.spawn_shell(workspace, cwd.clone(), size)?;
backend.control(ControlRequest::PaneSplit {
workspace,
pane,
@@ -527,6 +533,71 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu
report(format!("%{new}"), json!({ "pane": new }))
}
/// The grid a pane split off `parent` will be drawn at: the new pane takes
/// the share the existing one does not keep, less a cell for the divider
/// between them. Close to what the GUI lays out, which is what matters — the
/// GUI's own resize then moves it by a cell at most, instead of reflowing
/// every line a shell spawned at a stand-in size has already printed.
fn split_share(parent: WinSize, axis: Axis, ratio: f32) -> WinSize {
let share = |n: u16| {
let kept = ratio.clamp(0.05, 0.95);
((f32::from(n.saturating_sub(1)) * (1. - kept)).floor() as u16).max(1)
};
match axis {
Axis::Horizontal => WinSize {
cols: share(parent.cols),
..parent
},
Axis::Vertical => WinSize {
rows: share(parent.rows),
..parent
},
}
}
/// The grid a whole tab of `ws` is drawn at, so a new tab's shell can start at
/// it: the active tab's layout added back up from its panes. `None` when no
/// pane in it can be read — the caller falls back to the server's default.
fn tab_grid(ws: &Workspace, backend: &mut dyn Backend) -> Option<WinSize> {
fn whole(node: &PaneNode, backend: &mut dyn Backend) -> Option<WinSize> {
match node {
PaneNode::Leaf { pane } => backend.pane_size(*pane),
PaneNode::Split { axis, a, b, .. } => {
let (a, b) = (whole(a, backend)?, whole(b, backend)?);
Some(match axis {
Axis::Horizontal => WinSize {
cols: a.cols.saturating_add(b.cols).saturating_add(1),
rows: a.rows.max(b.rows),
..a
},
Axis::Vertical => WinSize {
rows: a.rows.saturating_add(b.rows).saturating_add(1),
cols: a.cols.max(b.cols),
..a
},
})
}
}
}
let tab = ws
.active_tab
.and_then(|id| ws.tabs.iter().find(|t| t.id == id))
.or_else(|| ws.tabs.first())?;
whole(&tab.root, backend)
}
/// [`tab_grid`] for whichever workspace was used last — a brand-new
/// workspace has no tab of its own to measure, and it opens in the same
/// window size the last one was shown in.
fn last_tab_grid(machine: &Machine, backend: &mut dyn Backend) -> Option<WinSize> {
let ws = machine
.workspaces
.iter()
.filter(|ws| !ws.tabs.is_empty())
.max_by_key(|ws| ws.last_active)?;
tab_grid(ws, backend)
}
fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
// `--enter` is the same thing as `--key enter`, and predates it. Keeping it
// as sugar rather than deprecating it: it reads better for the overwhelming
@@ -1019,7 +1090,12 @@ fn tab_new(
Some(spec) => adopt_pane(spec, explicit, cwd, ctx, &machine, backend)?,
None => {
let id = resolve_ws(explicit, ctx, &machine)?;
let pane = backend.spawn_shell(id, cwd.clone())?;
let size = machine
.workspaces
.iter()
.find(|ws| ws.id == id)
.and_then(|ws| tab_grid(ws, backend));
let pane = backend.spawn_shell(id, cwd.clone(), size)?;
(id, pane, cwd)
}
};
@@ -1218,7 +1294,12 @@ fn worktree_new(args: WorktreeNew, ctx: &Context, backend: &mut dyn Backend) ->
);
let cwd = wt.path.to_string_lossy().into_owned();
let pane = backend.spawn_shell(ws, Some(cwd.clone()))?;
let size = machine
.workspaces
.iter()
.find(|w| w.id == ws)
.and_then(|w| tab_grid(w, backend));
let pane = backend.spawn_shell(ws, Some(cwd.clone()), size)?;
let tab = match backend.control(ControlRequest::TabCreate {
workspace: ws,
at: None,
@@ -2635,6 +2716,7 @@ mod tests {
assert_eq!(
backend.control_calls,
vec![
ControlRequest::MachineGet,
ControlRequest::WorkspaceCreate {
name: None,
workspace: None,
@@ -3118,6 +3200,70 @@ mod tests {
);
}
fn grid(cols: u16, rows: u16) -> WinSize {
WinSize {
cols,
rows,
cell_w: 9,
cell_h: 21,
}
}
/// A shell spawned at a stand-in 120x30 runs whatever `send` gives it at
/// that width, then the GUI's resize starts a new scrollback segment and
/// `capture` shows none of it. So the new pane starts at its share of the
/// pane it was split off.
#[test]
fn pane_split_spawns_the_new_shell_at_its_share_of_the_parent() {
let mut backend = mock();
backend.pane_sizes.insert(2, grid(128, 40));
run_cli(
&["tty7", "pane", "split", "%2", "--h"],
&Context::default(),
&mut backend,
);
run_cli(
&["tty7", "pane", "split", "%2", "--v", "--ratio", "0.3"],
&Context::default(),
&mut backend,
);
assert_eq!(
backend.spawned_sizes,
vec![Some(grid(63, 40)), Some(grid(128, 27))],
"side by side halves the columns, stacked gives the new pane the 70% \
the existing one does not keep — each less a cell for the divider"
);
}
#[test]
fn pane_split_of_an_unreadable_pane_leaves_the_size_to_the_server() {
let mut backend = mock();
run_cli(
&["tty7", "pane", "split", "%2", "--h"],
&Context::default(),
&mut backend,
);
assert_eq!(backend.spawned_sizes, vec![None]);
}
#[test]
fn tab_new_spawns_at_the_size_of_the_workspaces_tab_added_back_up() {
let mut backend = mock();
let api = backend.machine.workspaces[0].clone();
backend.machine.workspaces[0].active_tab = Some(api.tabs[1].id);
backend.pane_sizes.insert(2, grid(63, 40));
backend.pane_sizes.insert(3, grid(64, 40));
backend
.replies
.push_back(ReplyOk::TabTree(Box::new(Tab::leaf(6))));
run_cli(
&["tty7", "tab", "new", &api.id.to_string()],
&Context::default(),
&mut backend,
);
assert_eq!(backend.spawned_sizes, vec![Some(grid(128, 40))]);
}
#[test]
fn split_without_an_address_uses_the_pane_from_the_environment() {
let mut backend = mock();