mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## Why Resuming a conversation that is open in another app fails with an active-writer error. Allow users to inspect its transcript and retry after closing it in the other app. ## What changed - Fall back to a read-only history snapshot on active-writer conflicts during startup and session selection. - Replace the composer with an explanatory notice, `R` to retry, exit shortcuts, and the configured transcript shortcut. - Block input and freeze active command and MCP animations while viewing the snapshot. Preserve initial prompts and existing drafts for a successful retry. - Keep the subscription when retrying the same thread and preserve read-only state across thread switches and reconnects. ## Testing Add regression tests for conflict detection, history viewing, draft preservation, same-thread retry without unsubscribing, exit shortcuts, and notice and frozen-tool rendering. GitOrigin-RevId: fa864cc73143cad9139c22e33b7958569eec20cf
323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
//! Render composition for the main chat widget surface.
|
|
|
|
use super::transcript::ActiveCellLayoutCache;
|
|
use super::transcript::ActiveCellLayoutCacheKey;
|
|
use super::*;
|
|
use crate::render::RectExt;
|
|
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::word_wrap_lines;
|
|
use ratatui::style::Styled as _;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Block;
|
|
use std::cell::Cell;
|
|
|
|
struct ExternalWriterNotice {
|
|
transcript_hint: Option<crate::key_hint::ShortcutHint>,
|
|
}
|
|
|
|
impl Renderable for ExternalWriterNotice {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
let content_width = area.width.saturating_sub(/*rhs*/ 4);
|
|
let card_lines = self.card_lines(content_width);
|
|
let card_height = (card_lines.len() as u16).saturating_add(/*rhs*/ 2);
|
|
let card = Rect::new(area.x, area.y, area.width, card_height.min(area.height));
|
|
Widget::render(
|
|
Block::default().style(crate::style::user_message_style()),
|
|
card,
|
|
buf,
|
|
);
|
|
let content = card.inset(Insets::tlbr(
|
|
/*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2,
|
|
));
|
|
Renderable::render(&Paragraph::new(card_lines), content, buf);
|
|
let footer_y = card.bottom();
|
|
if footer_y < area.bottom() {
|
|
let footer = Rect::new(
|
|
area.x.saturating_add(/*rhs*/ 2),
|
|
footer_y,
|
|
area.width.saturating_sub(/*rhs*/ 2),
|
|
area.bottom().saturating_sub(footer_y),
|
|
);
|
|
Renderable::render(
|
|
&Paragraph::new(self.footer_lines(footer.width)),
|
|
footer,
|
|
buf,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
(self.card_lines(width.saturating_sub(/*rhs*/ 4)).len() as u16)
|
|
.saturating_add(/*rhs*/ 2)
|
|
.saturating_add(self.footer_lines(width.saturating_sub(/*rhs*/ 2)).len() as u16)
|
|
}
|
|
}
|
|
|
|
impl ExternalWriterNotice {
|
|
fn card_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let title: Line<'static> = vec![
|
|
"🔒".into(),
|
|
" ".into(),
|
|
"This conversation is open in another app".bold(),
|
|
]
|
|
.into();
|
|
let retry: Line<'static> = vec![
|
|
Span::styled("R", crate::style::accent_style()),
|
|
" to Retry".into(),
|
|
]
|
|
.into();
|
|
let mut lines = word_wrap_lines(&[title], usize::from(width));
|
|
if lines.len() == 1 && lines[0].width() + retry.width() + 2 <= usize::from(width) {
|
|
let gap = usize::from(width) - lines[0].width() - retry.width();
|
|
lines[0].spans.push(" ".repeat(gap).into());
|
|
lines[0].spans.extend(retry.spans);
|
|
} else {
|
|
lines.push(retry);
|
|
}
|
|
lines.extend(word_wrap_lines(
|
|
&[Line::from(
|
|
"Close it there and press R to continue here.".dim(),
|
|
)],
|
|
RtOptions::new(usize::from(width))
|
|
.initial_indent(" ".into())
|
|
.subsequent_indent(" ".into()),
|
|
));
|
|
lines
|
|
}
|
|
|
|
fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let mut items = vec![
|
|
("r".to_string(), "retry".to_string()),
|
|
(
|
|
format!(
|
|
"{}/{}/{}",
|
|
crate::key_hint::plain(KeyCode::Esc).display_label(),
|
|
crate::key_hint::ctrl(KeyCode::Char('c')).display_label(),
|
|
crate::key_hint::plain(KeyCode::Char('q')).display_label(),
|
|
)
|
|
.replace(" + ", "+"),
|
|
"exit".to_string(),
|
|
),
|
|
];
|
|
if let Some(hint) = self.transcript_hint {
|
|
items.push((
|
|
hint.display_label().replace(" + ", "+"),
|
|
"transcript".to_string(),
|
|
));
|
|
}
|
|
let mut spans = vec![" ".set_style(crate::style::footer_hint_label_style())];
|
|
for (idx, (key, label)) in items.into_iter().enumerate() {
|
|
if idx > 0 {
|
|
spans.push(" ".set_style(crate::style::footer_hint_label_style()));
|
|
}
|
|
spans.push(key.set_style(crate::style::footer_hint_key_style()));
|
|
spans.push(format!(" {label}").set_style(crate::style::footer_hint_label_style()));
|
|
}
|
|
word_wrap_lines(&[Line::from(spans)], usize::from(width))
|
|
}
|
|
}
|
|
|
|
impl ChatWidget {
|
|
pub(crate) fn as_renderable(&self) -> RenderableItem<'_> {
|
|
if self
|
|
.bottom_pane
|
|
.selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID)
|
|
.is_some()
|
|
{
|
|
return self
|
|
.bottom_pane
|
|
.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0);
|
|
}
|
|
|
|
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
|
|
let active_cell_renderable = match &self.transcript.active_cell {
|
|
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
|
|
child: cell.as_ref(),
|
|
// The initial header becomes the first history cell, which has no leading separator.
|
|
top: if cell.as_any().is::<history_cell::SessionHeaderHistoryCell>() {
|
|
0
|
|
} else {
|
|
1
|
|
},
|
|
right: active_cell_right_reserve,
|
|
// Externally backed transcript cells can also change viewport height without an
|
|
// active-cell revision. Spinner cells remain safe because their indicator width
|
|
// is stable and their display lines are still rebuilt on every frame.
|
|
persistent_layout: cell.has_stable_transcript_height().then_some(
|
|
PersistentActiveCellLayout {
|
|
cache: &self.transcript.active_cell_layout,
|
|
cell_identity: cell.as_ref() as *const dyn HistoryCell as *const ()
|
|
as usize,
|
|
revision: self.transcript.active_cell_revision,
|
|
render_mode: self.history_render_mode(),
|
|
},
|
|
),
|
|
})),
|
|
None => RenderableItem::Owned(Box::new(())),
|
|
};
|
|
let mut flex = FlexRenderable::new();
|
|
flex.push(/*flex*/ 1, active_cell_renderable);
|
|
if let Some(cell) = self.pending_token_activity_output() {
|
|
flex.push(
|
|
/*flex*/ 1,
|
|
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
|
|
child: cell,
|
|
top: 1,
|
|
right: active_cell_right_reserve,
|
|
persistent_layout: None,
|
|
})),
|
|
);
|
|
}
|
|
if let Some(cell) = self.pending_rate_limit_reset_hint() {
|
|
flex.push(
|
|
/*flex*/ 1,
|
|
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
|
|
child: cell,
|
|
top: 1,
|
|
right: active_cell_right_reserve,
|
|
persistent_layout: None,
|
|
})),
|
|
);
|
|
}
|
|
let bottom = if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
|
RenderableItem::Owned(Box::new(ExternalWriterNotice {
|
|
transcript_hint: self.bottom_pane.transcript_shortcut_hint(),
|
|
}))
|
|
} else {
|
|
self.bottom_pane
|
|
.as_renderable_with_composer_right_reserve(active_cell_right_reserve)
|
|
};
|
|
flex.push(
|
|
/*flex*/ 0,
|
|
bottom.inset(Insets::tlbr(
|
|
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
|
)),
|
|
);
|
|
RenderableItem::Owned(Box::new(flex))
|
|
}
|
|
|
|
pub(crate) fn note_rendered_width(&self, width: u16) {
|
|
self.last_rendered_width.set(Some(width));
|
|
}
|
|
}
|
|
|
|
struct TranscriptAreaRenderable<'a> {
|
|
child: &'a dyn HistoryCell,
|
|
top: u16,
|
|
right: u16,
|
|
persistent_layout: Option<PersistentActiveCellLayout<'a>>,
|
|
}
|
|
|
|
struct PersistentActiveCellLayout<'a> {
|
|
cache: &'a Cell<Option<ActiveCellLayoutCache>>,
|
|
cell_identity: usize,
|
|
revision: u64,
|
|
render_mode: HistoryRenderMode,
|
|
}
|
|
|
|
impl Renderable for TranscriptAreaRenderable<'_> {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
let area = self.child_area(area);
|
|
let lines = self.child.display_hyperlink_lines(area.width);
|
|
let paragraph = HyperlinkParagraph::new(&lines, Style::default());
|
|
let y = if area.height == 0 {
|
|
0
|
|
} else {
|
|
let rendered_height = if let Some((cache, mut layout)) = self.layout(area.width) {
|
|
if let Some(height) = layout.rendered_height {
|
|
height
|
|
} else {
|
|
let height = paragraph.line_count(area.width);
|
|
layout.rendered_height = Some(height);
|
|
cache.set(Some(layout));
|
|
height
|
|
}
|
|
} else {
|
|
paragraph.line_count(area.width)
|
|
};
|
|
let overflow = rendered_height.saturating_sub(usize::from(area.height));
|
|
u16::try_from(overflow).unwrap_or(u16::MAX)
|
|
};
|
|
Clear.render(area, buf);
|
|
paragraph.scroll(y).render(area, buf);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
let child_width = width.saturating_sub(self.right).max(1);
|
|
let desired_height = if let Some((cache, mut layout)) = self.layout(child_width) {
|
|
if let Some(height) = layout.desired_height {
|
|
height
|
|
} else {
|
|
let height = HistoryCell::desired_height(self.child, child_width);
|
|
layout.desired_height = Some(height);
|
|
cache.set(Some(layout));
|
|
height
|
|
}
|
|
} else {
|
|
HistoryCell::desired_height(self.child, child_width)
|
|
};
|
|
desired_height + self.top
|
|
}
|
|
}
|
|
|
|
impl TranscriptAreaRenderable<'_> {
|
|
fn layout(
|
|
&self,
|
|
width: u16,
|
|
) -> Option<(&Cell<Option<ActiveCellLayoutCache>>, ActiveCellLayoutCache)> {
|
|
let persistent = self.persistent_layout.as_ref()?;
|
|
let key = ActiveCellLayoutCacheKey {
|
|
cell_identity: persistent.cell_identity,
|
|
revision: persistent.revision,
|
|
width,
|
|
render_mode: persistent.render_mode,
|
|
syntax_theme_revision: crate::render::highlight::syntax_theme_revision(),
|
|
};
|
|
let layout = persistent
|
|
.cache
|
|
.get()
|
|
.filter(|layout| layout.key == key)
|
|
.unwrap_or(ActiveCellLayoutCache {
|
|
key,
|
|
desired_height: None,
|
|
rendered_height: None,
|
|
});
|
|
Some((persistent.cache, layout))
|
|
}
|
|
|
|
fn child_area(&self, area: Rect) -> Rect {
|
|
let y = area.y.saturating_add(self.top);
|
|
let height = area.height.saturating_sub(self.top);
|
|
Rect::new(
|
|
area.x,
|
|
y,
|
|
area.width.saturating_sub(self.right).max(1),
|
|
height,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "rendering_tests.rs"]
|
|
mod tests;
|
|
|
|
impl Renderable for ChatWidget {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
self.as_renderable().render(area, buf);
|
|
self.note_rendered_width(area.width);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
self.as_renderable().desired_height(width)
|
|
}
|
|
|
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
self.as_renderable().cursor_pos(area)
|
|
}
|
|
|
|
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
|
|
self.as_renderable().cursor_style(area)
|
|
}
|
|
}
|