@@ -1452,6 +1452,69 @@ impl TerminalState {
14521452 . chain ( active)
14531453 }
14541454
1455+ /// Plain text of the most recent completed command's output (OSC 133),
1456+ /// soft-wrapped rows joined and per-line trailing padding trimmed.
1457+ /// Returns None when no zone recorded any output or the output is blank.
1458+ /// Capped at 1 MiB so a huge scrollback range cannot balloon a clipboard
1459+ /// write.
1460+ pub fn last_command_output_text ( & self ) -> Option < String > {
1461+ const MAX_BYTES : usize = 1 << 20 ;
1462+ let zone = self
1463+ . command_zones
1464+ . iter ( )
1465+ . rev ( )
1466+ . find ( |zone| zone. output_start . is_some ( ) ) ?;
1467+ let start = zone. output_start ?;
1468+ let scrollback_len = self . scrollback . len ( ) ;
1469+ let end = zone
1470+ . output_end
1471+ . unwrap_or ( start)
1472+ . min ( scrollback_len + self . grid . rows ( ) ) ;
1473+ if end <= start {
1474+ return None ;
1475+ }
1476+ let mut out = String :: new ( ) ;
1477+ for abs_row in start..end {
1478+ let ( mut segment, wrapped) = if abs_row < scrollback_len {
1479+ let line = & self . scrollback [ abs_row] ;
1480+ let text: String = line
1481+ . decompress ( )
1482+ . iter ( )
1483+ . filter ( |cell| !cell. flags . wide_continuation ( ) )
1484+ . map ( |cell| cell. character )
1485+ . collect ( ) ;
1486+ ( text, line. is_wrapped )
1487+ } else {
1488+ let grid_row = abs_row - scrollback_len;
1489+ if grid_row >= self . grid . rows ( ) {
1490+ break ;
1491+ }
1492+ let text: String = ( 0 ..self . grid . row_len ( ) )
1493+ . map ( |col| self . grid . get ( grid_row, col) )
1494+ . filter ( |cell| !cell. flags . wide_continuation ( ) )
1495+ . map ( |cell| cell. character )
1496+ . collect ( ) ;
1497+ ( text, self . grid . row_wrapped [ grid_row] )
1498+ } ;
1499+ if !wrapped {
1500+ // Hard line end: trailing cell padding is not real output.
1501+ segment. truncate ( segment. trim_end_matches ( ' ' ) . len ( ) ) ;
1502+ }
1503+ out. push_str ( & segment) ;
1504+ if out. len ( ) >= MAX_BYTES {
1505+ break ;
1506+ }
1507+ if !wrapped && abs_row + 1 < end {
1508+ out. push ( '\n' ) ;
1509+ }
1510+ }
1511+ if out. trim ( ) . is_empty ( ) {
1512+ None
1513+ } else {
1514+ Some ( out)
1515+ }
1516+ }
1517+
14551518 /// Scroll so the closest prompt above the current view lands at the top
14561519 /// of the viewport. Returns true when the viewport moved.
14571520 pub fn jump_to_prev_prompt ( & mut self ) -> bool {
@@ -6261,6 +6324,39 @@ mod tests {
62616324 ) ;
62626325 }
62636326
6327+ #[ test]
6328+ fn last_command_output_text_returns_trimmed_output_lines ( ) {
6329+ let mut terminal = TerminalState :: new ( 20 , 6 ) ;
6330+ assert_eq ! ( terminal. last_command_output_text( ) , None ) ;
6331+
6332+ emit_zone ( & mut terminal, 0 ) ;
6333+ assert_eq ! (
6334+ terminal. last_command_output_text( ) . as_deref( ) ,
6335+ Some ( "out\n out\n out" )
6336+ ) ;
6337+
6338+ // A newer command's output supersedes the previous zone.
6339+ terminal. process_input ( b"\x1b ]133;A\x07 \x1b ]133;B\x07 " ) ;
6340+ terminal. process_input ( b"$ true\r \n " ) ;
6341+ terminal. process_input ( b"\x1b ]133;C\x07 final line\r \n \x1b ]133;D;0\x07 " ) ;
6342+ assert_eq ! (
6343+ terminal. last_command_output_text( ) . as_deref( ) ,
6344+ Some ( "final line" )
6345+ ) ;
6346+ }
6347+
6348+ #[ test]
6349+ fn last_command_output_survives_scrollback_trim_or_disappears ( ) {
6350+ let mut terminal = TerminalState :: new ( 20 , 4 ) ;
6351+ terminal. set_max_scrollback ( 6 ) ;
6352+ emit_zone ( & mut terminal, 0 ) ;
6353+ // Trim until the zone's output is gone along with its rows.
6354+ for _ in 0 ..30 {
6355+ terminal. process_input ( b"fill\r \n " ) ;
6356+ }
6357+ assert_eq ! ( terminal. last_command_output_text( ) , None ) ;
6358+ }
6359+
62646360 #[ test]
62656361 fn prompt_jumps_walk_history_and_return_to_live_view ( ) {
62666362 let mut terminal = TerminalState :: new ( 20 , 4 ) ;
0 commit comments