1111import time
1212import _colorize
1313
14- from ..collector import Collector
14+ from ..collector import Collector , extract_lineno
1515from ..constants import (
1616 THREAD_STATUS_HAS_GIL ,
1717 THREAD_STATUS_ON_CPU ,
4141 COLOR_PAIR_SORTED_HEADER ,
4242)
4343from .display import CursesDisplay
44- from .widgets import HeaderWidget , TableWidget , FooterWidget , HelpWidget
44+ from .widgets import HeaderWidget , TableWidget , FooterWidget , HelpWidget , OpcodePanel
4545from .trend_tracker import TrendTracker
4646
4747
@@ -67,6 +67,11 @@ class ThreadData:
6767 sample_count : int = 0
6868 gc_frame_samples : int = 0
6969
70+ # Opcode statistics: {location: {opcode: count}}
71+ opcode_stats : dict = field (default_factory = lambda : collections .defaultdict (
72+ lambda : collections .defaultdict (int )
73+ ))
74+
7075 def increment_status_flag (self , status_flags ):
7176 """Update status counts based on status bit flags."""
7277 if status_flags & THREAD_STATUS_HAS_GIL :
@@ -103,6 +108,7 @@ def __init__(
103108 pid = None ,
104109 display = None ,
105110 mode = None ,
111+ opcodes = False ,
106112 ):
107113 """
108114 Initialize the live stats collector.
@@ -115,6 +121,7 @@ def __init__(
115121 pid: Process ID being profiled
116122 display: DisplayInterface implementation (None means curses will be used)
117123 mode: Profiling mode ('cpu', 'gil', etc.) - affects what stats are shown
124+ opcodes: Whether to show opcode panel (requires --opcodes flag)
118125 """
119126 self .result = collections .defaultdict (
120127 lambda : dict (total_rec_calls = 0 , direct_calls = 0 , cumulative_calls = 0 )
@@ -152,6 +159,12 @@ def __init__(
152159 }
153160 self .gc_frame_samples = 0 # Track samples with GC frames
154161
162+ # Opcode statistics: {location: {opcode: count}}
163+ self .opcode_stats = collections .defaultdict (lambda : collections .defaultdict (int ))
164+ self .show_opcodes = opcodes # Show opcode panel when --opcodes flag is passed
165+ self .selected_row = 0 # Currently selected row in table for opcode view
166+ self .scroll_offset = 0 # Scroll offset for table when in opcode mode
167+
155168 # Interactive controls state
156169 self .paused = False # Pause UI updates (profiling continues)
157170 self .show_help = False # Show help screen
@@ -178,6 +191,7 @@ def __init__(
178191 self .table_widget = None
179192 self .footer_widget = None
180193 self .help_widget = None
194+ self .opcode_panel = None
181195
182196 # Color mode
183197 self ._can_colorize = _colorize .can_colorize ()
@@ -282,18 +296,29 @@ def process_frames(self, frames, thread_id=None):
282296 thread_data = self ._get_or_create_thread_data (thread_id ) if thread_id is not None else None
283297
284298 # Process each frame in the stack to track cumulative calls
299+ # frame.location is (lineno, end_lineno, col_offset, end_col_offset), int, or None
285300 for frame in frames :
286- location = (frame .filename , frame .lineno , frame .funcname )
301+ lineno = extract_lineno (frame .location )
302+ location = (frame .filename , lineno , frame .funcname )
287303 self .result [location ]["cumulative_calls" ] += 1
288304 if thread_data :
289305 thread_data .result [location ]["cumulative_calls" ] += 1
290306
291307 # The top frame gets counted as an inline call (directly executing)
292- top_location = (frames [0 ].filename , frames [0 ].lineno , frames [0 ].funcname )
308+ top_frame = frames [0 ]
309+ top_lineno = extract_lineno (top_frame .location )
310+ top_location = (top_frame .filename , top_lineno , top_frame .funcname )
293311 self .result [top_location ]["direct_calls" ] += 1
294312 if thread_data :
295313 thread_data .result [top_location ]["direct_calls" ] += 1
296314
315+ # Track opcode for top frame (the actively executing instruction)
316+ opcode = getattr (top_frame , 'opcode' , None )
317+ if opcode is not None :
318+ self .opcode_stats [top_location ][opcode ] += 1
319+ if thread_data :
320+ thread_data .opcode_stats [top_location ][opcode ] += 1
321+
297322 def collect_failed_sample (self ):
298323 self .failed_samples += 1
299324 self .total_samples += 1
@@ -431,6 +456,7 @@ def _initialize_widgets(self, colors):
431456 self .table_widget = TableWidget (self .display , colors , self )
432457 self .footer_widget = FooterWidget (self .display , colors , self )
433458 self .help_widget = HelpWidget (self .display , colors )
459+ self .opcode_panel = OpcodePanel (self .display , colors , self )
434460
435461 def _render_display_sections (
436462 self , height , width , elapsed , stats_list , colors
@@ -451,6 +477,12 @@ def _render_display_sections(
451477 line , width , height = height , stats_list = stats_list
452478 )
453479
480+ # Render opcode panel if enabled
481+ if self .show_opcodes :
482+ line = self .opcode_panel .render (
483+ line , width , height = height , stats_list = stats_list
484+ )
485+
454486 except curses .error :
455487 pass
456488
@@ -918,19 +950,148 @@ def _handle_input(self):
918950 if self ._trend_tracker is not None :
919951 self ._trend_tracker .toggle ()
920952
921- elif ch == curses .KEY_LEFT or ch == curses .KEY_UP :
922- # Navigate to previous thread in PER_THREAD mode, or switch from ALL to PER_THREAD
953+ elif ch == ord ("j" ) or ch == ord ("J" ):
954+ # Move selection down in opcode mode (with scrolling)
955+ if self .show_opcodes :
956+ # Use the actual displayed stats_list count, not raw result_source
957+ # This matches what _prepare_display_data() produces
958+ stats_list = self .build_stats_list ()
959+ if self .display :
960+ height , _ = self .display .get_dimensions ()
961+ # Same calculation as _prepare_display_data
962+ extra_header = FINISHED_BANNER_EXTRA_LINES if self .finished else 0
963+ max_stats = max (0 , height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN )
964+ stats_list = stats_list [:max_stats ]
965+ visible_rows = max (1 , height - 8 - 2 - 12 )
966+ else :
967+ visible_rows = self .limit
968+ total_rows = len (stats_list )
969+ if total_rows == 0 :
970+ return
971+ # Max scroll is when last item is at bottom
972+ max_scroll = max (0 , total_rows - visible_rows )
973+ # Current absolute position
974+ abs_pos = self .scroll_offset + self .selected_row
975+ # Only move if not at the last item
976+ if abs_pos < total_rows - 1 :
977+ # Try to move selection within visible area first
978+ if self .selected_row < visible_rows - 1 :
979+ self .selected_row += 1
980+ elif self .scroll_offset < max_scroll :
981+ # Scroll down
982+ self .scroll_offset += 1
983+ # Clamp to valid range
984+ self .scroll_offset = min (self .scroll_offset , max_scroll )
985+ max_selected = min (visible_rows - 1 , total_rows - self .scroll_offset - 1 )
986+ self .selected_row = min (self .selected_row , max (0 , max_selected ))
987+
988+ elif ch == ord ("k" ) or ch == ord ("K" ):
989+ # Move selection up in opcode mode (with scrolling)
990+ if self .show_opcodes :
991+ if self .selected_row > 0 :
992+ self .selected_row -= 1
993+ elif self .scroll_offset > 0 :
994+ self .scroll_offset -= 1
995+ # Clamp to valid range based on actual stats_list
996+ stats_list = self .build_stats_list ()
997+ if self .display :
998+ height , _ = self .display .get_dimensions ()
999+ extra_header = FINISHED_BANNER_EXTRA_LINES if self .finished else 0
1000+ max_stats = max (0 , height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN )
1001+ stats_list = stats_list [:max_stats ]
1002+ visible_rows = max (1 , height - 8 - 2 - 12 )
1003+ else :
1004+ visible_rows = self .limit
1005+ total_rows = len (stats_list )
1006+ if total_rows > 0 :
1007+ max_scroll = max (0 , total_rows - visible_rows )
1008+ self .scroll_offset = min (self .scroll_offset , max_scroll )
1009+ max_selected = min (visible_rows - 1 , total_rows - self .scroll_offset - 1 )
1010+ self .selected_row = min (self .selected_row , max (0 , max_selected ))
1011+
1012+ elif ch == curses .KEY_UP :
1013+ # Move selection up (same as 'k') when in opcode mode
1014+ if self .show_opcodes :
1015+ if self .selected_row > 0 :
1016+ self .selected_row -= 1
1017+ elif self .scroll_offset > 0 :
1018+ self .scroll_offset -= 1
1019+ # Clamp to valid range based on actual stats_list
1020+ stats_list = self .build_stats_list ()
1021+ if self .display :
1022+ height , _ = self .display .get_dimensions ()
1023+ extra_header = FINISHED_BANNER_EXTRA_LINES if self .finished else 0
1024+ max_stats = max (0 , height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN )
1025+ stats_list = stats_list [:max_stats ]
1026+ visible_rows = max (1 , height - 8 - 2 - 12 )
1027+ else :
1028+ visible_rows = self .limit
1029+ total_rows = len (stats_list )
1030+ if total_rows > 0 :
1031+ max_scroll = max (0 , total_rows - visible_rows )
1032+ self .scroll_offset = min (self .scroll_offset , max_scroll )
1033+ max_selected = min (visible_rows - 1 , total_rows - self .scroll_offset - 1 )
1034+ self .selected_row = min (self .selected_row , max (0 , max_selected ))
1035+ else :
1036+ # Navigate to previous thread (same as KEY_LEFT)
1037+ if len (self .thread_ids ) > 0 :
1038+ if self .view_mode == "ALL" :
1039+ self .view_mode = "PER_THREAD"
1040+ self .current_thread_index = len (self .thread_ids ) - 1
1041+ else :
1042+ self .current_thread_index = (
1043+ self .current_thread_index - 1
1044+ ) % len (self .thread_ids )
1045+
1046+ elif ch == curses .KEY_DOWN :
1047+ # Move selection down (same as 'j') when in opcode mode
1048+ if self .show_opcodes :
1049+ stats_list = self .build_stats_list ()
1050+ if self .display :
1051+ height , _ = self .display .get_dimensions ()
1052+ extra_header = FINISHED_BANNER_EXTRA_LINES if self .finished else 0
1053+ max_stats = max (0 , height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN )
1054+ stats_list = stats_list [:max_stats ]
1055+ visible_rows = max (1 , height - 8 - 2 - 12 )
1056+ else :
1057+ visible_rows = self .limit
1058+ total_rows = len (stats_list )
1059+ if total_rows == 0 :
1060+ return
1061+ max_scroll = max (0 , total_rows - visible_rows )
1062+ abs_pos = self .scroll_offset + self .selected_row
1063+ if abs_pos < total_rows - 1 :
1064+ if self .selected_row < visible_rows - 1 :
1065+ self .selected_row += 1
1066+ elif self .scroll_offset < max_scroll :
1067+ self .scroll_offset += 1
1068+ self .scroll_offset = min (self .scroll_offset , max_scroll )
1069+ max_selected = min (visible_rows - 1 , total_rows - self .scroll_offset - 1 )
1070+ self .selected_row = min (self .selected_row , max (0 , max_selected ))
1071+ else :
1072+ # Navigate to next thread (same as KEY_RIGHT)
1073+ if len (self .thread_ids ) > 0 :
1074+ if self .view_mode == "ALL" :
1075+ self .view_mode = "PER_THREAD"
1076+ self .current_thread_index = 0
1077+ else :
1078+ self .current_thread_index = (
1079+ self .current_thread_index + 1
1080+ ) % len (self .thread_ids )
1081+
1082+ elif ch == curses .KEY_LEFT :
1083+ # Navigate to previous thread
9231084 if len (self .thread_ids ) > 0 :
9241085 if self .view_mode == "ALL" :
9251086 self .view_mode = "PER_THREAD"
926- self .current_thread_index = 0
1087+ self .current_thread_index = len ( self . thread_ids ) - 1
9271088 else :
9281089 self .current_thread_index = (
9291090 self .current_thread_index - 1
9301091 ) % len (self .thread_ids )
9311092
932- elif ch == curses .KEY_RIGHT or ch == curses . KEY_DOWN :
933- # Navigate to next thread in PER_THREAD mode, or switch from ALL to PER_THREAD
1093+ elif ch == curses .KEY_RIGHT :
1094+ # Navigate to next thread
9341095 if len (self .thread_ids ) > 0 :
9351096 if self .view_mode == "ALL" :
9361097 self .view_mode = "PER_THREAD"
0 commit comments