Merge cc0c30a50d into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-08-14 12:05:12 -07:00
committed by GitHub
14 changed files with 753 additions and 1131 deletions

View File

@@ -82,10 +82,34 @@ where
})
}
const ILLEGAL_ENV_VAR_PREFIX: &str = "CONST_";
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
///
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
fn load_dotenv() {
if let Ok(codex_home) = codex_core::config::find_codex_home() {
dotenvy::from_path(codex_home.join(".env")).ok();
// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys.
fn set_filtered<I>(iter: I)
where
I: IntoIterator<Item = Result<(String, String), dotenvy::Error>>,
{
for (key, value) in iter.into_iter().flatten() {
if !key.starts_with(ILLEGAL_ENV_VAR_PREFIX) {
// It is safe to call set_var() because our process is
// single-threaded at this point in its execution.
unsafe { std::env::set_var(&key, &value) };
}
}
}
if let Ok(codex_home) = codex_core::config::find_codex_home() {
if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) {
set_filtered(iter);
}
}
if let Ok(iter) = dotenvy::dotenv_iter() {
set_filtered(iter);
}
dotenvy::dotenv().ok();
}

View File

@@ -144,6 +144,7 @@ pub struct RemoveConversationListenerParams {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type", content = "data")]
pub enum InputItem {
Text {
text: String,

View File

@@ -183,12 +183,6 @@ impl App<'_> {
}
}
/// Clone of the internal event sender so external tasks (e.g. log bridge)
/// can inject `AppEvent`s.
pub fn event_sender(&self) -> AppEventSender {
self.app_event_tx.clone()
}
/// Schedule a redraw if one is not already pending.
#[allow(clippy::unwrap_used)]
fn schedule_redraw(&self) {
@@ -325,10 +319,6 @@ impl App<'_> {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::LatestLog(line) => match &mut self.app_state {
AppState::Chat { widget } => widget.update_latest_log(line),
AppState::Onboarding { .. } => {}
},
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
// User accepted switch to chat view.

View File

@@ -29,9 +29,6 @@ pub(crate) enum AppEvent {
/// bubbling channels through layers of widgets.
CodexOp(codex_core::protocol::Op),
/// Latest formatted log line emitted by `tracing`.
LatestLog(String),
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally.
DispatchCommand(SlashCommand),

View File

@@ -6,12 +6,6 @@ use ratatui::layout::Rect;
use super::BottomPane;
use super::CancellationEvent;
/// Type to use for a method that may require a redraw of the UI.
pub(crate) enum ConditionalUpdate {
NeedsRedraw,
NoRedraw,
}
/// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView<'a> {
/// Handle a key event while the view is active. A redraw is always
@@ -34,11 +28,6 @@ pub(crate) trait BottomPaneView<'a> {
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Update the status indicator text.
fn update_status_text(&mut self, _text: String) -> ConditionalUpdate {
ConditionalUpdate::NoRedraw
}
/// Called when task completes to check if the view should be hidden.
fn should_hide_when_task_is_done(&mut self) -> bool {
false

View File

@@ -32,7 +32,6 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView;
use status_indicator_view::StatusIndicatorView;
@@ -50,11 +49,6 @@ pub(crate) struct BottomPane<'a> {
is_task_running: bool,
ctrl_c_quit_hint: bool,
/// Optional live, multiline status/"live cell" rendered directly above
/// the composer while a task is running. Unlike `active_view`, this does
/// not replace the composer; it augments it.
live_status: Option<StatusIndicatorWidget>,
/// True if the active view is the StatusIndicatorView that replaces the
/// composer during a running task.
status_view_active: bool,
@@ -81,27 +75,18 @@ impl BottomPane<'_> {
has_input_focus: params.has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
live_status: None,
status_view_active: false,
}
}
pub fn desired_height(&self, width: u16) -> u16 {
let overlay_status_h = self
.live_status
.as_ref()
.map(|s| s.desired_height(width))
.unwrap_or(0);
let view_height = if let Some(view) = self.active_view.as_ref() {
view.desired_height(width)
} else {
self.composer.desired_height(width)
};
overlay_status_h
.saturating_add(view_height)
.saturating_add(Self::BOTTOM_PAD_LINES)
view_height.saturating_add(Self::BOTTOM_PAD_LINES)
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -182,44 +167,6 @@ impl BottomPane<'_> {
self.request_redraw();
}
/// Update the status indicator text. Prefer replacing the composer with
/// the StatusIndicatorView so the input pane shows a single-line status
/// like: `▌ Working waiting for model`.
pub(crate) fn update_status_text(&mut self, text: String) {
let mut handled_by_view = false;
if let Some(view) = self.active_view.as_mut() {
if matches!(
view.update_status_text(text.clone()),
bottom_pane_view::ConditionalUpdate::NeedsRedraw
) {
handled_by_view = true;
}
} else {
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
v.update_text(text.clone());
self.active_view = Some(Box::new(v));
self.status_view_active = true;
handled_by_view = true;
}
// Fallback: if the current active view did not consume status updates
// and no modal view is active, present an overlay above the composer.
// If a modal is active, do NOT render the overlay to avoid drawing
// over the dialog.
if !handled_by_view && self.active_view.is_none() {
if self.live_status.is_none() {
self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone()));
}
if let Some(status) = &mut self.live_status {
status.update_text(text);
}
} else if !handled_by_view {
// Ensure any previous overlay is cleared when a modal becomes active.
self.live_status = None;
}
self.request_redraw();
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
self.composer
@@ -252,7 +199,6 @@ impl BottomPane<'_> {
}
self.request_redraw();
} else {
self.live_status = None;
// Drop the status view when a task completes, but keep other
// modal views (e.g. approval dialogs).
if let Some(mut view) = self.active_view.take() {
@@ -302,8 +248,6 @@ impl BottomPane<'_> {
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.active_view = Some(Box::new(modal));
// Hide any overlay status while a modal is visible.
self.live_status = None;
self.status_view_active = false;
self.request_redraw()
}
@@ -342,46 +286,31 @@ impl BottomPane<'_> {
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut y_offset = 0u16;
if let Some(status) = &self.live_status {
let live_h = status
.desired_height(area.width)
.min(area.height.saturating_sub(y_offset));
if live_h > 0 {
let live_rect = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
height: live_h,
};
status.render_ref(live_rect, buf);
y_offset = y_offset.saturating_add(live_h);
}
}
if let Some(view) = &self.active_view {
if y_offset < area.height {
// Reserve bottom padding lines; keep at least 1 line for the view.
let avail = area.height - y_offset;
// Reserve bottom padding lines; keep at least 1 line for the view.
let avail = area.height;
if avail > 0 {
let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1));
let view_rect = Rect {
x: area.x,
y: area.y + y_offset,
y: area.y,
width: area.width,
height: avail - pad,
};
view.render(view_rect, buf);
}
} else if y_offset < area.height {
let composer_rect = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
// Reserve bottom padding
height: (area.height - y_offset)
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
};
(&self.composer).render_ref(composer_rect, buf);
} else {
let avail = area.height;
if avail > 0 {
let composer_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
// Reserve bottom padding
height: avail - BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1)),
};
(&self.composer).render_ref(composer_rect, buf);
}
}
}
}
@@ -431,10 +360,8 @@ mod tests {
// Create an approval modal (active view).
pane.push_approval_request(exec_request());
// Attempt to update status; this should NOT create an overlay while modal is visible.
pane.update_status_text("running command".to_string());
// Render and verify the top row does not include the Working header overlay.
// Render and verify the top row does not include an overlay.
let area = Rect::new(0, 0, 60, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
@@ -445,7 +372,7 @@ mod tests {
}
assert!(
!r0.contains("Working"),
"overlay Working header should not render above modal"
"overlay should not render above modal"
);
}
@@ -461,7 +388,6 @@ mod tests {
// Start a running task so the status indicator replaces the composer.
pane.set_task_running(true);
pane.update_status_text("waiting for model".to_string());
// Push an approval modal (e.g., command approval) which should hide the status view.
pane.push_approval_request(exec_request());
@@ -511,11 +437,6 @@ mod tests {
// Begin a task: show initial status.
pane.set_task_running(true);
pane.update_status_text("waiting for model".to_string());
// As a long-running command begins (post-approval), ensure the status
// indicator is visible while we wait for the command to run.
pane.update_status_text("running command".to_string());
// Allow some frames so the animation thread ticks.
std::thread::sleep(std::time::Duration::from_millis(120));
@@ -547,7 +468,6 @@ mod tests {
// Activate spinner (status view replaces composer) with no live ring.
pane.set_task_running(true);
pane.update_status_text("waiting for model".to_string());
// Use height == desired_height; expect 1 status row at top and 2 bottom padding rows.
let height = pane.desired_height(30);
@@ -598,7 +518,6 @@ mod tests {
});
pane.set_task_running(true);
pane.update_status_text("waiting for model".to_string());
// Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner.
let area2 = Rect::new(0, 0, 20, 2);

View File

@@ -8,7 +8,6 @@ use crate::bottom_pane::BottomPane;
use crate::status_indicator_widget::StatusIndicatorWidget;
use super::BottomPaneView;
use super::bottom_pane_view::ConditionalUpdate;
pub(crate) struct StatusIndicatorView {
view: StatusIndicatorWidget,
@@ -27,11 +26,6 @@ impl StatusIndicatorView {
}
impl BottomPaneView<'_> for StatusIndicatorView {
fn update_status_text(&mut self, text: String) -> ConditionalUpdate {
self.update_text(text);
ConditionalUpdate::NeedsRedraw
}
fn should_hide_when_task_is_done(&mut self) -> bool {
true
}

View File

@@ -44,7 +44,9 @@ use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
// streaming internals are provided by crate::streaming and crate::markdown_stream
@@ -68,7 +70,7 @@ pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>,
active_exec_cell: Option<HistoryCell>,
active_exec_cell: Option<ExecCell>,
config: Config,
initial_user_message: Option<UserMessage>,
total_token_usage: TokenUsage,
@@ -123,7 +125,7 @@ impl ChatWidget<'_> {
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
self.add_to_history(&history_cell::new_session_info(&self.config, event, true));
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@@ -164,7 +166,6 @@ impl ChatWidget<'_> {
fn on_task_started(&mut self) {
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.set_task_running(true);
self.set_waiting_for_model_status();
self.stream.reset_headers_for_new_turn();
self.last_stream_kind = None;
self.mark_needs_redraw();
@@ -195,14 +196,14 @@ impl ChatWidget<'_> {
}
fn on_error(&mut self, message: String) {
self.add_to_history(HistoryCell::new_error_event(message));
self.add_to_history(&history_cell::new_error_event(message));
self.bottom_pane.set_task_running(false);
self.stream.clear_all();
self.mark_needs_redraw();
}
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
self.add_to_history(HistoryCell::new_plan_update(update));
self.add_to_history(&history_cell::new_plan_update(update));
}
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
@@ -237,7 +238,7 @@ impl ChatWidget<'_> {
}
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(HistoryCell::new_patch_event(
self.add_to_history(&history_cell::new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: event.auto_approved,
},
@@ -336,16 +337,9 @@ impl ChatWidget<'_> {
}
}
#[inline]
fn set_waiting_for_model_status(&mut self) {
self.bottom_pane
.update_status_text("waiting for model".to_string());
}
#[inline]
fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.set_waiting_for_model_status();
self.stream.begin(kind, &sink);
self.last_stream_kind = Some(kind);
self.stream.push_and_maybe_commit(&delta, &sink);
@@ -372,7 +366,7 @@ impl ChatWidget<'_> {
self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending {
self.add_to_history(HistoryCell::new_completed_exec_command(
self.add_to_history(&history_cell::new_completed_exec_command(
command, parsed, output,
));
}
@@ -384,9 +378,9 @@ impl ChatWidget<'_> {
event: codex_core::protocol::PatchApplyEndEvent,
) {
if event.success {
self.add_to_history(HistoryCell::new_patch_apply_success(event.stdout));
self.add_to_history(&history_cell::new_patch_apply_success(event.stdout));
} else {
self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr));
}
}
@@ -402,7 +396,7 @@ impl ChatWidget<'_> {
.map(|r| format!("\n{r}"))
.unwrap_or_default()
);
self.add_to_history(HistoryCell::new_background_event(text));
self.add_to_history(&history_cell::new_background_event(text));
let request = ApprovalRequest::Exec {
id,
@@ -419,7 +413,7 @@ impl ChatWidget<'_> {
ev: ApplyPatchApprovalRequestEvent,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(HistoryCell::new_patch_event(
self.add_to_history(&history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
));
@@ -435,8 +429,6 @@ impl ChatWidget<'_> {
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.bottom_pane
.update_status_text("running command".to_string());
self.running_commands.insert(
ev.call_id.clone(),
RunningCommand {
@@ -446,11 +438,11 @@ impl ChatWidget<'_> {
);
// Accumulate parsed commands into a single active Exec cell so they stack
match self.active_exec_cell.as_mut() {
Some(HistoryCell::Exec(exec)) => {
Some(exec) => {
exec.parsed.extend(ev.parsed_cmd);
}
_ => {
self.active_exec_cell = Some(HistoryCell::new_active_exec_command(
self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.command,
ev.parsed_cmd,
));
@@ -463,11 +455,11 @@ impl ChatWidget<'_> {
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation));
self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation));
}
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
self.add_to_history(&*history_cell::new_completed_mcp_tool_call(
80,
ev.invocation,
ev.duration,
@@ -564,14 +556,14 @@ impl ChatWidget<'_> {
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.app_event_tx
.send(AppEvent::InsertHistory(active.plain_lines()));
.send(AppEvent::InsertHistory(active.display_lines()));
}
}
fn add_to_history(&mut self, cell: HistoryCell) {
fn add_to_history(&mut self, cell: &dyn HistoryCell) {
self.flush_active_exec_cell();
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
.send(AppEvent::InsertHistory(cell.display_lines()));
}
fn submit_user_message(&mut self, user_message: UserMessage) {
@@ -607,7 +599,7 @@ impl ChatWidget<'_> {
// Only show the text portion in conversation history.
if !text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
self.add_to_history(&history_cell::new_user_prompt(text.clone()));
}
}
@@ -668,30 +660,23 @@ impl ChatWidget<'_> {
}
}
/// Update the live log preview while a task is running.
pub(crate) fn update_latest_log(&mut self, line: String) {
if self.bottom_pane.is_task_running() {
self.bottom_pane.update_status_text(line);
}
}
fn request_redraw(&mut self) {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
self.add_to_history(&history_cell::new_diff_output(diff_output.clone()));
}
pub(crate) fn add_status_output(&mut self) {
self.add_to_history(HistoryCell::new_status_output(
self.add_to_history(&history_cell::new_status_output(
&self.config,
&self.total_token_usage,
));
}
pub(crate) fn add_prompts_output(&mut self) {
self.add_to_history(HistoryCell::new_prompts_output());
self.add_to_history(&history_cell::new_prompts_output());
}
/// Forward file-search results to the bottom pane.

View File

@@ -361,19 +361,22 @@ fn style_del() -> Style {
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::HistoryCell;
use crate::text_block::TextBlock;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
let cell = HistoryCell::PendingPatch {
view: TextBlock::new(lines),
};
terminal
.draw(|f| f.render_widget_ref(&cell, f.area()))
.draw(|f| {
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.render_ref(f.area(), f.buffer_mut())
})
.expect("draw");
assert_snapshot!(name, terminal.backend());
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_login::CodexAuth;
use codex_ollama::DEFAULT_OSS_MODEL;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
use std::path::PathBuf;
use tracing::error;
@@ -39,7 +38,6 @@ mod get_git_diff;
mod history_cell;
pub mod insert_history;
pub mod live_wrap;
mod log_layer;
mod markdown;
mod markdown_stream;
pub mod onboarding;
@@ -49,7 +47,6 @@ mod shimmer;
mod slash_command;
mod status_indicator_widget;
mod streaming;
mod text_block;
mod text_formatting;
mod tui;
mod user_approval_widget;
@@ -213,14 +210,7 @@ pub async fn run_main(
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
}
// Channel that carries formatted log lines to the UI.
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let tui_layer = TuiLogLayer::new(log_tx.clone(), 120).with_filter(env_filter());
let _ = tracing_subscriber::registry()
.with(file_layer)
.with(tui_layer)
.try_init();
let _ = tracing_subscriber::registry().with(file_layer).try_init();
#[allow(clippy::print_stderr)]
#[cfg(not(debug_assertions))]
@@ -254,7 +244,7 @@ pub async fn run_main(
eprintln!("");
}
run_ratatui_app(cli, config, should_show_trust_screen, log_rx)
run_ratatui_app(cli, config, should_show_trust_screen)
.map_err(|err| std::io::Error::other(err.to_string()))
}
@@ -262,7 +252,6 @@ fn run_ratatui_app(
cli: Cli,
config: Config,
should_show_trust_screen: bool,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
color_eyre::install()?;
@@ -284,16 +273,6 @@ fn run_ratatui_app(
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{
let app_event_tx = app.event_sender();
tokio::spawn(async move {
while let Some(line) = log_rx.recv().await {
app_event_tx.send(crate::app_event::AppEvent::LatestLog(line));
}
});
}
let app_result = app.run(&mut terminal);
let usage = app.token_usage();

View File

@@ -1,91 +0,0 @@
//! Custom `tracing_subscriber` layer that forwards every formatted log event to the
//! TUI so the status indicator can display the *latest* log line while a task is
//! running.
//!
//! The layer is intentionally extremely small: we implement `on_event()` only and
//! ignore spans/metadata because we only care about the alreadyformatted output
//! that the default `fmt` layer would print. We therefore borrow the same
//! formatter (`tracing_subscriber::fmt::format::FmtSpan`) used by the default
//! fmt layer so the text matches what is written to the log file.
use std::fmt::Write as _;
use tokio::sync::mpsc::UnboundedSender;
use tracing::Event;
use tracing::Subscriber;
use tracing::field::Field;
use tracing::field::Visit;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
/// Maximum characters forwarded to the TUI. Longer messages are truncated so the
/// singleline status indicator cannot overflow the viewport.
pub struct TuiLogLayer {
tx: UnboundedSender<String>,
max_len: usize,
}
impl TuiLogLayer {
pub fn new(tx: UnboundedSender<String>, max_len: usize) -> Self {
Self {
tx,
max_len: max_len.max(8),
}
}
}
impl<S> Layer<S> for TuiLogLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
// Build a terse line like `[TRACE core::session] message …` by visiting
// fields into a buffer. This avoids pulling in the heavyweight
// formatter machinery.
struct Visitor<'a> {
buf: &'a mut String,
}
impl Visit for Visitor<'_> {
fn record_debug(&mut self, _field: &Field, value: &dyn std::fmt::Debug) {
let _ = write!(self.buf, " {value:?}");
}
}
let mut buf = String::new();
let _ = write!(
buf,
"[{} {}]",
event.metadata().level(),
event.metadata().target()
);
event.record(&mut Visitor { buf: &mut buf });
// `String::truncate` operates on UTF8 codepoint boundaries and will
// panic if the provided index is not one. Because we limit the log
// line by its **byte** length we can not guarantee that the index we
// want to cut at happens to be on a boundary. Therefore we fall back
// to a simple, boundarysafe loop that pops complete characters until
// the string is within the designated size.
if buf.len() > self.max_len {
// Attempt direct truncate at the byte index. If that is not a
// valid boundary we advance to the next one ( ≤3 bytes away ).
if buf.is_char_boundary(self.max_len) {
buf.truncate(self.max_len);
} else {
let mut idx = self.max_len;
while idx < buf.len() && !buf.is_char_boundary(idx) {
idx += 1;
}
buf.truncate(idx);
}
}
let sanitized = buf.replace(['\n', '\r'], " ");
let _ = self.tx.send(sanitized);
}
}

View File

@@ -188,15 +188,6 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
});
LOGGER.write_json_line(value);
}
AppEvent::LatestLog(line) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "log_line",
"line": line,
});
LOGGER.write_json_line(value);
}
// Noise or control flow record variant only
other => {
let value = json!({

View File

@@ -1,14 +0,0 @@
use ratatui::prelude::*;
/// A simple widget that just displays a list of `Line`s via a `Paragraph`.
/// This is the default rendering backend for most `HistoryCell` variants.
#[derive(Clone)]
pub(crate) struct TextBlock {
pub(crate) lines: Vec<Line<'static>>,
}
impl TextBlock {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
}
}