@@ -636,7 +636,7 @@ def count_lines_by_section(self) -> Dict[str, int]:
636636
637637 # --- markdown rendering with filter ---
638638 def to_markdown (self , section_filter : Optional [Dict [str , bool ]] = None ,
639- clean_content : bool = False , output_cap : int = 0 ) -> str :
639+ clean_content : bool = False , output_cap : int = 0 , msg_cap : int = 0 ) -> str :
640640 if section_filter is None :
641641 section_filter = {s [0 ]: True for s in SECTION_DEFS }
642642
@@ -650,6 +650,15 @@ def _cap_text(text: str) -> str:
650650 kept = lines [- output_cap :]
651651 return f'... ({ len (lines ) - output_cap } lines trimmed) ...\n ' + '\n ' .join (kept )
652652
653+ keep_indices = set (range (len (self .data )))
654+ if msg_cap > 0 :
655+ chat_msg_count = 0
656+ for i in range (len (self .data ) - 1 , - 1 , - 1 ):
657+ if self .data [i ]['type' ] in {'user_message' , 'agent_message' , 'agent_reasoning' , 'reasoning' }:
658+ if chat_msg_count >= msg_cap :
659+ keep_indices .remove (i )
660+ chat_msg_count += 1
661+
653662 md : List [str ] = []
654663 md .append (f"# { self .title } \n " )
655664 last_rendered_message = None
@@ -664,7 +673,8 @@ def _cap_text(text: str) -> str:
664673 md .append (f"CWD: { self .metadata ['cwd' ]} " )
665674 md .append ("```\n " )
666675
667- for item in self .data :
676+ for i , item in enumerate (self .data ):
677+ if i not in keep_indices : continue
668678 itype = item ['type' ]
669679 if not section_filter .get (itype , False ):
670680 continue
@@ -782,66 +792,96 @@ def read_key() -> str:
782792# ──────────────────────────────────────────────────────────────
783793
784794# Output sections whose blocks can be capped
785- OUTPUT_SECTIONS = {'terminal_output' , 'mcp_tool_output' , 'other_tool_output' ,
786- 'custom_tool_output' }
795+ OUTPUT_SECTIONS = {'terminal_output' , 'mcp_tool_output' , 'other_tool_output' , 'custom_tool_output' }
787796# Cap steps for ◀ ▶ control (0 = no cap, show all)
788797CAP_STEPS = [0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 15 , 20 , 30 , 50 , 100 , 200 , 500 ]
798+ MSG_CAP_STEPS = [0 , 5 , 10 , 20 , 50 ] + list (range (70 , 511 , 20 ))
789799
790- def interactive_filter (parsers : List [SessionParser ]) -> Tuple [Dict [str , bool ], bool , int ]:
800+ def interactive_filter (parsers : List [SessionParser ]) -> Tuple [Dict [str , bool ], bool , int , int ]:
791801 """
792802 Full-screen interactive filter.
793- Returns (section_filter, clean_content, output_cap).
803+ Returns (section_filter, clean_content, output_cap, msg_cap ).
794804 """
795- # ── Pre-compute data ──
796- agg_lines : Dict [str , int ] = {}
797- for parser in parsers :
798- for key , count in parser .count_lines_by_section ().items ():
799- agg_lines [key ] = agg_lines .get (key , 0 ) + count
805+ _line_cache = {}
806+
807+ def get_lines_for_state (cap_out : int , cap_msg : int , cc : bool ) -> Dict [str , int ]:
808+ cache_key = (cap_out , cap_msg , cc )
809+ if cache_key in _line_cache :
810+ return _line_cache [cache_key ]
811+
812+ counts = {s [0 ]: 0 for s in SECTION_DEFS }
813+ tool_call_types = {'terminal_cmd' , 'mcp_tool' , 'other_tool' }
814+ tool_output_types = {'terminal_output' , 'mcp_tool_output' , 'other_tool_output' }
815+
816+ for parser in parsers :
817+ keep_indices = set (range (len (parser .data )))
818+ if cap_msg > 0 :
819+ chat_msg_count = 0
820+ for i in range (len (parser .data ) - 1 , - 1 , - 1 ):
821+ if parser .data [i ]['type' ] in {'user_message' , 'agent_message' , 'agent_reasoning' , 'reasoning' }:
822+ if chat_msg_count >= cap_msg :
823+ keep_indices .remove (i )
824+ chat_msg_count += 1
825+
826+ for i , item in enumerate (parser .data ):
827+ if i not in keep_indices : continue
828+ itype = item ['type' ]
829+ content = item .get ('content' , '' )
830+
831+ if itype == 'user_message' :
832+ if cc : content = trim_chat_content (content )
833+ lines = content .count ('\n ' ) + 4 if content else 0
834+ elif itype == 'agent_message' :
835+ if cc : content = trim_chat_content (content )
836+ lines = content .count ('\n ' ) + 4 if content else 0
837+ elif itype in ('agent_reasoning' , 'reasoning' ):
838+ lines = 2 if not content else content .count ('\n ' ) + 3
839+ elif itype in tool_call_types :
840+ args = item .get ('arguments' , '' )
841+ lines = args .count ('\n ' ) + 5
842+ elif itype in tool_output_types :
843+ out = item .get ('output' , '' )
844+ total_out = out .count ('\n ' ) + 1 if out .strip () else 0
845+ if cap_out > 0 : lines = min (total_out , cap_out ) + 4 if total_out > 0 else 0
846+ else : lines = total_out + 4 if total_out > 0 else 0
847+ elif itype == 'custom_tool_call' :
848+ lines = content .count ('\n ' ) + 5
849+ elif itype == 'custom_tool_output' :
850+ total_out = content .count ('\n ' ) + 1 if content .strip () else 0
851+ if cap_out > 0 : lines = min (total_out , cap_out ) + 4 if total_out > 0 else 0
852+ else : lines = total_out + 4 if total_out > 0 else 0
853+ elif itype in ('web_search' , 'token_count' , 'turn_context' , 'task_event' , 'git_snapshot' , 'session_event' ):
854+ lines = 2
855+ elif itype == 'system_message' :
856+ lines = content .count ('\n ' ) + 5
857+ else :
858+ lines = 1
859+
860+ counts [itype ] = counts .get (itype , 0 ) + lines
861+
862+ if parser .metadata :
863+ counts ['session_meta' ] = counts .get ('session_meta' , 0 ) + 5
864+
865+ _line_cache [cache_key ] = counts
866+ return counts
800867
801- # Per-output-block content line counts (for cap estimation)
802- output_blocks : Dict [str , List [int ]] = {}
803- for parser in parsers :
804- for item in parser .data :
805- itype = item ['type' ]
806- if itype in OUTPUT_SECTIONS :
807- text = item .get ('output' , item .get ('content' , '' ))
808- if text .strip ():
809- output_blocks .setdefault (itype , []).append (text .count ('\n ' ) + 1 )
810-
811- # Lines affected by clean chat (user + agent messages)
812- chat_lines = agg_lines .get ('user_message' , 0 ) + agg_lines .get ('agent_message' , 0 )
813-
814- def capped_lines_for (section_key : str , cap : int ) -> int :
815- """Compute output section lines with a cap applied."""
816- if cap == 0 :
817- return agg_lines .get (section_key , 0 )
818- blocks = output_blocks .get (section_key , [])
819- return sum (min (cl , cap ) + 4 for cl in blocks )
820-
821- def effective_lines (section_key : str ) -> int :
822- """Line count for a section, respecting current output_cap."""
823- if section_key in OUTPUT_SECTIONS and output_cap > 0 :
824- return capped_lines_for (section_key , output_cap )
825- return agg_lines .get (section_key , 0 )
826-
827- # ── State ──
828868 fstate : Dict [str , bool ] = {s [0 ]: s [3 ] for s in SECTION_DEFS }
829869 clean_content = False
830- output_cap = 8 # default: 8 lines per output block
870+ output_cap = 8
831871 cap_idx = CAP_STEPS .index (8 )
872+ msg_cap = 0
873+ msg_idx = MSG_CAP_STEPS .index (0 )
832874
833875 cursor = 0
834- # Rows: sections + clean_chat + output_cap
835876 ROW_CLEAN = len (SECTION_DEFS )
836877 ROW_CAP = len (SECTION_DEFS ) + 1
837- num_items = len (SECTION_DEFS ) + 2
878+ ROW_MSG = len (SECTION_DEFS ) + 2
879+ num_items = len (SECTION_DEFS ) + 3
838880
839881 while True :
840- # ── Compute totals ──
841- total_lines = sum (effective_lines (s [0 ]) for s in SECTION_DEFS )
842- selected_lines = sum (
843- effective_lines (s [0 ]) for s in SECTION_DEFS if fstate .get (s [0 ], False )
844- )
882+ agg_lines = get_lines_for_state (output_cap , msg_cap , clean_content )
883+ total_lines = sum (agg_lines .get (s [0 ], 0 ) for s in SECTION_DEFS )
884+ selected_lines = sum (agg_lines .get (s [0 ], 0 ) for s in SECTION_DEFS if fstate .get (s [0 ], False ))
845885 pct = (selected_lines / total_lines * 100 ) if total_lines > 0 else 0
846886
847887 # ── Render ──
@@ -853,48 +893,25 @@ def effective_lines(section_key: str) -> int:
853893 for i , (key , name , emoji , _default ) in enumerate (SECTION_DEFS ):
854894 is_cursor = (i == cursor )
855895 is_on = fstate .get (key , False )
856- lines = effective_lines (key )
857- full = agg_lines .get (key , 0 )
858-
896+ lines = agg_lines .get (key , 0 )
897+
859898 arrow = f'{ Style .BOLD } { Style .YELLOW } ▸{ Style .RESET } ' if is_cursor else ' '
899+ toggle = f'{ Style .GREEN } ██{ Style .RESET } ' if is_on else f'{ Style .DIM } ░░{ Style .RESET } '
900+
901+ if is_cursor and is_on : nstyle = f'{ Style .BOLD } { Style .GREEN } '
902+ elif is_cursor and not is_on : nstyle = f'{ Style .BOLD } { Style .RED } '
903+ elif is_on : nstyle = ''
904+ else : nstyle = Style .DIM
860905
861- if is_on :
862- toggle = f'{ Style .GREEN } ██{ Style .RESET } '
863- else :
864- toggle = f'{ Style .DIM } ░░{ Style .RESET } '
865-
866- if is_cursor and is_on :
867- nstyle = f'{ Style .BOLD } { Style .GREEN } '
868- elif is_cursor and not is_on :
869- nstyle = f'{ Style .BOLD } { Style .RED } '
870- elif is_on :
871- nstyle = ''
872- else :
873- nstyle = Style .DIM
874-
875- # Line count — show capped vs full if cap is active
876- if key in OUTPUT_SECTIONS and output_cap > 0 and full != lines :
877- if lines == 0 :
878- count_str = f'{ Style .DIM } 0{ Style .RESET } '
879- elif is_on :
880- count_str = f'{ Style .CYAN } { lines :>6,} { Style .RESET } { Style .DIM } ↓{ full :,} { Style .RESET } '
881- else :
882- count_str = f'{ Style .DIM } { lines :>6,} ↓{ full :,} { Style .RESET } '
883- else :
884- if lines == 0 :
885- count_str = f'{ Style .DIM } 0{ Style .RESET } '
886- elif is_on :
887- count_str = f'{ Style .CYAN } { lines :>6,} { Style .RESET } '
888- else :
889- count_str = f'{ Style .DIM } { lines :>6,} { Style .RESET } '
906+ count_str = f'{ Style .CYAN } { lines :>6,} { Style .RESET } ' if is_on and lines > 0 else f'{ Style .DIM } { lines :>6,} { Style .RESET } '
907+ if lines == 0 : count_str = f'{ Style .DIM } 0{ Style .RESET } '
890908
891909 visible_name = f'{ emoji } { name } '
892910 pad_len = max (1 , 44 - len (visible_name ))
893911 dots = f'{ Style .DIM } { "·" * pad_len } { Style .RESET } '
894912
895913 print (f' { arrow } { toggle } { nstyle } { visible_name } { Style .RESET } { dots } { count_str } ' )
896914
897- # ── Settings rows ──
898915 print (f' { Style .DIM } { "─" * 62 } { Style .RESET } ' )
899916
900917 # Clean Chat
@@ -904,85 +921,83 @@ def effective_lines(section_key: str) -> int:
904921 cc_tog = f'{ Style .GREEN } ██{ Style .RESET } ' if cc_on else f'{ Style .DIM } ░░{ Style .RESET } '
905922 cc_st = f'{ Style .BOLD } ' if cc_cur else Style .DIM
906923 cc_val = f'{ Style .GREEN } ON { Style .RESET } ' if cc_on else f'{ Style .DIM } OFF{ Style .RESET } '
907- print (f' { cc_arrow } { cc_tog } { cc_st } ✂️ Clean Chat{ Style .RESET } '
908- f' { Style .DIM } (strips IDE context from 👤🤖){ Style .RESET } '
909- f' { Style .DIM } { chat_lines :,} L{ Style .RESET } { cc_val } ' )
924+ chat_lines = agg_lines .get ('user_message' , 0 ) + agg_lines .get ('agent_message' , 0 )
925+ print (f' { cc_arrow } { cc_tog } { cc_st } ✂️ Clean Chat{ Style .RESET } { Style .DIM } (strips IDE context from 👤🤖){ Style .RESET } { Style .DIM } { chat_lines :,} L{ Style .RESET } { cc_val } ' )
910926
911927 # Output Cap
912928 cap_cur = (cursor == ROW_CAP )
913929 cap_arrow = f'{ Style .BOLD } { Style .YELLOW } ▸{ Style .RESET } ' if cap_cur else ' '
914930 cap_st = f'{ Style .BOLD } ' if cap_cur else Style .DIM
915- if output_cap == 0 :
916- cap_label = f'{ Style .DIM } ALL{ Style .RESET } '
917- else :
918- cap_label = f'{ Style .YELLOW } { output_cap } { Style .RESET } '
931+ cap_label = f'{ Style .DIM } ALL{ Style .RESET } ' if output_cap == 0 else f'{ Style .YELLOW } { output_cap } { Style .RESET } '
919932 hint = f' { Style .DIM } ◀▶{ Style .RESET } ' if cap_cur else ''
920- print (f' { cap_arrow } { cap_st } 📏 Output Cap{ Style .RESET } '
921- f' { Style .DIM } (max lines per block){ Style .RESET } '
922- f' { cap_label } { hint } ' )
933+ print (f' { cap_arrow } { cap_st } 💻 Terminal Output Cap{ Style .RESET } { Style .DIM } (max lines/block){ Style .RESET } { cap_label } { hint } ' )
934+
935+ # Msg Cap
936+ msg_cur = (cursor == ROW_MSG )
937+ msg_arrow = f'{ Style .BOLD } { Style .YELLOW } ▸{ Style .RESET } ' if msg_cur else ' '
938+ msg_st = f'{ Style .BOLD } ' if msg_cur else Style .DIM
939+ msg_label = f'{ Style .DIM } ALL{ Style .RESET } ' if msg_cap == 0 else f'{ Style .YELLOW } Last { msg_cap } { Style .RESET } '
940+ msg_hint = f' { Style .DIM } ◀▶{ Style .RESET } ' if msg_cur else ''
941+ print (f' { msg_arrow } { msg_st } 🕒 Chat Message Cap{ Style .RESET } { Style .DIM } (blocks for 👤🤖🧠){ Style .RESET } { msg_label } { msg_hint } ' )
923942
924- # ── Footer ──
925943 print (f'\n { Style .DIM } { "━" * 62 } { Style .RESET } ' )
926944 bar_w = 30
927945 filled = int (bar_w * pct / 100 )
928946 bar = f'{ Style .GREEN } { "█" * filled } { Style .DIM } { "░" * (bar_w - filled )} { Style .RESET } '
929947 sel_c = Style .GREEN if pct > 0 else Style .RED
930- print (f' { bar } { sel_c } { Style .BOLD } { selected_lines :,} { Style .RESET } '
931- f'{ Style .DIM } /{ Style .RESET } { total_lines :,} { Style .DIM } ({ pct :.0f} %){ Style .RESET } ' )
948+ print (f' { bar } { sel_c } { Style .BOLD } { selected_lines :,} { Style .RESET } { Style .DIM } /{ Style .RESET } { total_lines :,} { Style .DIM } ({ pct :.0f} %){ Style .RESET } ' )
932949 print (f'\n { Style .DIM } ↑↓ move ⏎ toggle ◀▶ cap Q export A all N none D defaults 1-7 presets{ Style .RESET } ' )
933950
934- # ── Read key ──
935951 key = read_key ()
936952
937- if key == 'UP' :
938- cursor = (cursor - 1 ) % num_items
939- elif key == 'DOWN' :
940- cursor = (cursor + 1 ) % num_items
953+ if key == 'UP' : cursor = (cursor - 1 ) % num_items
954+ elif key == 'DOWN' : cursor = (cursor + 1 ) % num_items
941955 elif key in ('ENTER' , 'SPACE' ):
942956 if cursor < len (SECTION_DEFS ):
943957 skey = SECTION_DEFS [cursor ][0 ]
944958 fstate [skey ] = not fstate [skey ]
945959 elif cursor == ROW_CLEAN :
946960 clean_content = not clean_content
947- # ROW_CAP: enter does nothing — use ◀▶
948961 elif key == 'LEFT' :
949- cap_idx = max (0 , cap_idx - 1 )
950- output_cap = CAP_STEPS [cap_idx ]
962+ if cursor == ROW_CAP :
963+ cap_idx = max (0 , cap_idx - 1 )
964+ output_cap = CAP_STEPS [cap_idx ]
965+ elif cursor == ROW_MSG :
966+ msg_idx = max (0 , msg_idx - 1 )
967+ msg_cap = MSG_CAP_STEPS [msg_idx ]
951968 elif key == 'RIGHT' :
952- cap_idx = min (len (CAP_STEPS ) - 1 , cap_idx + 1 )
953- output_cap = CAP_STEPS [cap_idx ]
969+ if cursor == ROW_CAP :
970+ cap_idx = min (len (CAP_STEPS ) - 1 , cap_idx + 1 )
971+ output_cap = CAP_STEPS [cap_idx ]
972+ elif cursor == ROW_MSG :
973+ msg_idx = min (len (MSG_CAP_STEPS ) - 1 , msg_idx + 1 )
974+ msg_cap = MSG_CAP_STEPS [msg_idx ]
954975 elif key == 'A' :
955- for s in SECTION_DEFS :
956- fstate [s [0 ]] = True
976+ for s in SECTION_DEFS : fstate [s [0 ]] = True
957977 elif key == 'N' :
958- for s in SECTION_DEFS :
959- fstate [s [0 ]] = False
978+ for s in SECTION_DEFS : fstate [s [0 ]] = False
960979 elif key == 'I' :
961- for s in SECTION_DEFS :
962- fstate [s [0 ]] = not fstate [s [0 ]]
980+ for s in SECTION_DEFS : fstate [s [0 ]] = not fstate [s [0 ]]
963981 elif key == 'D' :
964- for s in SECTION_DEFS :
965- fstate [s [0 ]] = s [3 ]
982+ for s in SECTION_DEFS : fstate [s [0 ]] = s [3 ]
966983 clean_content = False
967984 output_cap = 8
968985 cap_idx = CAP_STEPS .index (8 )
969- elif key == 'Q' :
970- break
971- elif key == 'ESC' :
986+ msg_cap = 0
987+ msg_idx = MSG_CAP_STEPS . index ( 0 )
988+ elif key == 'Q' or key == ' ESC' :
972989 break
973990 elif key .isdigit ():
974991 pi = int (key ) - 1
975992 if 0 <= pi < len (FILTER_PRESETS ):
976993 _pname , pkeys , pclean = FILTER_PRESETS [pi ]
977994 if pkeys is None :
978- for s in SECTION_DEFS :
979- fstate [s [0 ]] = s [3 ]
995+ for s in SECTION_DEFS : fstate [s [0 ]] = s [3 ]
980996 else :
981- for s in SECTION_DEFS :
982- fstate [s [0 ]] = s [0 ] in pkeys
997+ for s in SECTION_DEFS : fstate [s [0 ]] = s [0 ] in pkeys
983998 clean_content = pclean
984999
985- return fstate , clean_content , output_cap
1000+ return fstate , clean_content , output_cap , msg_cap
9861001
9871002# ──────────────────────────────────────────────────────────────
9881003# Session List & Main Loop
@@ -1112,7 +1127,7 @@ def process_conversion(indices_str: str, files: List[Path]):
11121127 return
11131128
11141129 # Interactive filter
1115- section_filter , clean_content , output_cap = interactive_filter (parsers )
1130+ section_filter , clean_content , output_cap , msg_cap = interactive_filter (parsers )
11161131
11171132 # Check anything is selected
11181133 if not any (section_filter .values ()):
@@ -1149,6 +1164,7 @@ def process_conversion(indices_str: str, files: List[Path]):
11491164 section_filter = section_filter ,
11501165 clean_content = clean_content ,
11511166 output_cap = output_cap ,
1167+ msg_cap = msg_cap ,
11521168 )
11531169
11541170 date_prefix = datetime .fromtimestamp (
0 commit comments