Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 234 additions & 24 deletions app/src/pane_group/mod.rs

Large diffs are not rendered by default.

429 changes: 429 additions & 0 deletions app/src/pane_group/mod_tests.rs

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions app/src/terminal/model/block/serialized_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,73 @@ impl SerializedBlock {
};
has_block_failed(self.exit_code, block_state)
}

/// A best-effort plain-text approximation of this block's command line,
/// stripping ANSI/OSC escape sequences from the raw styled bytes. This is
/// a lossy shortcut, not the full ANSI-aware parsing `restore_block`
/// performs; it exists only to give a pane a usable tab title while its
/// real restoration is still deferred (see
/// `FeatureFlag::LazyBackgroundTabScrollbackRestore`), before its blocks
/// have ever been fed through the real terminal model.
pub fn plain_text_command_preview(&self) -> Option<String> {
if !self.did_execute {
return None;
}
let text = strip_ansi_escapes_for_preview(&self.stylized_command);
(!text.is_empty()).then_some(text)
}
}

/// Strips ANSI CSI/OSC escape sequences from `bytes` and returns the first
/// non-blank line of the remaining printable text. Deliberately not a full
/// VTE parser (which requires a live grid to interpret correctly); good
/// enough for a short preview string, not for faithful rendering.
fn strip_ansi_escapes_for_preview(bytes: &[u8]) -> String {
let mut result = String::new();
let mut i = 0;
while i < bytes.len() {
let byte = bytes[i];
if byte == 0x1b {
match bytes.get(i + 1) {
Some(b'[') => {
// CSI: skip to and past the final byte in 0x40..=0x7e.
i += 2;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
i = (i + 1).min(bytes.len());
}
Some(b']') => {
// OSC: skip to and past the BEL or ST (ESC \) terminator.
i += 2;
while i < bytes.len() && bytes[i] != 0x07 {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\') {
i += 2;
break;
}
i += 1;
}
if i < bytes.len() && bytes[i] == 0x07 {
i += 1;
}
}
_ => i += 2,
}
continue;
}
if byte == b'\r' || byte == b'\n' {
if !result.trim().is_empty() {
break;
}
i += 1;
continue;
}
if byte.is_ascii_graphic() || byte == b' ' || byte == b'\t' {
result.push(byte as char);
}
i += 1;
}
result.trim().to_string()
}

/// We should only be serializing a block that has finished.
Expand Down
56 changes: 56 additions & 0 deletions app/src/terminal/model/block/serialized_block_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,59 @@ fn from_json_accepts_integer_array_bytes() {
assert_eq!(block.stylized_command, b"echo hello");
assert_eq!(block.stylized_output, b"hello world");
}

/// APP-5257: `plain_text_command_preview` is used to give a background tab a
/// usable title before its real restoration has ever run. It must handle
/// plain commands, ones wrapped in CSI (color) escapes, and ones interleaved
/// with an OSC (e.g. title-setting) escape sequence, matching what a shell
/// with syntax highlighting or a title-setting hook would actually emit.
#[test]
fn plain_text_command_preview_strips_plain_command() {
let block = SerializedBlock::new_for_test(b"pwd".to_vec(), b"/home/user\n".to_vec());
assert_eq!(block.plain_text_command_preview().as_deref(), Some("pwd"));
}

#[test]
fn plain_text_command_preview_strips_csi_color_codes() {
let stylized = b"\x1b[32mecho\x1b[0m TAB2_MARKER_BRAVO".to_vec();
let block = SerializedBlock::new_for_test(stylized, Vec::new());
assert_eq!(
block.plain_text_command_preview().as_deref(),
Some("echo TAB2_MARKER_BRAVO")
);
}

#[test]
fn plain_text_command_preview_strips_osc_title_sequence() {
// A shell configured to set the terminal title to the running command
// (a common preexec hook) could plausibly interleave an OSC sequence
// with the echoed command bytes.
let stylized = b"\x1b]0;uname -a\x07uname -a".to_vec();
let block = SerializedBlock::new_for_test(stylized, Vec::new());
assert_eq!(
block.plain_text_command_preview().as_deref(),
Some("uname -a")
);
}

#[test]
fn plain_text_command_preview_takes_only_the_first_line() {
let block = SerializedBlock::new_for_test(b"echo one\necho two".to_vec(), Vec::new());
assert_eq!(
block.plain_text_command_preview().as_deref(),
Some("echo one")
);
}

#[test]
fn plain_text_command_preview_is_none_for_a_block_that_never_executed() {
let mut block = SerializedBlock::new_for_test(b"pwd".to_vec(), Vec::new());
block.did_execute = false;
assert_eq!(block.plain_text_command_preview(), None);
}

#[test]
fn plain_text_command_preview_is_none_for_empty_command() {
let block = SerializedBlock::new_for_test(Vec::new(), Vec::new());
assert_eq!(block.plain_text_command_preview(), None);
}
60 changes: 59 additions & 1 deletion app/src/terminal/model/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,7 @@ impl BlockList {
}

#[cfg(feature = "local_fs")]
pub(in crate::terminal) fn append_session_restoration_separator_to_block_list(
pub fn append_session_restoration_separator_to_block_list(
&mut self,
is_historical_conversation_restoration: bool,
) {
Expand All @@ -1013,6 +1013,64 @@ impl BlockList {
);
}

/// Applies scrollback blocks whose restoration was deferred at startup.
/// No-ops if the live session is no longer pristine (a command has
/// already started in it): the restored content is strictly older than
/// anything the live session could have produced, and this list only
/// supports appending, so splicing it in now would force-finish and
/// misorder real, possibly still-running, output. Returns whether the
/// blocks were applied.
pub fn apply_deferred_restored_blocks(
&mut self,
restored_blocks: &[SerializedBlockListItem],
) -> bool {
let valid_blocks: Vec<&SerializedBlock> = restored_blocks
.iter()
.filter_map(|item| match item {
SerializedBlockListItem::Command { block }
if block.start_ts.is_some() && block.completed_ts.is_some() =>
{
Some(block.as_ref())
}
_ => None,
})
.collect();

if valid_blocks.is_empty() {
return false;
}

// A still-bootstrapping session's active block can already be
// `started()` (e.g. the script-execution stage), which is expected
// and harmless to finish here. Only bail once bootstrapping is done
// and a real post-bootstrap command has genuinely started.
if self.is_bootstrapping_precmd_done() && self.active_block().started() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this guard fires the restored scrollback is silently dropped from the live view rather than spliced in, because BlockList.blocks is append-only and cannot interleave history ahead of live content without a reindexing change. Dropping beats corrupting a running command, and the persisted blocks on disk are untouched, but the user sees a restored tab that is missing its history with only a Sentry report to show for it. Worth confirming this is the tradeoff you want, and worth watching that report_error! after rollout to learn how often it actually fires.

Responding as wilson: Open session · View factory task

report_error!(
"Dropping deferred scrollback restoration: session is no longer pristine"
);
return false;
}

self.is_restored_session = true;
self.restored_session_ts = valid_blocks.last().and_then(|block| block.completed_ts);

self.finish_active_block_before_followup_append();

let mut processor = Processor::new();
for block in valid_blocks {
// Tag as `RestoreBlocks` (matching eager restoration via
// `initialize`), not `PostBootstrapPrecmd` like
// `append_followup_shared_session_scrollback` uses: this content
// genuinely comes from a previous session, so it should render
// with the same "restored" treatment eager restoration gives it.
self.restore_block(block, BootstrapStage::RestoreBlocks, &mut processor);
}

self.ensure_active_block_after_shared_session_scrollback();
self.event_proxy.send_wakeup_event();
true
}

/// Inserts an inline banner _before_ the provided block_index.
pub fn insert_inline_banner_before_block(
&mut self,
Expand Down
107 changes: 107 additions & 0 deletions app/src/terminal/model/blocks_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,113 @@ pub fn test_restore_completed_blocks() {
));
}

/// APP-5257: `apply_deferred_restored_blocks` backfills scrollback into an
/// already-bootstrapped, live block list (mirroring how a background tab's
/// deferred restoration is applied once it's activated). The already-active
/// (empty, never-typed-into) block must not linger as visible clutter, the
/// restored blocks must land ahead of a fresh active block, and each restored
/// block must fire a `Restored` completion event just like eager restoration
/// does.
#[test]
pub fn test_apply_deferred_restored_blocks_after_bootstrap() {
let (events_tx, events_rx) = async_channel::unbounded();
let channel_event_proxy = ChannelEventListener::builder_for_test()
.with_terminal_events_tx(events_tx)
.build();

// Simulates a background tab whose scrollback restoration was deferred at
// startup: the session still bootstraps eagerly (with no restored
// blocks), leaving a live, empty, ready-for-input block list.
let mut block_list = new_bootstrapped_block_list(None, None, channel_event_proxy);
let blocks_before_restore = block_list.blocks().len();
drain_terminal_events(&events_rx);

let serialized_block: SerializedBlockListItem =
SerializedBlock::new_for_test("i am".into(), "restored".into()).into();
let deferred_blocks = [serialized_block.clone(), serialized_block];

block_list.apply_deferred_restored_blocks(&deferred_blocks);

assert_eq!(block_list.blocks().len(), blocks_before_restore + 3);
assert!(block_list.blocks()[blocks_before_restore].is_restored());
assert!(block_list.blocks()[blocks_before_restore + 1].is_restored());
assert!(
block_list.blocks()[blocks_before_restore - 1].is_empty(&TranscriptScope::Terminal),
"the previously-active empty block must not become visible clutter"
);
assert!(
!block_list.active_block().finished(),
"a fresh active block should be ready for input after materializing"
);
assert!(block_list.is_restored_session());

let mut block_completed_events = Vec::new();
while let Ok(event) = events_rx.try_recv() {
if let Event::AfterBlockCompleted(block_completed_type) = event {
block_completed_events.push(block_completed_type);
}
}
assert_eq!(block_completed_events.len(), 2);
assert!(matches!(
block_completed_events[0].block_type,
BlockType::Restored
));
assert!(matches!(
block_completed_events[1].block_type,
BlockType::Restored
));
}

/// A second call after the first is fed no new blocks and must be a no-op.
#[test]
pub fn test_apply_deferred_restored_blocks_is_a_noop_when_given_no_blocks() {
let mut block_list =
new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test());
let blocks_before = block_list.blocks().len();

block_list.apply_deferred_restored_blocks(&[]);

assert_eq!(block_list.blocks().len(), blocks_before);
assert!(!block_list.is_restored_session());
}

/// APP-5257: a background tab's session stays live while its restoration is
/// deferred, so it can produce genuine content (e.g. via synced input)
/// before ever being activated. Applying the deferred (strictly older)
/// blocks in that case must not force-finish or reorder the live, currently
/// running command — the restoration must be dropped instead.
#[test]
pub fn test_apply_deferred_restored_blocks_does_not_corrupt_a_live_running_command() {
let mut block_list =
new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test());

block_list.start_active_block();
input_string(&mut block_list, "sleep 100");
block_list.preexec(Default::default());
assert!(block_list.active_block().started());
assert!(!block_list.active_block().finished());

let serialized_block: SerializedBlockListItem =
SerializedBlock::new_for_test("i am".into(), "restored".into()).into();

let applied = block_list.apply_deferred_restored_blocks(&[serialized_block]);

assert!(
!applied,
"deferred restoration must be dropped rather than corrupt a live, running command"
);
assert!(
!block_list.active_block().finished(),
"the live command must not be force-finished by a deferred restoration attempt"
);
assert_eq!(
block_list.active_block().command_to_string(),
"sleep 100",
"the live command's content must be untouched"
);
assert!(!block_list.is_restored_session());
}

#[test]
pub fn test_restore_blocks_with_local_status() {
let (events_tx, events_rx) = async_channel::unbounded();
Expand Down
7 changes: 7 additions & 0 deletions app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2870,6 +2870,12 @@ pub struct TerminalView {
// we want to keep the title as the conversation title, so we should ignore the model event setting the title after bootstrapping finishes
ignore_next_set_title_event: bool,

/// A tab-title fallback for a pane whose scrollback restoration was
/// deferred (see `FeatureFlag::LazyBackgroundTabScrollbackRestore`),
/// computed once from the still-pending blocks so the tab has a usable
/// label before those blocks are ever fed into this view's live model.
pending_restoration_title_hint: Option<String>,

cli_subagent_views: HashMap<BlockId, ViewHandle<CLISubagentView>>,
cli_subagent_controller: ModelHandle<CLISubagentController>,
use_agent_footer: ViewHandle<UseAgentToolbar>,
Expand Down Expand Up @@ -4444,6 +4450,7 @@ impl TerminalView {
current_repo_path: None,
terminal_title: Default::default(),
ignore_next_set_title_event: false,
pending_restoration_title_hint: None,
cli_subagent_views: Default::default(),
cli_subagent_controller,
use_agent_footer: use_agent_button_bar,
Expand Down
2 changes: 1 addition & 1 deletion app/src/terminal/view/load_ai_conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ impl TerminalView {
/// In this case, we expect shell command blocks to already exist in the terminal model, since they must
/// be restored before bootstrapping finishes.
/// Then we need to order the AI blocks correctly relative to shell commands that exist in the model.
pub(super) fn restore_conversations_on_view_creation(
pub(crate) fn restore_conversations_on_view_creation(
&mut self,
conversation_restoration: ConversationRestorationInNewPaneType,
ctx: &mut ViewContext<Self>,
Expand Down
39 changes: 24 additions & 15 deletions app/src/terminal/view/tab_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,32 @@ impl TerminalView {
}

pub fn last_completed_command_text(&self) -> Option<String> {
let model = self.model.lock();
model.block_list().blocks().iter().rev().find_map(|block| {
if block.finished()
&& !block.is_background()
&& !block.is_static()
&& (block.bootstrap_stage().is_done() || block.is_restored())
{
let cmd = block.command_to_string();
if cmd.trim().is_empty() {
None
let from_live_blocks = {
let model = self.model.lock();
model.block_list().blocks().iter().rev().find_map(|block| {
if block.finished()
&& !block.is_background()
&& !block.is_static()
&& (block.bootstrap_stage().is_done() || block.is_restored())
{
let cmd = block.command_to_string();
if cmd.trim().is_empty() {
None
} else {
Some(cmd)
}
} else {
Some(cmd)
None
}
} else {
None
}
})
})
};
from_live_blocks.or_else(|| self.pending_restoration_title_hint.clone())
}

/// Sets the tab-title fallback shown while this pane's scrollback
/// restoration is deferred. See `pending_restoration_title_hint`.
pub(crate) fn set_pending_restoration_title_hint(&mut self, hint: Option<String>) {
self.pending_restoration_title_hint = hint;
}

pub fn terminal_title_text(&self) -> String {
Expand Down
Loading