Record bucketed terminal sizes in TUI telemetry (#53859)

## What changed

- Add `terminal_columns` and `terminal_rows` buckets to `codex.tui.start`.
- Record `codex.tui.terminal_size` for new turns in the active thread and for resize bucket changes after the terminal size settles for 15 seconds. Reuse launch context and suppress duplicate turn notifications.
- Respect analytics consent for the built-in metrics exporter while allowing explicitly configured OTLP collectors. Apply consent checks to the active exporter across provider replacements.

## Testing

Add coverage for exporter replacement and consent after config reloads. Extend the PTY integration test to verify startup size buckets, turn-start observations, and delayed resize observations with analytics disabled and an explicit OTLP collector.

GitOrigin-RevId: e01a900cd3f04a36731078f725c75374596cb01b
This commit is contained in:
Ian MacLeod
2026-10-14 03:52:30 +00:00
committed by copyberry
parent e342df7b66
commit c22a06c180
16 changed files with 349 additions and 16 deletions
+84 -6
View File
@@ -9,6 +9,7 @@ use std::io::Write as _;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
#[cfg(not(unix))]
use tempfile::TempDir;
use wiremock::Mock;
@@ -252,6 +253,10 @@ trust_level = "trusted"
env.insert("TERM".into(), "xterm-256color".into());
env.insert("TERM_PROGRAM".into(), "unrecognized-terminal".into());
env.insert("OTEL_METRIC_EXPORT_INTERVAL".into(), "100".into());
env.insert(
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE".into(),
"delta".into(),
);
for key in [
"CODEX_EXEC_SERVER_URL",
"CODEX_ACCESS_TOKEN",
@@ -335,8 +340,8 @@ trust_level = "trusted"
}
let (metric_tx, mut metric_rx) = tokio::sync::mpsc::unbounded_channel();
Mock::given(wiremock::matchers::path("/metrics"))
.respond_with(move |_: &wiremock::Request| {
let _ = metric_tx.send(());
.respond_with(move |request: &wiremock::Request| {
let _ = metric_tx.send(request.body.clone());
ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
})
.mount(&server)
@@ -409,7 +414,8 @@ trust_level = "trusted"
if analytics {
args.extend([
"-c".into(),
"analytics.enabled=true".into(),
// An explicit collector is independent of built-in analytics consent.
"analytics.enabled=false".into(),
"-c".into(),
format!(
"otel.metrics_exporter={{otlp-http={{endpoint=\"{}/metrics\",protocol=\"json\"}}}}",
@@ -551,9 +557,44 @@ trust_level = "trusted"
.with_context(|| format!("daemon status timed out: {output}"))??;
}
if analytics {
tokio::time::timeout(Duration::from_secs(/*secs*/ 10), metric_rx.recv())
.await?
.context("metrics export while the TUI is running")?;
for reason in ["turn_start", "resize"] {
let started = Instant::now();
let wait = if reason == "resize" {
session.resize(codex_utils_pty::TerminalSize {
rows: 80,
cols: 240,
})?;
Duration::from_secs(/*secs*/ 30)
} else {
Duration::from_secs(/*secs*/ 10)
};
tokio::time::timeout(wait, async {
loop {
tokio::select! {
body = metric_rx.recv() => {
let payload: Value = serde_json::from_slice(&body.context("metric body")?)?;
let found = payload["resourceMetrics"].as_array().into_iter().flatten()
.flat_map(|resource| resource["scopeMetrics"].as_array().into_iter().flatten())
.flat_map(|scope| scope["metrics"].as_array().into_iter().flatten())
.filter(|metric| metric["name"] == "codex.tui.terminal_size")
.flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten())
.any(|point| point["attributes"].as_array().into_iter().flatten().any(|attribute|
attribute["key"] == "observation_reason" && attribute["value"]["stringValue"] == reason));
if found { return Ok::<_, anyhow::Error>(()); }
}
bytes = stdout.recv() => {
let bytes = bytes.context("TUI exited before size observation")?;
output.push_str(&String::from_utf8_lossy(&bytes));
let replies = queries.feed(&bytes);
if !replies.is_empty() { session.writer_sender().send(replies).await?; }
}
}
}
}).await.with_context(|| format!("waiting for {reason} observation"))??;
if reason == "resize" {
assert!(started.elapsed() >= Duration::from_secs(/*secs*/ 15));
}
}
}
let startup_result = observed.as_ref().map(|result| result.as_ref().map(|_| ()));
assert!(
@@ -637,6 +678,7 @@ trust_level = "trusted"
.flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten())
.next()
.context("codex.tui.start data point")?;
assert_eq!(point["asInt"], 1);
let tags = point["attributes"]
.as_array()
.context("codex.tui.start attributes")?
@@ -653,6 +695,8 @@ trust_level = "trusted"
let mut expected_tags = HashMap::from([
("app_server_mode", "in_process"),
("terminal_name", "unknown"),
("terminal_columns", "120-239"),
("terminal_rows", "40-79"),
("multiplexer", "none"),
("daemon_selection_reason", "incompatible_option"),
(
@@ -680,6 +724,40 @@ trust_level = "trusted"
]);
}
assert_eq!(tags, expected_tags);
let observations = exported
.iter()
.filter(|metric| metric["name"] == "codex.tui.terminal_size")
.flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten())
.collect::<Vec<_>>();
assert_eq!(observations.len(), 2);
for observation in observations {
assert_eq!(observation["asInt"], 1);
let observation_tags = observation["attributes"]
.as_array()
.context("observation attributes")?
.iter()
.map(|attribute| {
Ok((
attribute["key"].as_str().context("observation key")?,
attribute["value"]["stringValue"]
.as_str()
.context("observation value")?,
))
})
.collect::<anyhow::Result<HashMap<_, _>>>()?;
let reason = observation_tags["observation_reason"];
let mut expected = expected_tags.clone();
expected.insert("observation_reason", reason);
match reason {
"turn_start" => {}
"resize" => {
expected.insert("terminal_columns", "240-479");
expected.insert("terminal_rows", "80-119");
}
other => anyhow::bail!("unexpected observation: {other}"),
}
assert_eq!(observation_tags, expected);
}
} else {
assert!(metrics.is_empty(), "metrics exporter disabled");
}
+24
View File
@@ -43,6 +43,10 @@ use std::sync::Weak;
use std::time::Duration;
use tracing::debug;
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
const ENV_ATTRIBUTE: &str = "env";
const METER_NAME: &str = "codex";
const MILLISECOND_DURATION_UNIT: &str = "ms";
@@ -109,6 +113,7 @@ pub(super) struct MetricsClientInner {
histograms: Mutex<HashMap<String, Histogram<f64>>>,
duration_histograms: Mutex<HashMap<InstrumentKey, Histogram<f64>>>,
runtime_reader: Option<Arc<ManualReader>>,
requires_analytics_consent: bool,
statsig_disabled_metrics: &'static [&'static str],
default_tags: BTreeMap<String, String>,
}
@@ -358,6 +363,8 @@ impl MetricsClient {
)
});
let requires_analytics_consent =
matches!(exporter, MetricsExporter::Otlp(OtelExporter::Statsig));
let (meter_provider, meter) = match exporter {
MetricsExporter::InMemory(exporter) => {
build_provider(resource, exporter, export_interval, runtime_reader.clone())
@@ -391,6 +398,7 @@ impl MetricsClient {
histograms: Mutex::new(HashMap::new()),
duration_histograms: Mutex::new(HashMap::new()),
runtime_reader,
requires_analytics_consent,
statsig_disabled_metrics,
default_tags,
}),
@@ -414,6 +422,22 @@ impl MetricsClient {
.counter(name, /*description*/ None, inc, tags)
}
/// Record a counter while applying analytics consent only to the built-in exporter.
/// The consent check and recording use the same exporter even during provider replacement.
pub fn counter_with_analytics_consent(
&self,
name: &str,
inc: i64,
tags: &[(&str, &str)],
analytics_enabled: bool,
) -> Result<()> {
let inner = self.active_inner();
if inner.requires_analytics_consent && !analytics_enabled {
return Ok(());
}
inner.counter(name, /*description*/ None, inc, tags)
}
/// Send a single counter increment with an instrument description.
pub fn counter_with_description(
&self,
+57
View File
@@ -0,0 +1,57 @@
//! Consent follows the actual counter destination across exporter replacements.
use super::*;
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
use opentelemetry_sdk::metrics::data::MetricData;
use opentelemetry_sdk::metrics::data::ScopeMetrics;
use opentelemetry_sdk::metrics::data::SumDataPoint;
use pretty_assertions::assert_eq;
#[test]
fn counter_consent_uses_the_active_exporter() -> Result<()> {
let mut builtin = MetricsClient::new(
MetricsConfig::in_memory("test", "test", "1", Default::default()).with_runtime_reader(),
)?;
// Use the existing in-memory exporter to observe the built-in consent policy without
// contacting Statsig (the real Statsig exporter is disabled in debug builds).
Arc::get_mut(&mut builtin.inner)
.expect("unique provider")
.requires_analytics_consent = true;
let custom = MetricsClient::new(
MetricsConfig::in_memory("test", "test", "1", Default::default()).with_runtime_reader(),
)?;
let active = Arc::new(RwLock::new(Arc::clone(&builtin.inner)));
let metrics = MetricsClient {
inner: Arc::clone(&builtin.inner),
active: Some(Arc::clone(&active)),
};
// A config reload alone does not replace the installed built-in exporter.
for analytics_enabled in [false, true] {
metrics.counter_with_analytics_consent(
"test.observations",
/*inc*/ 1,
&[],
analytics_enabled,
)?;
}
*active.write().unwrap() = Arc::clone(&custom.inner);
metrics.counter_with_analytics_consent(
"test.observations",
/*inc*/ 1,
&[],
/*analytics_enabled*/ false,
)?;
let mut counts = Vec::new();
for destination in [&builtin, &custom] {
let snapshot = destination.snapshot()?;
for metric in snapshot.scope_metrics().flat_map(ScopeMetrics::metrics) {
if let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() {
counts.extend(sum.data_points().map(SumDataPoint::value));
}
}
}
assert_eq!(counts, vec![1, 1]);
Ok(())
}
+1
View File
@@ -536,6 +536,7 @@ pub(crate) struct App {
config_notification_test_pending: Option<u64>,
model_catalog: Arc<ModelCatalog>,
pub(crate) session_telemetry: SessionTelemetry,
terminal_size_tags: crate::terminal_size_telemetry::LaunchTags,
pub(crate) app_event_tx: AppEventSender,
pub(crate) chat_widget: ChatWidget,
workspace_command_runner: Option<WorkspaceCommandRunner>,
+39
View File
@@ -889,6 +889,7 @@ See the Codex keymap documentation for supported actions and examples."
config_notification_test_pending: None,
model_catalog,
session_telemetry: session_telemetry.clone(),
terminal_size_tags: tui.terminal_size_tags.clone(),
app_event_tx,
chat_widget,
workspace_command_runner: Some(workspace_command_runner),
@@ -1160,6 +1161,9 @@ See the Codex keymap documentation for supported actions and examples."
);
}
let mut last_screen_size = tui.terminal.last_known_screen_size;
let mut settled_size_tags = crate::terminal_size_telemetry::tags(last_screen_size);
let mut pending_resize: Option<(ratatui::layout::Size, Instant)> = None;
let mut listen_for_app_server_events = true;
let mut reconnect = None;
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
@@ -1263,6 +1267,19 @@ See the Codex keymap documentation for supported actions and examples."
&& has_pending_app_events
|| (!waiting_for_initial_session_configured
&& app.has_queued_startup_protected_request());
// The screen cache is physical geometry, not the inline viewport. This also
// notices the final size after returning from a standalone nested screen.
let size = tui.terminal.last_known_screen_size;
if !crate::terminal_size_telemetry::enabled(&app.config)
|| app.terminal_size_tags.is_empty()
{
pending_resize = None;
settled_size_tags = crate::terminal_size_telemetry::tags(size);
} else if size != last_screen_size {
pending_resize =
Some((size, Instant::now() + Duration::from_secs(/*secs*/ 15)));
}
last_screen_size = size;
let rate_limit_poll_deadline = app
.chat_widget
.rate_limit_refresh_interval()
@@ -1390,6 +1407,28 @@ See the Codex keymap documentation for supported actions and examples."
}
AppRunControl::Continue
}
() = async {
match pending_resize {
Some((_, deadline)) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
None => std::future::pending().await,
}
} => {
if let Some((expected, _)) = pending_resize.take()
&& crate::terminal_size_telemetry::enabled(&app.config)
{
let actual = crate::terminal_size_telemetry::read_size();
if actual != expected {
pending_resize = Some((actual, Instant::now() + Duration::from_secs(/*secs*/ 15)));
} else {
let tags = crate::terminal_size_telemetry::tags(actual);
if tags != settled_size_tags {
crate::terminal_size_telemetry::record(&app.terminal_size_tags, &app.config, "resize", actual);
}
settled_size_tags = tags;
}
}
AppRunControl::Continue
}
() = async {
match rate_limit_poll_deadline {
Some(deadline) => {
+1
View File
@@ -26,6 +26,7 @@ pub(crate) async fn make_test_app() -> Box<App> {
config_notification_test_pending: None,
model_catalog: chat_widget.model_catalog(),
session_telemetry,
terminal_size_tags: Vec::new(),
app_event_tx,
chat_widget,
workspace_command_runner: None,
+5
View File
@@ -1,5 +1,8 @@
//! App-level orchestration tests for the TUI.
#[path = "tests/terminal_size_telemetry_tests.rs"]
mod terminal_size_telemetry_tests;
#[path = "tests/copy_mode_tests.rs"]
mod copy_mode_tests;
#[path = "tests/mcp_login_tests.rs"]
@@ -6264,6 +6267,7 @@ async fn make_test_app() -> Box<App> {
config_notification_test_pending: None,
model_catalog: chat_widget.model_catalog(),
session_telemetry,
terminal_size_tags: Vec::new(),
app_event_tx,
chat_widget,
workspace_command_runner: None,
@@ -6389,6 +6393,7 @@ pub(super) async fn make_test_app_with_channels() -> (
config_notification_test_pending: None,
model_catalog: chat_widget.model_catalog(),
session_telemetry,
terminal_size_tags: Vec::new(),
app_event_tx,
chat_widget,
workspace_command_runner: None,
@@ -0,0 +1,26 @@
//! Terminal-size observation eligibility follows the config reloaded for resume.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn terminal_size_telemetry_respects_reloaded_exporter_and_consent() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let cwd = app.config.cwd.to_path_buf();
let mut enabled = Vec::new();
for config in [
"[otel]\nmetrics_exporter = \"statsig\"\n",
"[otel]\nmetrics_exporter = \"none\"\n",
"[otel]\nmetrics_exporter = \"statsig\"\n[analytics]\nenabled = false\n",
"analytics.enabled = false\n[otel.metrics_exporter.otlp-http]\nendpoint = \"http://127.0.0.1:4318/v1/metrics\"\nprotocol = \"json\"\n",
] {
std::fs::write(app.config.codex_home.join("config.toml"), config)?;
let (config, local_settings) =
Box::pin(app.rebuild_config_for_resume_or_fallback(&cwd, cwd.clone())).await?;
app.config = config;
app.local_settings = local_settings;
enabled.push(crate::terminal_size_telemetry::enabled(&app.config));
}
assert_eq!(enabled, vec![true, false, false, true]);
Ok(())
}
+17
View File
@@ -2059,6 +2059,23 @@ impl App {
}
pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) {
// Replay has its own handler; suppress repeated notifications for the current turn.
if crate::terminal_size_telemetry::enabled(&self.config)
&& !self.terminal_size_tags.is_empty()
&& let ThreadBufferedEvent::Notification(notification) = &event
&& let ServerNotification::TurnStarted(started) = notification.as_ref()
&& self
.active_thread_id
.is_some_and(|id| id.to_string() == started.thread_id)
&& self.chat_widget.last_turn_id() != Some(started.turn.id.as_str())
{
crate::terminal_size_telemetry::record(
&self.terminal_size_tags,
&self.config,
"turn_start",
crate::terminal_size_telemetry::read_size(),
);
}
let needs_refresh = matches!(
&event,
ThreadBufferedEvent::Notification(notification)
+4
View File
@@ -1950,6 +1950,10 @@ impl ChatWidget {
.unwrap_or_default()
}
pub(crate) fn last_turn_id(&self) -> Option<&str> {
self.turn_lifecycle.last_turn_id.as_deref()
}
pub(crate) fn thread_id(&self) -> Option<ThreadId> {
self.thread_id
}
+1
View File
@@ -125,6 +125,7 @@ fn daemon_launch_telemetry_records_once_on_connection_or_early_return() {
let observations = std::cell::RefCell::new(Vec::new());
let launch = daemon_telemetry::Launch(Some(|target: &AppServerTarget, actual: bool| {
observations.borrow_mut().push((target.clone(), actual));
Vec::new()
}));
if connected {
launch.record(&AppServerTarget::Embedded, connected);
+9 -7
View File
@@ -1,19 +1,21 @@
//! Records automatic daemon startup and ensures launch observations survive setup failures.
use super::*;
use crate::terminal_size_telemetry::LaunchTags;
/// Emit once after connection, or as unconfirmed if startup returns early.
pub(super) struct Launch<F: FnOnce(&AppServerTarget, bool)>(pub(super) Option<F>);
pub(super) struct Launch<F: FnOnce(&AppServerTarget, bool) -> LaunchTags>(pub(super) Option<F>);
impl<F: FnOnce(&AppServerTarget, bool)> Launch<F> {
pub(super) fn record(mut self, target: &AppServerTarget, connected: bool) {
if let Some(record) = self.0.take() {
record(target, connected);
}
impl<F: FnOnce(&AppServerTarget, bool) -> LaunchTags> Launch<F> {
pub(super) fn record(mut self, target: &AppServerTarget, connected: bool) -> LaunchTags {
self.0
.take()
.map(|record| record(target, connected))
.unwrap_or_default()
}
}
impl<F: FnOnce(&AppServerTarget, bool)> Drop for Launch<F> {
impl<F: FnOnce(&AppServerTarget, bool) -> LaunchTags> Drop for Launch<F> {
fn drop(&mut self) {
if let Some(record) = self.0.take() {
record(&AppServerTarget::Embedded, /*connected*/ false);
+6 -2
View File
@@ -230,6 +230,7 @@ mod temporary_structured_request;
mod terminal_hyperlinks;
mod terminal_palette;
mod terminal_probe;
mod terminal_size_telemetry;
mod terminal_title;
mod terminal_visualization_instructions;
mod text_formatting;
@@ -1159,7 +1160,9 @@ async fn run_ratatui_app(
managed_worktree: Option<ManagedTuiWorktree>,
daemon_startup_warning: Option<String>,
managed_daemon: bool,
launch_telemetry: daemon_telemetry::Launch<impl FnOnce(&AppServerTarget, bool)>,
launch_telemetry: daemon_telemetry::Launch<
impl FnOnce(&AppServerTarget, bool) -> terminal_size_telemetry::LaunchTags,
>,
startup_draft: startup_draft::StartupDraft,
) -> color_eyre::Result<AppExitInfo> {
let uses_remote_workspace = app_server_target.uses_remote_workspace();
@@ -1226,7 +1229,8 @@ async fn run_ratatui_app(
),
)
.await;
launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_))));
tui.terminal_size_tags =
launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_))));
let mut app_server_session = match startup_app_server {
Ok(Ok(app_server)) => {
AppServerSession::new(app_server, app_server_target.thread_params_mode())
+9 -1
View File
@@ -285,6 +285,9 @@ pub(super) async fn run_main_inner(
})
.await
.map_err(std::io::Error::other)??;
// Snapshot the backend size sampled during init, before any viewport changes or resizes.
let initial_terminal_size = initialized_terminal.terminal.last_known_screen_size;
let terminal_size_tags = crate::terminal_size_telemetry::tags(initial_terminal_size);
let startup_presentation::StartupPresentation {
bootstrap_config,
config_cwd,
@@ -684,6 +687,7 @@ pub(super) async fn run_main_inner(
Vec::new()
};
let mut launch_tags = daemon_settings.to_vec();
launch_tags.extend(terminal_size_tags);
launch_tags.extend([
("daemon_selection_reason", selection_reason),
(
@@ -697,7 +701,9 @@ pub(super) async fn run_main_inner(
]);
// Record the first connection attempt's actual mode, including embedded fallback; never reconnects.
let launch_telemetry = move |target: &AppServerTarget, connected: bool| {
let Some(metrics) = metrics else { return };
let Some(metrics) = metrics else {
return Vec::new();
};
let app_server_mode = match (connected, target) {
(false, _) => "unconfirmed",
(true, AppServerTarget::Embedded) => "in_process",
@@ -733,6 +739,8 @@ pub(super) async fn run_main_inner(
("multiplexer", multiplexer),
]);
let _ = metrics.counter("codex.tui.start", /*inc*/ 1, &launch_tags);
launch_tags.retain(|(key, _)| !matches!(*key, "terminal_columns" | "terminal_rows"));
launch_tags
};
let launch_telemetry = daemon_telemetry::Launch(Some(launch_telemetry));
let state_db = startup_draft
@@ -0,0 +1,64 @@
//! Shared terminal-cell buckets and best-effort observations using the existing metrics provider.
use crate::legacy_core::config::Config;
use codex_config::types::OtelExporterKind;
use ratatui::layout::Size;
pub(crate) type LaunchTags = Vec<(&'static str, &'static str)>;
pub(crate) fn enabled(config: &Config) -> bool {
match config.otel.metrics_exporter {
OtelExporterKind::None => false,
OtelExporterKind::Statsig => config.analytics_enabled != Some(false),
OtelExporterKind::OtlpHttp { .. } | OtelExporterKind::OtlpGrpc { .. } => true,
}
}
pub(crate) fn tags(size: Size) -> [(&'static str, &'static str); 2] {
// Keep values compatible with metrics tag validation; `<` and `+` are rejected.
let columns = match size.width {
0 => "unknown",
1..40 => "under-40",
40..80 => "40-79",
80..120 => "80-119",
120..240 => "120-239",
240..480 => "240-479",
480.. => "480-and-up",
};
let rows = match size.height {
0 => "unknown",
1..15 => "under-15",
15..24 => "15-23",
24..40 => "24-39",
40..80 => "40-79",
80..120 => "80-119",
120.. => "120-and-up",
};
[("terminal_columns", columns), ("terminal_rows", rows)]
}
pub(crate) fn read_size() -> Size {
// On Unix, size() may synchronously spawn tput when the direct query fails.
#[cfg(unix)]
let size = crossterm::terminal::window_size().map(|size| Size::new(size.columns, size.rows));
// Windows size() queries the console directly; window_size() is unsupported there.
#[cfg(not(unix))]
let size = crossterm::terminal::size().map(|(width, height)| Size::new(width, height));
size.unwrap_or_default()
}
pub(crate) fn record(context: &LaunchTags, config: &Config, reason: &'static str, size: Size) {
if !context.is_empty()
&& let Some(metrics) = codex_otel::global()
{
let mut labels = context.clone();
labels.extend(tags(size));
labels.push(("observation_reason", reason));
let _ = metrics.counter_with_analytics_consent(
"codex.tui.terminal_size",
/*inc*/ 1,
&labels,
config.analytics_enabled != Some(false),
);
}
}
+2
View File
@@ -664,6 +664,7 @@ pub struct Tui {
frame_requester: FrameRequester,
draw_tx: broadcast::Sender<()>,
event_broker: Arc<EventBroker>,
pub(crate) terminal_size_tags: crate::terminal_size_telemetry::LaunchTags,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<PendingHistoryLines>,
clear_thread_switch_on_draw: bool,
@@ -741,6 +742,7 @@ impl Tui {
frame_requester,
draw_tx,
event_broker: Arc::new(event_broker),
terminal_size_tags: Vec::new(),
terminal,
pending_history_lines: vec![],
clear_thread_switch_on_draw: false,