Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits.

### Changed
- Headless servers now use a configurable 120×40 virtual terminal instead of 80×24 when no client is attached, giving newly created panes a practical default size. (#2828)
- Desktop tab labels are now centered in their tabs, so the active-tab highlight has symmetric padding.
- Bumped the client/server protocol version to 20 for pane terminal bell forwarding.
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.
Expand Down
12 changes: 12 additions & 0 deletions docs/next/website/src/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ You can also open the global menu in Herdr and choose `reload config`.

Reload applies most UI settings without restarting panes. Startup-only settings still need a restart.

## Headless terminal size

When no client is attached, the server uses a 120×40 virtual terminal for layout and newly created panes. Change that fallback for headless orchestration with:

```toml
[server]
headless_cols = 160
headless_rows = 50
```

An attached client remains authoritative for the shared runtime size. After it detaches, existing pane PTYs retain their last attached size while new headless layout uses the configured fallback.

## Terminal defaults

Set the executable Herdr uses for newly created interactive panes:
Expand Down
18 changes: 18 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@
}
]
},
{
"id": "server",
"title": "Server",
"keys": [
{
"key": "server.headless_cols",
"type": "integer",
"default": "120",
"description": "Virtual terminal width used for layout and newly created panes when no client is attached. Must be greater than zero."
},
{
"key": "server.headless_rows",
"type": "integer",
"default": "40",
"description": "Virtual terminal height used for layout and newly created panes when no client is attached. Must be greater than zero."
}
]
},
{
"id": "theme",
"title": "Theme",
Expand Down
12 changes: 11 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ impl App {
outer_terminal_focus: None,
prefix_code,
prefix_mods,
headless_size: config.headless_size(),
default_sidebar_width: config.ui.sidebar_width,
sidebar_width,
sidebar_min_width,
Expand Down Expand Up @@ -1539,6 +1540,14 @@ impl App {
}
}

if !invalid_section("server") {
if let Some(diagnostic) = config.invalid_headless_size_diagnostic() {
diagnostics.push(format!("{diagnostic}; keeping current [server] settings"));
} else {
self.state.headless_size = config.headless_size();
}
}

if !invalid_section("advanced") {
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
}
Expand Down Expand Up @@ -2974,7 +2983,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
"[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
Expand Down Expand Up @@ -3008,6 +3017,7 @@ mod tests {
let report = app.reload_config();

assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
assert_eq!(app.state.headless_size, (160, 50));
assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
assert!(app
Expand Down
16 changes: 15 additions & 1 deletion src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,8 @@ pub struct AppState {
// Config
pub prefix_code: KeyCode,
pub prefix_mods: KeyModifiers,
/// Virtual terminal size (columns, rows) used when no client is attached.
pub(crate) headless_size: (u16, u16),
pub default_sidebar_width: u16,
pub sidebar_width: u16,
pub sidebar_min_width: u16,
Expand Down Expand Up @@ -1618,7 +1620,7 @@ impl AppState {
if let Some(info) = self.view.pane_infos.first() {
(info.rect.height, info.rect.width)
} else {
(24, 80)
(self.headless_size.1, self.headless_size.0)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -1800,6 +1802,10 @@ impl AppState {
outer_terminal_focus: None,
prefix_code: KeyCode::Char('b'),
prefix_mods: KeyModifiers::CONTROL,
headless_size: (
crate::config::DEFAULT_HEADLESS_COLS,
crate::config::DEFAULT_HEADLESS_ROWS,
),
default_sidebar_width: 26,
sidebar_width: 26,
sidebar_min_width: 18,
Expand Down Expand Up @@ -2242,6 +2248,14 @@ mod tests {
use super::*;
use crossterm::event::KeyEvent;

#[test]
fn pane_size_estimate_uses_headless_size_before_first_view() {
let mut state = AppState::test_new();
state.headless_size = (132, 41);

assert_eq!(state.estimate_pane_size(), (41, 132));
}

#[test]
fn agent_terminal_keeps_final_child_cursor_exposed() {
let mut state = AppState::test_new();
Expand Down
20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
pub const DEFAULT_MOUSE_SCROLL_LINES: usize = 3;
pub const DEFAULT_MOBILE_WIDTH_THRESHOLD: u16 = 64;
pub const DEFAULT_HEADLESS_COLS: u16 = 120;
pub const DEFAULT_HEADLESS_ROWS: u16 = 40;

#[cfg(test)]
pub(crate) fn app_dir_name() -> &'static str {
Expand Down Expand Up @@ -90,9 +92,27 @@ impl Config {
.chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right))
.chain(window_title_diagnostics(&self.ui.window_title))
.chain(self.invalid_sidebar_bounds_diagnostic())
.chain(self.invalid_headless_size_diagnostic())
.collect()
}

pub(crate) fn headless_size(&self) -> (u16, u16) {
if self.invalid_headless_size_diagnostic().is_some() {
(DEFAULT_HEADLESS_COLS, DEFAULT_HEADLESS_ROWS)
} else {
(self.server.headless_cols, self.server.headless_rows)
}
}

pub(crate) fn invalid_headless_size_diagnostic(&self) -> Option<String> {
(self.server.headless_cols == 0 || self.server.headless_rows == 0).then(|| {
format!(
"server.headless_cols and server.headless_rows must be greater than zero (got {}x{})",
self.server.headless_cols, self.server.headless_rows
)
})
}

pub(crate) fn invalid_sidebar_bounds_diagnostic(&self) -> Option<String> {
validated_sidebar_bounds(self.ui.sidebar_min_width, self.ui.sidebar_max_width)
.is_none()
Expand Down
9 changes: 9 additions & 0 deletions src/config/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const KNOWN_TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"keys",
"onboarding",
"remote",
"server",
"session",
"terminal",
"theme",
Expand Down Expand Up @@ -292,6 +293,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
&mut invalid_sections,
|section| config.session = section,
);
load_live_section(
table,
"server",
"server config",
&mut diagnostics,
&mut invalid_sections,
|section| config.server = section,
);
load_live_section(
table,
"update",
Expand Down
58 changes: 58 additions & 0 deletions src/config/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ pub struct Config {
pub theme: ThemeConfig,
pub terminal: TerminalConfig,
pub session: SessionConfig,
pub server: ServerConfig,
pub update: UpdateConfig,
pub keys: KeysConfig,
pub ui: UiConfig,
Expand Down Expand Up @@ -938,6 +939,15 @@ impl ImeCursorShape {
}
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
/// Virtual terminal width used when no client is attached. Default: 120.
pub headless_cols: u16,
/// Virtual terminal height used when no client is attached. Default: 40.
pub headless_rows: u16,
}

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct AdvancedConfig {
Expand Down Expand Up @@ -1200,6 +1210,15 @@ impl<'de> Deserialize<'de> for ToastConfig {
}
}

impl Default for ServerConfig {
fn default() -> Self {
Self {
headless_cols: crate::config::DEFAULT_HEADLESS_COLS,
headless_rows: crate::config::DEFAULT_HEADLESS_ROWS,
}
}
}

impl Default for AdvancedConfig {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -1817,6 +1836,45 @@ delay_seconds = {}
assert!(!config.should_show_onboarding());
}

#[test]
fn server_headless_size_defaults_and_parses() {
let default_config = Config::default();
assert_eq!(
default_config.server.headless_cols,
crate::config::DEFAULT_HEADLESS_COLS
);
assert_eq!(
default_config.server.headless_rows,
crate::config::DEFAULT_HEADLESS_ROWS
);

let config: Config = toml::from_str(
r#"[server]
headless_cols = 160
headless_rows = 50
"#,
)
.unwrap();
assert_eq!(config.server.headless_cols, 160);
assert_eq!(config.server.headless_rows, 50);

let invalid: Config = toml::from_str(
r#"[server]
headless_cols = 0
headless_rows = 50
"#,
)
.unwrap();
assert!(invalid.invalid_headless_size_diagnostic().is_some());
assert_eq!(
invalid.headless_size(),
(
crate::config::DEFAULT_HEADLESS_COLS,
crate::config::DEFAULT_HEADLESS_ROWS
)
);
}

#[test]
fn advanced_defaults_include_scrollback_limit_bytes() {
let config = Config::default();
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly
# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly

# Size of the virtual terminal used when no client is attached.
# Attached clients always use their own terminal size.
[server]
# headless_cols = 120
# headless_rows = 40

# [worktrees]
# directory = "~/.herdr/worktrees"

Expand Down
Loading
Loading