fix(tui): defer token card past stream consolidation

This commit is contained in:
Felipe Coury
2026-05-30 21:09:18 -03:00
parent a4ab2153a8
commit 6b8ca2b4d2
6 changed files with 100 additions and 24 deletions
+7 -2
View File
@@ -215,12 +215,14 @@ impl App {
scrollback_reflow,
deferred_history_cell,
)?;
self.insert_completed_token_activity_output_if_ready(tui);
}
AppEvent::ConsolidateProposedPlan(source) => {
if !self.terminal_resize_reflow_enabled() {
if !self.transcript_reflow.history_cell_refresh_requested() {
self.transcript_reflow.clear();
}
self.insert_completed_token_activity_output(tui);
return Ok(AppRunControl::Continue);
}
let end = self.transcript_cells.len();
@@ -255,6 +257,7 @@ impl App {
self.maybe_finish_stream_reflow(tui)?;
}
self.insert_completed_token_activity_output_if_ready(tui);
}
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
@@ -728,14 +731,16 @@ impl App {
if let Err(err) = &result {
tracing::warn!("account/tokenUsage/read failed during TUI refresh: {err}");
}
if let Some(cell) = self
if self
.chat_widget
.finish_token_activity_refresh(request_id, result)
{
// Commit synchronously so an already queued /clear cannot overtake this card.
// Do not route through ChatWidget::add_to_history: /tokens may complete during
// active work, and flushing an in-progress tool cell would corrupt its lifecycle.
self.insert_history_cell(tui, Box::new(cell));
// If an answer stream is active, keep the settled card transient until its
// provisional transcript cells have been consolidated.
self.insert_completed_token_activity_output_if_ready(tui);
}
}
AppEvent::ConnectorsLoaded { result, is_final } => {
+18
View File
@@ -30,6 +30,24 @@ impl App {
}
}
pub(super) fn insert_completed_token_activity_output_if_ready(&mut self, tui: &mut tui::Tui) {
if self.chat_widget.token_activity_history_insertion_blocked()
|| self.transcript_cells.last().is_some_and(|cell| {
cell.as_any().is::<history_cell::AgentMessageCell>()
|| cell.as_any().is::<history_cell::ProposedPlanStreamCell>()
})
{
return;
}
self.insert_completed_token_activity_output(tui);
}
pub(super) fn insert_completed_token_activity_output(&mut self, tui: &mut tui::Tui) {
if let Some(cell) = self.chat_widget.take_completed_token_activity_output() {
self.insert_history_cell(tui, Box::new(cell));
}
}
pub(super) fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
self.chat_widget
+1
View File
@@ -551,6 +551,7 @@ pub(crate) struct ChatWidget {
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
refreshing_token_activity_output: Option<tokens::PendingTokenActivityOutput>,
completed_token_activity_output: Option<history_cell::CompositeHistoryCell>,
next_token_activity_request_id: u64,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
@@ -125,6 +125,7 @@ impl ChatWidget {
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
refreshing_token_activity_output: None,
completed_token_activity_output: None,
next_token_activity_request_id: 0,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
@@ -1199,11 +1199,10 @@ async fn clearing_pending_token_activity_refreshes_discards_late_result() {
chat.clear_pending_token_activity_refreshes();
assert!(
chat.finish_token_activity_refresh(
!chat.finish_token_activity_refresh(
request_id,
Err("stale token activity result".to_string()),
)
.is_none()
);
}
@@ -1247,8 +1246,15 @@ async fn completed_token_activity_refresh_returns_one_history_cell() {
};
assert!(chat.pending_token_activity_output().is_some());
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(chat.pending_token_activity_output().is_some());
let cell = chat
.finish_token_activity_refresh(request_id, Err("token activity unavailable".to_string()))
.take_completed_token_activity_output()
.expect("completed token activity cell");
assert!(chat.pending_token_activity_output().is_none());
@@ -1258,6 +1264,36 @@ async fn completed_token_activity_refresh_returns_one_history_cell() {
);
}
#[tokio::test]
async fn completed_token_activity_refresh_waits_for_active_stream() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Tokens);
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id,
other => panic!("expected token activity refresh request, got {other:?}"),
};
chat.on_agent_message_delta("partial response".to_string());
assert!(chat.token_activity_history_insertion_blocked());
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert_eq!(
chat.pending_token_activity_output()
.map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))),
Some("/tokens daily\n\n Token activity\n Token activity unavailable\n".to_string()),
);
chat.stream_controller = None;
assert!(!chat.token_activity_history_insertion_blocked());
assert!(chat.take_completed_token_activity_output().is_some());
}
#[tokio::test]
async fn repeated_token_activity_refreshes_keep_only_latest_card() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1280,20 +1316,14 @@ async fn repeated_token_activity_refreshes_keep_only_latest_card() {
.map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))),
Some("/tokens weekly\n\n Token activity\n Loading...\n".to_string()),
);
assert!(
chat.finish_token_activity_refresh(
first_request_id,
Err("stale token activity result".to_string()),
)
.is_none()
);
assert!(
chat.finish_token_activity_refresh(
second_request_id,
Err("token activity unavailable".to_string()),
)
.is_some()
);
assert!(!chat.finish_token_activity_refresh(
first_request_id,
Err("stale token activity result".to_string()),
));
assert!(chat.finish_token_activity_refresh(
second_request_id,
Err("token activity unavailable".to_string()),
));
}
#[tokio::test]
+26 -5
View File
@@ -642,6 +642,7 @@ impl ChatWidget {
self.next_token_activity_request_id =
self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1);
let (cell, handle) = new_token_activity_output(view);
self.completed_token_activity_output = None;
self.refreshing_token_activity_output = Some(PendingTokenActivityOutput {
request_id,
cell,
@@ -657,26 +658,46 @@ impl ChatWidget {
self.refreshing_token_activity_output
.as_ref()
.map(|output| &output.cell as &dyn HistoryCell)
.or_else(|| {
self.completed_token_activity_output
.as_ref()
.map(|cell| cell as &dyn HistoryCell)
})
}
pub(crate) fn finish_token_activity_refresh(
&mut self,
request_id: u64,
result: Result<GetAccountTokenUsageResponse, String>,
) -> Option<CompositeHistoryCell> {
let output = self.refreshing_token_activity_output.take()?;
) -> bool {
let Some(output) = self.refreshing_token_activity_output.take() else {
return false;
};
if output.request_id != request_id {
self.refreshing_token_activity_output = Some(output);
return None;
return false;
}
output.handle.finish(result);
self.completed_token_activity_output = Some(output.cell);
self.bump_active_cell_revision();
self.request_redraw();
Some(output.cell)
true
}
pub(crate) fn token_activity_history_insertion_blocked(&self) -> bool {
self.stream_controller.is_some() || self.plan_stream_controller.is_some()
}
pub(crate) fn take_completed_token_activity_output(&mut self) -> Option<CompositeHistoryCell> {
let output = self.completed_token_activity_output.take()?;
self.bump_active_cell_revision();
Some(output)
}
pub(crate) fn clear_pending_token_activity_refreshes(&mut self) {
if self.refreshing_token_activity_output.take().is_some() {
let cleared_refresh = self.refreshing_token_activity_output.take().is_some();
let cleared_completed = self.completed_token_activity_output.take().is_some();
if cleared_refresh || cleared_completed {
self.bump_active_cell_revision();
self.request_redraw();
}