Files
tty7/crates/tty7-cli/src/output.rs
T
l0ng-ai bd0dd22bfa feat(tabs): hibernate a tab to free its memory and wake it later (#954)
A tab can be put to sleep from its context menu or the command palette:
its panes are stopped (screens kept on disk), the tab keeps its place in
the sidebar, and selecting it wakes it through the same restore a reboot
runs, resuming a supported agent's session. The sleep mark lives on the
machine tree, so it survives app and daemon restarts.

Closes #762
2026-09-25 16:41:12 +08:00

682 lines
24 KiB
Rust

use unicode_width::UnicodeWidthStr;
use tty7_core::core::machine::{Machine, PaneNode, Workspace};
use tty7_core::core::session::WorkspaceId;
use tty7_core::core::tab_view::{TabLabel, TabView, strip_host_prefix, tab_views_of};
use tty7_core::daemon::control::{PaneAgentState, RouteInfo, ServerStatus};
use tty7_core::daemon::protocol::{PaneInfo, PaneProcs, PortProbe};
use crate::resolve;
/// What to call a tab in a table or a tree. Almost no tab carries a name — the
/// GUI's strip shows the terminal's OSC title instead — so a column printing
/// `tab.name` alone comes out empty for a window full of work. The evidence
/// ranking is shared with the GUI; only the rendering is ours.
pub fn tab_label(view: &TabView) -> String {
match view.label() {
TabLabel::Named(name) => name.to_string(),
TabLabel::Osc(title) => {
let title = strip_host_prefix(title);
// A title that is only a path is what a shell integration writes,
// and it gets cut down to its leaf like a cwd — the tree prints the
// whole path underneath anyway. Prose is an agent saying what it is
// doing: that is the name, and it stays whole, clamped only so one
// talkative tab cannot widen every column in the table.
match title.starts_with(['/', '~']) {
true => path_leaf(title).to_string(),
false => clamp(title, 40),
}
}
TabLabel::Agent(agent) => agent.display_name().to_string(),
// The tree prints every pane's full cwd right underneath, and a table
// has no room for one anyway: the leaf is what tells tabs apart.
TabLabel::Cwd(cwd) => path_leaf(cwd).to_string(),
TabLabel::Process(title) => title.to_string(),
TabLabel::Unknown => "-".to_string(),
}
}
/// `max` characters at most, with an ellipsis in place of what was dropped.
fn clamp(s: &str, max: usize) -> String {
match s.chars().count() > max {
true => s.chars().take(max - 1).chain(['…']).collect(),
false => s.to_string(),
}
}
/// The last segment of a path, for columns that have room for a word and not
/// for a path. Both separators: the same server answers a Windows client, and
/// `C:\proj` has to lose its head too.
pub fn path_leaf(path: &str) -> &str {
let trimmed = path.trim_end_matches(['/', '\\']);
match trimmed.rsplit(['/', '\\']).next() {
Some(leaf) if !leaf.is_empty() => leaf,
_ => path,
}
}
/// Display columns, not bytes: a CJK path is two columns per char and three
/// bytes, so padding by `len()` would push every later column out of line.
fn width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
pub fn table(header: &[&str], rows: &[Vec<String>]) -> String {
let mut widths: Vec<usize> = header.iter().map(|h| width(h)).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(width(cell));
}
}
}
let mut out = String::new();
render_row(&mut out, &mut header.iter().map(|h| h.to_string()), &widths);
for row in rows {
render_row(&mut out, &mut row.iter().cloned(), &widths);
}
out
}
fn render_row(out: &mut String, cells: &mut dyn Iterator<Item = String>, widths: &[usize]) {
let mut line = String::new();
for (i, cell) in cells.enumerate() {
if i > 0 {
line.push_str(" ");
}
line.push_str(&cell);
let pad = widths
.get(i)
.copied()
.unwrap_or(0)
.saturating_sub(width(&cell));
line.push_str(&" ".repeat(pad));
}
out.push_str(line.trim_end());
out.push('\n');
}
pub fn workspace_table(machine: &Machine) -> String {
if machine.workspaces.is_empty() {
return "no workspaces — `tty7 new <path>` starts one\n".to_string();
}
let rows: Vec<Vec<String>> = machine
.workspaces
.iter()
.map(|ws| {
vec![
resolve::short_id(&ws.id),
ws.name.clone().unwrap_or_else(|| "-".to_string()),
ws.tabs.len().to_string(),
ws.tabs
.iter()
.map(|t| t.root.pane_ids().len())
.sum::<usize>()
.to_string(),
ws.attachment
.as_ref()
.map(|a| a.hostname.clone())
.unwrap_or_else(|| "-".to_string()),
]
})
.collect();
table(&["WORKSPACE", "NAME", "TABS", "PANES", "ATTACHED"], &rows)
}
pub fn pane_table(machine: &Machine, only: Option<WorkspaceId>) -> String {
let mut rows = Vec::new();
for ws in &machine.workspaces {
if only.is_some_and(|id| id != ws.id) {
continue;
}
for tab in &ws.tabs {
let ordinal = resolve::ordinal_of(machine, tab.id).unwrap_or(0);
for pane in tab.root.pane_ids() {
let record = machine.panes.iter().find(|p| p.id == pane);
rows.push(vec![
format!("%{pane}"),
ws.name.clone().unwrap_or_else(|| resolve::short_id(&ws.id)),
format!("@{ordinal}"),
record
.and_then(|r| r.cwd.clone())
.unwrap_or_else(|| "-".to_string()),
// Not running, and meant not to be: a sleeping tab's panes
// are waiting for their tab to be woken, not dead.
match record {
_ if tab.hibernated => "asleep".to_string(),
Some(r) if r.live => "yes".to_string(),
Some(_) => "no".to_string(),
None => "?".to_string(),
},
]);
}
}
}
if rows.is_empty() {
return "no panes\n".to_string();
}
table(&["PANE", "WS", "TAB", "CWD", "LIVE"], &rows)
}
/// The server's registry rather than the machine tree, so orphans appear. `held`
/// answers which workspace holds a pane, if any; a pane with no holder is shown
/// as `-` under WS, which is the whole point of the listing.
///
/// OWNER names the workspace allowed to attach to the pane, and for a pane in
/// its own workspace's tree that is the WS beside it — so the column only
/// speaks when the two disagree, which is the case worth reading: an orphan
/// still remembering where it belongs, or a pane the holder cannot attach to.
pub fn registry_table(panes: &[PaneInfo], held: &dyn Fn(u64) -> Option<String>) -> String {
if panes.is_empty() {
return "no panes\n".to_string();
}
let short = |id: &str| -> String { id.chars().take(8).collect() };
let rows: Vec<Vec<String>> = panes
.iter()
.map(|info| {
let holder = held(info.pane_id);
let owner = match (&info.owner, &holder) {
(None, _) => "-".to_string(),
(Some(owner), Some(holder)) if owner == holder => "-".to_string(),
(Some(owner), _) => short(owner),
};
vec![
format!("%{}", info.pane_id),
holder
.as_deref()
.map(short)
.unwrap_or_else(|| "-".to_string()),
owner,
info.cwd
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "-".to_string()),
if info.alive { "yes" } else { "no" }.to_string(),
]
})
.collect();
table(&["PANE", "WS", "OWNER", "CWD", "LIVE"], &rows)
}
pub fn workspace_tree(ws: &Workspace, machine: &Machine) -> String {
let mut out = format!(
"{} ({})\n",
ws.name.as_deref().unwrap_or("-"),
resolve::short_id(&ws.id)
);
for (tab, view) in ws.tabs.iter().zip(tab_views_of(ws, &machine.panes)) {
let ordinal = resolve::ordinal_of(machine, tab.id).unwrap_or(0);
match view.label() {
TabLabel::Unknown => out.push_str(&format!(" @{ordinal}\n")),
_ => out.push_str(&format!(" @{ordinal} {}\n", tab_label(&view))),
}
render_node(&mut out, &tab.root, machine, 2);
}
out
}
fn render_node(out: &mut String, node: &PaneNode, machine: &Machine, depth: usize) {
let indent = " ".repeat(depth);
match node {
PaneNode::Leaf { pane } => {
let cwd = machine
.panes
.iter()
.find(|p| p.id == *pane)
.and_then(|p| p.cwd.clone());
match cwd {
Some(cwd) => out.push_str(&format!("{indent}%{pane} {cwd}\n")),
None => out.push_str(&format!("{indent}%{pane}\n")),
}
}
PaneNode::Split { axis, ratio, a, b } => {
let axis = match axis {
tty7_core::core::machine::Axis::Horizontal => "h",
tty7_core::core::machine::Axis::Vertical => "v",
};
out.push_str(&format!("{indent}{axis} {:.0}%\n", ratio * 100.0));
render_node(out, a, machine, depth + 1);
render_node(out, b, machine, depth + 1);
}
}
}
/// What the port probe has to say for itself, when it has something to say.
///
/// This is the line that makes `tty7 procs` a diagnostic rather than another
/// place to read an empty PORTS table. An empty table means "nothing is
/// listening" only when the probe actually ran, and until it said so there was
/// no way — from a screenshot, from a bug report, from the JSON — to tell that
/// case from a probe that never got off the ground.
fn probe_note(probe: &PortProbe) -> Option<String> {
match probe {
PortProbe::Ok => None,
PortProbe::Restricted => Some(
"note: some processes here belong to another user; a probe running as you \
cannot see their sockets"
.to_string(),
),
PortProbe::Unavailable(detail) => Some(format!(
"note: could not check for listening ports ({detail})"
)),
}
}
pub fn procs_tables(procs: &PaneProcs) -> String {
let note = probe_note(&procs.probe);
if procs.procs.is_empty() && procs.ports.is_empty() {
return match note {
Some(note) => format!("nothing running in this pane\n{note}\n"),
None => "nothing running in this pane\n".to_string(),
};
}
let rows: Vec<Vec<String>> = procs
.procs
.iter()
.map(|p| {
vec![
p.pid.to_string(),
format!("{}{}", " ".repeat(p.depth as usize), p.name),
if p.foreground { "*" } else { "" }.to_string(),
]
})
.collect();
let mut out = table(&["PID", "NAME", "FG"], &rows);
if !procs.ports.is_empty() {
out.push('\n');
let rows: Vec<Vec<String>> = procs
.ports
.iter()
.map(|p| vec![p.port.to_string(), p.pid.to_string(), p.name.clone()])
.collect();
out.push_str(&table(&["PORT", "PID", "NAME"], &rows));
}
if let Some(note) = note {
out.push('\n');
out.push_str(&note);
out.push('\n');
}
out
}
pub fn agents_table(states: &[PaneAgentState]) -> String {
if states.is_empty() {
return "no agents running\n".to_string();
}
let rows: Vec<Vec<String>> = states
.iter()
.map(|s| {
vec![
format!("%{}", s.pane_id),
s.agent
.map(|a| format!("{a:?}").to_lowercase())
.unwrap_or_else(|| "-".to_string()),
format!("{:?}", s.state.status).to_lowercase(),
s.state.message.clone().unwrap_or_else(|| "-".to_string()),
]
})
.collect();
table(&["PANE", "AGENT", "STATUS", "MESSAGE"], &rows)
}
pub fn status_lines(status: &ServerStatus) -> String {
let rows = vec![
vec!["pid".to_string(), status.pid.to_string()],
vec!["uptime".to_string(), format!("{}s", status.uptime_secs)],
vec!["panes".to_string(), status.panes.to_string()],
vec![
"dialect".to_string(),
format!(
"control v{}, protocol v{}",
status.control_version, status.protocol_version
),
],
vec!["build".to_string(), status.build.clone()],
vec!["socket".to_string(), status.socket.clone()],
];
table(&["SERVER", ""], &rows)
}
pub fn routes_table(routes: &[RouteInfo]) -> String {
let mut rows = vec![vec![
"local".to_string(),
"local".to_string(),
"yes".to_string(),
]];
rows.extend(routes.iter().map(|r| {
vec![
r.key.clone(),
r.kind.clone(),
if r.connected { "yes" } else { "no" }.to_string(),
]
}));
table(&["MACHINE", "KIND", "CONNECTED"], &rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testbed::two_workspace_machine;
use tty7_core::daemon::protocol::{PortEntry, ProcEntry};
#[test]
fn columns_line_up_and_lines_carry_no_trailing_spaces() {
let rendered = table(
&["PANE", "CWD"],
&[
vec!["%1".into(), "C:\\proj".into()],
vec!["%12345".into(), "C:\\".into()],
],
);
assert_eq!(rendered, "PANE CWD\n%1 C:\\proj\n%12345 C:\\\n");
assert!(
rendered.lines().all(|l| l == l.trim_end()),
"trailing spaces break naive downstream parsing"
);
}
#[test]
fn owner_speaks_only_when_it_disagrees_with_the_workspace_holding_the_pane() {
let held = "9fd8072f-465c-4016-9a81-8143bff1240c";
let elsewhere = "76698a44-3f13-4961-8fed-90d0b3defff1";
let pane = |id: u64, owner: Option<&str>| PaneInfo {
pane_id: id,
cwd: None,
title: "zsh".into(),
osc_title: None,
alive: true,
owner: owner.map(str::to_string),
};
let rendered = registry_table(
&[
pane(1, Some(held)),
pane(2, Some(elsewhere)),
pane(3, None),
pane(4, Some(elsewhere)),
],
&|id| (id != 4).then(|| held.to_string()),
);
let owner_of = |pane: &str| -> String {
rendered
.lines()
.find(|line| line.starts_with(pane))
.unwrap_or_else(|| panic!("{pane} is listed: {rendered}"))
.split_whitespace()
.nth(2)
.expect("PANE WS OWNER")
.to_string()
};
assert_eq!(
owner_of("%1"),
"-",
"repeating the WS beside it says nothing"
);
assert_eq!(
owner_of("%2"),
"76698a44",
"a holder that may not attach is the whole reason to look"
);
assert_eq!(owner_of("%3"), "-", "nobody claims it");
assert_eq!(
owner_of("%4"),
"76698a44",
"an orphan still remembers where it belongs"
);
}
#[test]
fn wide_characters_are_padded_by_display_width_not_byte_length() {
// "项目" is 6 bytes but occupies 4 columns. Padding by len() would add
// 2 spaces too few and skew every column after it.
let rendered = table(
&["NAME", "TABS"],
&[
vec!["项目".into(), "3".into()],
vec!["api".into(), "1".into()],
],
);
// Column one is 4 wide: "NAME" and "项目" both fill it exactly, "api"
// gets one pad space. Sizing by len() would make it 6 (项目's byte
// count) and push the header's second column two places right.
assert_eq!(rendered, "NAME TABS\n项目 3\napi 1\n");
// The second column starts at the same display offset on every line.
let second_column_at: Vec<usize> = rendered
.lines()
.map(|line| {
let split = line.rfind(' ').expect("two columns per line") + 1;
UnicodeWidthStr::width(&line[..split])
})
.collect();
assert!(
second_column_at.iter().all(|c| *c == second_column_at[0]),
"columns must line up on screen: {rendered:?} gave {second_column_at:?}"
);
}
#[test]
fn the_workspace_table_is_one_line_per_workspace() {
let m = two_workspace_machine();
let rendered = workspace_table(&m);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines.len(), 3, "header plus two workspaces:\n{rendered}");
assert!(lines[0].starts_with("WORKSPACE"));
assert!(lines[1].contains("api"), "{rendered}");
assert!(lines[1].contains('3'), "api holds three panes: {rendered}");
assert!(lines[2].contains("web"), "{rendered}");
}
#[test]
fn an_empty_machine_says_how_to_start() {
let rendered = workspace_table(&Machine::default());
assert!(rendered.contains("tty7 new"), "{rendered}");
}
#[test]
fn the_pane_table_addresses_panes_and_tabs_the_way_commands_take_them() {
let m = two_workspace_machine();
let rendered = pane_table(&m, None);
assert!(rendered.contains("%1"), "{rendered}");
assert!(rendered.contains("%5"), "{rendered}");
assert!(
rendered.contains("@3"),
"web's tab keeps its machine-wide number: {rendered}"
);
let web_only = pane_table(&m, Some(m.workspaces[1].id));
assert!(!web_only.contains("%1"), "{web_only}");
assert!(web_only.contains("%5"), "{web_only}");
}
#[test]
fn the_tree_shows_tabs_splits_and_cwds_by_indentation() {
let m = two_workspace_machine();
let rendered = workspace_tree(&m.workspaces[0], &m);
// @1 was named; @2 was not, so it borrows the leaf of its cwd rather
// than printing nothing at all.
let expected = format!(
"api ({})\n @1 build\n %1 C:\\proj\n @2 proj\n h 50%\n %2 C:\\proj\n %3 C:\\proj\\sub\n",
crate::resolve::short_id(&m.workspaces[0].id)
);
assert_eq!(rendered, expected);
}
#[test]
fn an_unnamed_tab_borrows_its_title_then_an_agent_then_a_place_then_its_process() {
let view = |f: &dyn Fn(&mut TabView)| {
let mut v = TabView {
id: tty7_core::core::machine::TabId::new(),
name: None,
title: String::new(),
osc_title: None,
cwd: None,
agent: None,
status: None,
live: true,
panes: 1,
};
f(&mut v);
v
};
assert_eq!(
tab_label(&view(&|v| v.name = Some("deploy".into()))),
"deploy"
);
assert_eq!(
tab_label(&view(
&|v| v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude)
)),
"Claude Code"
);
// What the pane's own terminal says it is doing beats naming the agent
// running it — every tab of a workspace would otherwise read alike.
// The mark the agent writes in front of that title comes off here too:
// `tab_label` reads `TabView::label`, so the table says what the tab
// strip says without either being told about the other.
assert_eq!(
tab_label(&view(&|v| {
v.osc_title = Some("✳ fixing the switcher".into());
v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude);
})),
"fixing the switcher"
);
assert_eq!(
tab_label(&view(
&|v| v.osc_title = Some("user@host:~/repo/tty7".into())
)),
"tty7",
"a shell's title is a path and is cut down like one"
);
let long = "wondering ".repeat(8);
let clamped = tab_label(&view(&|v| v.osc_title = Some(long.clone())));
assert_eq!(clamped.chars().count(), 40);
assert!(clamped.ends_with('…'));
assert_eq!(
tab_label(&view(&|v| v.cwd = Some("/Users/me/repo/tty7".into()))),
"tty7"
);
assert_eq!(
tab_label(&view(&|v| v.cwd = Some("C:\\proj\\sub\\".into()))),
"sub",
"a Windows path loses its head and its trailing separator"
);
assert_eq!(tab_label(&view(&|v| v.cwd = Some("/".into()))), "/");
assert_eq!(tab_label(&view(&|v| v.title = "zsh".into())), "zsh");
assert_eq!(tab_label(&view(&|_| {})), "-");
}
#[test]
fn procs_render_as_a_process_tree_plus_ports() {
let procs = PaneProcs {
procs: vec![
ProcEntry {
pid: 100,
name: "pwsh".into(),
depth: 0,
foreground: false,
},
ProcEntry {
pid: 200,
name: "cargo".into(),
depth: 1,
foreground: true,
},
],
ports: vec![PortEntry {
port: 3000,
pid: 200,
addr: "*".into(),
name: "node".into(),
}],
probe: PortProbe::Ok,
context: None,
};
let rendered = procs_tables(&procs);
assert!(
rendered.contains(" cargo"),
"children are indented: {rendered}"
);
assert!(
rendered.contains('*'),
"the foreground process is marked: {rendered}"
);
assert!(rendered.contains("3000"), "{rendered}");
assert!(
!rendered.contains("note:"),
"a probe that worked says nothing: {rendered}"
);
}
/// #731's diagnostic. Someone whose ports are missing runs `tty7 procs`
/// and pastes what it says; an empty PORTS table is only worth pasting if
/// it distinguishes a quiet pane from a probe that never ran.
#[test]
fn a_probe_that_could_not_run_says_so_next_to_the_empty_table() {
let mut procs = PaneProcs {
procs: vec![ProcEntry {
pid: 100,
name: "zsh".into(),
depth: 0,
foreground: true,
}],
ports: Vec::new(),
probe: PortProbe::Unavailable("lsof: program not found".into()),
context: None,
};
let rendered = procs_tables(&procs);
assert!(
rendered.contains("could not check for listening ports"),
"{rendered}"
);
assert!(
rendered.contains("lsof: program not found"),
"the reason is the whole point of the line: {rendered}"
);
procs.probe = PortProbe::Restricted;
assert!(
procs_tables(&procs).contains("another user"),
"{}",
procs_tables(&procs)
);
// And on a pane with nothing running at all, where the tables are not
// drawn, the note still has to come out.
let empty = PaneProcs {
probe: PortProbe::Unavailable("lsof: program not found".into()),
..Default::default()
};
assert!(
procs_tables(&empty).contains("could not check"),
"{}",
procs_tables(&empty)
);
}
/// A sleeping tab's panes are not running and not dead either: the table
/// says which, so nobody reads a tab put to sleep as one that crashed.
#[test]
fn the_pane_table_calls_a_sleeping_tabs_panes_asleep() {
let mut m = two_workspace_machine();
m.workspaces[0].tabs[1].hibernated = true;
for pane in &mut m.panes {
if pane.id == 2 || pane.id == 3 {
pane.live = false;
}
}
let rendered = pane_table(&m, None);
let row = |pane: &str| {
rendered
.lines()
.find(|l| l.starts_with(pane))
.unwrap_or_else(|| panic!("no row for {pane}: {rendered}"))
.to_string()
};
assert!(row("%1 ").ends_with("yes"), "{rendered}");
assert!(row("%2 ").ends_with("asleep"), "{rendered}");
assert!(row("%3 ").ends_with("asleep"), "{rendered}");
assert!(row("%5 ").ends_with("yes"), "{rendered}");
}
}