refactor(tui): scope conversation presentation events

This commit is contained in:
Felipe Coury
2026-07-04 16:11:51 -03:00
parent 0b0abcbbc3
commit 231945a73e
14 changed files with 480 additions and 46 deletions
+1
View File
@@ -206,6 +206,7 @@ mod app_server_events;
pub(crate) mod app_server_requests;
mod background_requests;
mod config_persistence;
mod conversation_events;
mod event_dispatch;
mod history_ui;
mod input;
@@ -0,0 +1,61 @@
//! Conversation-origin routing policy for application events.
//!
//! Widget-owned senders attach a pane generation to their events. Presentation events from a
//! retired widget are discarded. Only events whose handlers are independent of widget-local
//! request state are allowed to outlive their originating generation.
use crate::app_event::AppEvent;
use crate::app_event::ConversationOrigin;
pub(super) fn normalize_conversation_event(
event: AppEvent,
current_origin: Option<ConversationOrigin>,
) -> Option<(Option<ConversationOrigin>, AppEvent)> {
match event {
AppEvent::FromConversation { target, event } if current_origin == Some(target) => {
Some((Some(target), *event))
}
AppEvent::FromConversation { event, .. } if survives_conversation_retirement(&event) => {
Some((None, *event))
}
AppEvent::FromConversation { .. } => None,
event => Some((None, event)),
}
}
pub(super) fn survives_conversation_retirement(event: &AppEvent) -> bool {
match event {
AppEvent::Exit(_)
| AppEvent::Logout
| AppEvent::FatalExitRequest(_)
| AppEvent::ConversationOp { .. }
| AppEvent::SubmitThreadOp { .. }
| AppEvent::AppendMessageHistoryEntry { .. }
| AppEvent::SyncThreadGitBranch { .. }
| AppEvent::SetThreadGoalDraft { .. }
| AppEvent::SetThreadGoalStatus { .. }
| AppEvent::ClearThreadGoal { .. }
| AppEvent::OpenUrlInBrowser { .. }
| AppEvent::OpenDesktopThread { .. }
| AppEvent::UpdateFeatureFlags { .. }
| AppEvent::UpdateMemorySettings { .. }
| AppEvent::ResetMemories
| AppEvent::SetSkillEnabled { .. }
| AppEvent::SetAppEnabled { .. }
| AppEvent::PetDisabled
| AppEvent::StatusLineSetup { .. }
| AppEvent::TerminalTitleSetup { .. }
| AppEvent::SyntaxThemeSelected { .. }
| AppEvent::KeymapCaptured { .. }
| AppEvent::KeymapCleared { .. } => true,
AppEvent::FeedbackSubmitted {
origin_thread_id: Some(_),
..
} => true,
_ => false,
}
}
#[cfg(test)]
#[path = "conversation_events_tests.rs"]
mod tests;
@@ -0,0 +1,82 @@
use super::*;
use crate::app_event::ExitMode;
use crate::app_event::PaneGeneration;
use crate::app_event::PaneSlot;
use pretty_assertions::assert_eq;
fn origin(pane: PaneSlot) -> ConversationOrigin {
ConversationOrigin {
pane,
generation: PaneGeneration::fresh(),
}
}
#[test]
fn current_generation_dispatches_to_its_conversation() {
let current = origin(PaneSlot::Parent);
let routed = normalize_conversation_event(
AppEvent::FromConversation {
target: current,
event: Box::new(AppEvent::RawOutputModeChanged { enabled: true }),
},
Some(current),
)
.expect("current event should be delivered");
assert_eq!(routed.0, Some(current));
assert!(matches!(
routed.1,
AppEvent::RawOutputModeChanged { enabled: true }
));
}
#[test]
fn stale_presentation_event_is_dropped() {
let stale = origin(PaneSlot::Parent);
let current = origin(PaneSlot::Parent);
assert!(
normalize_conversation_event(
AppEvent::FromConversation {
target: stale,
event: Box::new(AppEvent::RawOutputModeChanged { enabled: true }),
},
Some(current),
)
.is_none()
);
}
#[test]
fn stale_widget_completion_is_dropped() {
let stale = origin(PaneSlot::Parent);
let current = origin(PaneSlot::Parent);
assert!(
normalize_conversation_event(
AppEvent::FromConversation {
target: stale,
event: Box::new(AppEvent::PluginMentionsLoaded { plugins: None }),
},
Some(current),
)
.is_none()
);
}
#[test]
fn stale_durable_event_is_delivered_without_pane_dispatch() {
let stale = origin(PaneSlot::Side);
let current = origin(PaneSlot::Parent);
let routed = normalize_conversation_event(
AppEvent::FromConversation {
target: stale,
event: Box::new(AppEvent::Exit(ExitMode::ShutdownFirst)),
},
Some(current),
)
.expect("durable event should be delivered");
assert_eq!(routed.0, None);
assert!(matches!(routed.1, AppEvent::Exit(ExitMode::ShutdownFirst)));
}
+33 -1
View File
@@ -3,6 +3,7 @@
//! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain
//! actions are delegated to focused app submodules so the central match remains the routing layer.
use super::conversation_events::normalize_conversation_event;
use super::resize_reflow::trailing_run_start;
use super::*;
use crate::config_update::format_config_error;
@@ -18,8 +19,38 @@ impl App {
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
event: AppEvent,
) -> Result<AppRunControl> {
let Some((conversation_origin, event)) =
normalize_conversation_event(event, self.chat_widget.conversation_origin())
else {
tracing::debug!("dropping event from retired conversation");
return Ok(AppRunControl::Continue);
};
let previous_sender = if let Some(origin) = conversation_origin {
let sender = self.chat_widget.conversation_event_sender();
debug_assert_eq!(sender.conversation_origin(), Some(origin));
Some(std::mem::replace(&mut self.app_event_tx, sender))
} else {
None
};
let result = self.handle_event_inner(tui, app_server, event).await;
if let Some(previous_sender) = previous_sender {
self.app_event_tx = previous_sender;
}
result
}
async fn handle_event_inner(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
event: AppEvent,
) -> Result<AppRunControl> {
match event {
AppEvent::FromConversation { .. } => {
unreachable!("conversation event envelope should be normalized before dispatch")
}
AppEvent::NewSession => {
self.start_fresh_session_with_summary_hint(
tui, app_server, /*session_start_source*/ None,
@@ -787,7 +818,8 @@ impl App {
);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
self.file_search
.on_user_query(query, self.app_event_tx.clone());
}
AppEvent::FileSearchResult { query, matches } => {
self.chat_widget.apply_file_search_result(query, matches);
@@ -325,6 +325,13 @@ impl App {
/// This helper copies every known nickname/role from `AgentNavigationState` into the
/// replacement widget so that replayed collab items render agent names immediately.
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
// A ticker belongs to the widget generation that started it. Stop it before installing a
// replacement so a stale StopCommitAnimation event cannot leave the shared latch stuck.
let retired_commit_anim_running = std::mem::replace(
&mut self.commit_anim_running,
Arc::new(AtomicBool::new(/*v*/ false)),
);
retired_commit_anim_running.store(/*val*/ false, Ordering::Release);
// Transfer the last-written terminal title to the replacement widget
// so it knows what OSC title is currently displayed. Without this, the
// new widget would redundantly clear and rewrite the same title, causing
+24 -1
View File
@@ -121,6 +121,13 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
fn conversation_event_payload(event: AppEvent) -> AppEvent {
match event {
AppEvent::FromConversation { event, .. } => *event,
event => event,
}
}
async fn next_thread_settings_updated(
app_server: &mut AppServerSession,
thread_id: ThreadId,
@@ -361,7 +368,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
let mut saw_replayed_answer = false;
let mut submitted_items = None;
while let Ok(event) = app_event_rx.try_recv() {
match event {
match conversation_event_payload(event) {
AppEvent::InsertHistoryCell(cell) => {
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
saw_replayed_answer |= transcript.contains("earlier prompt");
@@ -376,6 +383,13 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
AppEvent::CodexOp(Op::UserTurn { items, .. }) => {
submitted_items = Some(items);
}
AppEvent::ConversationOp {
target,
op: Op::UserTurn { items, .. },
} => {
assert_eq!(target.thread_id, thread_id);
submitted_items = Some(items);
}
_ => {}
}
}
@@ -5407,7 +5421,15 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
session_telemetry: app.session_telemetry.clone(),
});
let retired_commit_anim_running = app.commit_anim_running.clone();
retired_commit_anim_running.store(/*val*/ true, Ordering::Release);
app.replace_chat_widget(replacement);
assert!(!app.commit_anim_running.load(Ordering::Acquire));
assert!(!retired_commit_anim_running.load(Ordering::Acquire));
assert!(!Arc::ptr_eq(
&app.commit_anim_running,
&retired_commit_anim_running
));
app.replay_thread_snapshot(
ThreadEventSnapshot {
@@ -5441,6 +5463,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
let mut saw_named_wait = false;
while let Ok(event) = app_event_rx.try_recv() {
let event = conversation_event_payload(event);
if let AppEvent::InsertHistoryCell(cell) = event {
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
saw_named_wait |= transcript.contains("Robie [explorer]");
+52 -4
View File
@@ -9,6 +9,8 @@
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
@@ -141,15 +143,55 @@ pub(crate) enum KeymapEditIntent {
ReplaceOne { old_key: String },
}
/// Identifies the conversation surface that originated an asynchronous app event.
///
/// Keeping this as a distinct type lets the multi-pane UI extend the target with a pane
/// generation without changing every conversation-scoped event again.
/// Fixed conversation surfaces supported by the owned-screen layout.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaneSlot {
Parent,
#[allow(dead_code)]
Side,
}
/// Distinguishes successive widgets installed in the same [`PaneSlot`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PaneGeneration(u64);
impl PaneGeneration {
pub(crate) fn fresh() -> Self {
static NEXT_PANE_GENERATION: AtomicU64 = AtomicU64::new(/*v*/ 1);
Self(NEXT_PANE_GENERATION.fetch_add(/*val*/ 1, Ordering::Relaxed))
}
pub(crate) fn get(self) -> u64 {
self.0
}
}
/// Immutable identity of the widget that originated an asynchronous app event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ConversationOrigin {
pub(crate) pane: PaneSlot,
pub(crate) generation: PaneGeneration,
}
/// Identifies both a conversation widget generation and its bound thread.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ConversationTarget {
pub(crate) pane: PaneSlot,
pub(crate) generation: PaneGeneration,
pub(crate) thread_id: ThreadId,
}
impl ConversationTarget {
#[allow(dead_code)]
pub(crate) fn origin(self) -> ConversationOrigin {
ConversationOrigin {
pane: self.pane,
generation: self.generation,
}
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -176,6 +218,12 @@ pub(crate) enum AppEvent {
op: AppCommand,
},
/// Deliver a non-operation event to the conversation widget generation that emitted it.
FromConversation {
target: ConversationOrigin,
event: Box<AppEvent>,
},
/// Interrupt, roll back, and retry a safety-buffered turn with the server-selected model.
RetrySafetyBufferedTurn {
thread_id: ThreadId,
+64 -23
View File
@@ -19,74 +19,115 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use tokio::sync::mpsc::UnboundedSender;
use crate::app_event::AppEvent;
use crate::app_event::ConversationOrigin;
use crate::app_event::ConversationTarget;
use crate::app_event::PaneGeneration;
use crate::app_event::PaneSlot;
use crate::session_log;
#[derive(Clone, Debug)]
struct ConversationScope {
origin: ConversationOrigin,
thread_id: Arc<RwLock<Option<ThreadId>>>,
}
fn has_explicit_conversation_delivery(event: &AppEvent) -> bool {
matches!(
event,
AppEvent::ConversationOp { .. }
| AppEvent::FromConversation { .. }
| AppEvent::SubmitThreadOp { .. }
)
}
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
pub app_event_tx: UnboundedSender<AppEvent>,
conversation_target: Option<Arc<RwLock<Option<ConversationTarget>>>>,
conversation_scope: Option<ConversationScope>,
}
impl AppEventSender {
pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
Self {
app_event_tx,
conversation_target: None,
conversation_scope: None,
}
}
/// Returns a sender scope shared by one chat widget and all of its child views.
pub(crate) fn scoped_to_conversation(&self) -> Self {
pub(crate) fn scoped_to_conversation(&self, pane: PaneSlot) -> Self {
Self {
app_event_tx: self.app_event_tx.clone(),
conversation_target: Some(Arc::new(RwLock::default())),
conversation_scope: Some(ConversationScope {
origin: ConversationOrigin {
pane,
generation: PaneGeneration::fresh(),
},
thread_id: Arc::new(RwLock::default()),
}),
}
}
/// Binds this widget scope to the thread supplied by its session configuration.
pub(crate) fn bind_conversation_thread(&self, thread_id: ThreadId) {
let Some(target) = &self.conversation_target else {
let Some(scope) = &self.conversation_scope else {
tracing::warn!(%thread_id, "cannot bind an unscoped app event sender");
return;
};
let mut target = match target.write() {
Ok(target) => target,
let mut bound_thread_id = match scope.thread_id.write() {
Ok(thread_id) => thread_id,
Err(poisoned) => poisoned.into_inner(),
};
*target = Some(ConversationTarget { thread_id });
*bound_thread_id = Some(thread_id);
}
pub(crate) fn conversation_origin(&self) -> Option<ConversationOrigin> {
self.conversation_scope.as_ref().map(|scope| scope.origin)
}
fn conversation_target(&self) -> Option<ConversationTarget> {
let target = self.conversation_target.as_ref()?;
let target = match target.read() {
Ok(target) => target,
let scope = self.conversation_scope.as_ref()?;
let thread_id = match scope.thread_id.read() {
Ok(thread_id) => thread_id,
Err(poisoned) => poisoned.into_inner(),
};
*target
Some(ConversationTarget {
pane: scope.origin.pane,
generation: scope.origin.generation,
thread_id: (*thread_id)?,
})
}
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
pub(crate) fn send(&self, event: AppEvent) {
let event = match event {
AppEvent::CodexOp(op) if self.conversation_target.is_some() => {
// Record inbound events for high-fidelity session replay before adding the conversation
// envelope, so existing event-specific logging remains intact.
// Avoid double-logging Ops; those are logged at the point of submission.
if !matches!(
event,
AppEvent::CodexOp(_)
| AppEvent::ConversationOp { .. }
| AppEvent::FromConversation { .. }
) {
session_log::log_inbound_app_event(&event, self.conversation_origin());
}
let event = match (self.conversation_scope.as_ref(), event) {
(Some(_), AppEvent::CodexOp(op)) => {
let Some(target) = self.conversation_target() else {
tracing::warn!("dropping op from an unbound conversation sender");
return;
};
AppEvent::ConversationOp { target, op }
}
event => event,
(_, event) if has_explicit_conversation_delivery(&event) => event,
(Some(scope), event) => AppEvent::FromConversation {
target: scope.origin,
event: Box::new(event),
},
(None, event) => event,
};
// Record inbound events for high-fidelity session replay.
// Avoid double-logging Ops; those are logged at the point of submission.
if !matches!(
event,
AppEvent::CodexOp(_) | AppEvent::ConversationOp { .. }
) {
session_log::log_inbound_app_event(&event);
}
if let Err(e) = self.app_event_tx.send(event) {
tracing::error!("failed to send event: {e}");
}
+104 -6
View File
@@ -6,7 +6,7 @@ use super::*;
#[test]
fn conversation_sender_captures_bound_thread_for_codex_ops() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx).scoped_to_conversation();
let sender = AppEventSender::new(tx).scoped_to_conversation(PaneSlot::Parent);
let cloned_sender = sender.clone();
let thread_id = ThreadId::new();
sender.bind_conversation_thread(thread_id);
@@ -17,6 +17,7 @@ fn conversation_sender_captures_bound_thread_for_codex_ops() {
let AppEvent::ConversationOp { target, op } = event else {
panic!("expected conversation-scoped op");
};
assert_eq!(target.pane, PaneSlot::Parent);
assert_eq!(target.thread_id, thread_id);
assert!(matches!(op, AppCommand::Compact { .. }));
}
@@ -25,8 +26,8 @@ fn conversation_sender_captures_bound_thread_for_codex_ops() {
fn independent_conversation_senders_do_not_share_targets() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx);
let first_sender = sender.scoped_to_conversation();
let second_sender = sender.scoped_to_conversation();
let first_sender = sender.scoped_to_conversation(PaneSlot::Parent);
let second_sender = sender.scoped_to_conversation(PaneSlot::Side);
let first_thread_id = ThreadId::new();
let second_thread_id = ThreadId::new();
first_sender.bind_conversation_thread(first_thread_id);
@@ -36,10 +37,14 @@ fn independent_conversation_senders_do_not_share_targets() {
second_sender.compact();
let targets = [rx.try_recv(), rx.try_recv()].map(|event| match event {
Ok(AppEvent::ConversationOp { target, .. }) => target.thread_id,
Ok(AppEvent::ConversationOp { target, .. }) => target,
_ => panic!("expected conversation-scoped op"),
});
assert_eq!(targets, [first_thread_id, second_thread_id]);
assert_eq!(targets[0].pane, PaneSlot::Parent);
assert_eq!(targets[0].thread_id, first_thread_id);
assert_eq!(targets[1].pane, PaneSlot::Side);
assert_eq!(targets[1].thread_id, second_thread_id);
assert_ne!(targets[0].generation, targets[1].generation);
}
#[test]
@@ -58,9 +63,102 @@ fn global_sender_preserves_active_thread_codex_ops() {
#[test]
fn unbound_conversation_sender_does_not_fall_back_to_active_thread() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx).scoped_to_conversation();
let sender = AppEventSender::new(tx).scoped_to_conversation(PaneSlot::Parent);
sender.compact();
assert!(rx.try_recv().is_err());
}
#[test]
fn conversation_sender_envelopes_non_operations_before_thread_binding() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx).scoped_to_conversation(PaneSlot::Parent);
sender.send(AppEvent::NewSession);
let event = rx.try_recv().expect("conversation event should be sent");
let AppEvent::FromConversation { target, event } = event else {
panic!("expected conversation envelope");
};
assert_eq!(target.pane, PaneSlot::Parent);
assert!(matches!(*event, AppEvent::NewSession));
}
#[test]
fn replacement_sender_has_a_new_generation_for_the_same_pane() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx);
let stale_sender = sender.scoped_to_conversation(PaneSlot::Parent);
let replacement_sender = sender.scoped_to_conversation(PaneSlot::Parent);
stale_sender.send(AppEvent::ClearUi);
replacement_sender.send(AppEvent::ClearUi);
let targets = [rx.try_recv(), rx.try_recv()].map(|event| match event {
Ok(AppEvent::FromConversation { target, event }) => {
assert!(matches!(*event, AppEvent::ClearUi));
target
}
_ => panic!("expected conversation envelope"),
});
assert_eq!(targets[0].pane, PaneSlot::Parent);
assert_eq!(targets[1].pane, PaneSlot::Parent);
assert_ne!(targets[0].generation, targets[1].generation);
}
#[test]
fn explicit_thread_operation_is_not_enveloped() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx).scoped_to_conversation(PaneSlot::Parent);
let thread_id = ThreadId::new();
sender.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::compact(),
});
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::SubmitThreadOp {
thread_id: event_thread_id,
op: AppCommand::Compact { .. },
}) if event_thread_id == thread_id
));
}
#[test]
fn widget_local_thread_transport_is_enveloped() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx).scoped_to_conversation(PaneSlot::Parent);
let thread_id = ThreadId::new();
sender.send(AppEvent::LookupMessageHistoryEntry {
thread_id,
offset: 3,
log_id: 7,
});
let event = rx.try_recv().expect("conversation event should be sent");
let AppEvent::FromConversation { event, .. } = event else {
panic!("expected conversation envelope");
};
assert!(matches!(
*event,
AppEvent::LookupMessageHistoryEntry {
thread_id: event_thread_id,
offset: 3,
log_id: 7,
} if event_thread_id == thread_id
));
}
#[test]
fn global_sender_preserves_non_operation_events() {
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx);
sender.send(AppEvent::NewSession);
assert!(matches!(rx.try_recv(), Ok(AppEvent::NewSession)));
}
+2
View File
@@ -257,7 +257,9 @@ fn normalize_thread_name(name: &str) -> Option<String> {
}
use crate::app_event::AppEvent;
use crate::app_event::ConversationOrigin;
use crate::app_event::ExitMode;
use crate::app_event::PaneSlot;
use crate::app_event::PermissionProfileSelection;
use crate::app_event::RateLimitRefreshOrigin;
#[cfg(target_os = "windows")]
+9 -1
View File
@@ -4,10 +4,18 @@ use super::*;
impl ChatWidget {
pub(crate) fn new_with_app_event(mut common: ChatWidgetInit) -> Self {
common.app_event_tx = common.app_event_tx.scoped_to_conversation();
common.app_event_tx = common.app_event_tx.scoped_to_conversation(PaneSlot::Parent);
Self::new_with_op_target(common, CodexOpTarget::AppEvent)
}
pub(crate) fn conversation_origin(&self) -> Option<ConversationOrigin> {
self.app_event_tx.conversation_origin()
}
pub(crate) fn conversation_event_sender(&self) -> AppEventSender {
self.app_event_tx.clone()
}
pub(super) fn new_with_op_target(
common: ChatWidgetInit,
codex_op_target: CodexOpTarget,
@@ -451,6 +451,10 @@ async fn configured_pet_load_is_deferred_until_after_construction() {
.await
.unwrap()
.unwrap();
let event = match event {
AppEvent::FromConversation { event, .. } => *event,
event => event,
};
assert_matches!(
event,
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
+9 -1
View File
@@ -50,9 +50,17 @@ impl FileSearchManager {
}
/// Call whenever the user edits the `@` token.
pub fn on_user_query(&self, query: String) {
pub fn on_user_query(&mut self, query: String, app_tx: AppEventSender) {
let sender_changed = self.app_tx.conversation_origin() != app_tx.conversation_origin();
if sender_changed {
self.app_tx = app_tx;
}
#[expect(clippy::unwrap_used)]
let mut st = self.state.lock().unwrap();
if sender_changed {
st.session.take();
st.latest_query.clear();
}
if query == st.latest_query {
return;
}
+28 -9
View File
@@ -12,6 +12,8 @@ use serde::Serialize;
use serde_json::json;
use crate::app_event::AppEvent;
use crate::app_event::ConversationOrigin;
use crate::app_event::PaneSlot;
static LOGGER: LazyLock<SessionLogger> = LazyLock::new(SessionLogger::new);
@@ -119,7 +121,7 @@ pub(crate) fn maybe_init(config: &Config) {
LOGGER.write_json_line(header);
}
pub(crate) fn log_inbound_app_event(event: &AppEvent) {
pub(crate) fn log_inbound_app_event(event: &AppEvent, origin: Option<ConversationOrigin>) {
// Log only if enabled
if !LOGGER.is_enabled() {
return;
@@ -132,7 +134,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"dir": "to_tui",
"kind": "new_session",
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
AppEvent::ClearUi => {
let value = json!({
@@ -140,7 +142,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"dir": "to_tui",
"kind": "clear_ui",
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
AppEvent::InsertHistoryCell(cell) => {
let value = json!({
@@ -149,7 +151,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"kind": "insert_history_cell",
"lines": cell.transcript_lines(u16::MAX).len(),
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
AppEvent::StartFileSearch(query) => {
let value = json!({
@@ -158,7 +160,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"kind": "file_search_start",
"query": query,
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
AppEvent::FileSearchResult { query, matches } => {
let value = json!({
@@ -168,7 +170,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"query": query,
"matches": matches.len(),
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
AppEvent::PetPreviewLoaded { request_id, result } => {
let value = json!({
@@ -179,7 +181,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"request_id": request_id,
"ok": result.is_ok(),
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
AppEvent::PetSelectionLoaded {
request_id,
@@ -195,7 +197,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"pet_id": pet_id,
"ok": result.is_ok(),
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
// Noise or control flow – record variant only
other => {
@@ -205,11 +207,28 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"kind": "app_event",
"variant": format!("{other:?}").split('(').next().unwrap_or("app_event"),
});
LOGGER.write_json_line(value);
write_inbound_app_event(value, origin);
}
}
}
fn write_inbound_app_event(mut value: serde_json::Value, origin: Option<ConversationOrigin>) {
if let Some(origin) = origin
&& let Some(fields) = value.as_object_mut()
{
let pane = match origin.pane {
PaneSlot::Parent => "parent",
PaneSlot::Side => "side",
};
fields.insert("conversation_pane".to_string(), json!(pane));
fields.insert(
"conversation_generation".to_string(),
json!(origin.generation.get()),
);
}
LOGGER.write_json_line(value);
}
pub(crate) fn log_outbound_op(op: &AppCommand) {
if !LOGGER.is_enabled() {
return;