From 73d1f3367aed0d2b08dac078df59a4c124d6d1dd Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Thu, 9 Apr 2026 18:14:56 -0700 Subject: [PATCH 01/13] Strip (CR)LF from titles --- tools/chat_sessions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 203274b9..275c2e5f 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -309,6 +309,8 @@ def list_sessions( if reqs: first_msg = reqs[0].get("user", "")[:80] label = title or first_msg or "(empty)" + # Remove newlines to prevent formatting issues + label = label.replace("\n", " ").replace("\r", "") date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") print(f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}") From 09c20892e001f101dfad22a34e73cee540405fda Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Thu, 9 Apr 2026 18:31:56 -0700 Subject: [PATCH 02/13] Clip lines to current terminal width in overview and searcg listings --- tools/chat_sessions.py | 53 +++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 275c2e5f..0e852e83 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -18,13 +18,16 @@ from collections.abc import Iterator import contextlib import datetime +import fcntl import io import json import os from pathlib import Path import shutil +import struct import subprocess import sys +import termios import textwrap from typing import Any @@ -292,13 +295,23 @@ def format_timestamp(ts: int | None) -> str: return dt.strftime("%Y-%m-%d %H:%M") +def get_terminal_width() -> int: + """Get terminal character width using ioctl.""" + try: + return struct.unpack("HHHH", fcntl.ioctl(0, termios.TIOCGWINSZ, b"\0" * 8))[1] + except (OSError, struct.error, IOError): + return 80 # default fallback + + def list_sessions( sessions: list[SessionInfo], limit: int | None = None, show_all: bool = False, + term_width: int | None = None, ) -> None: """Print a summary table of sessions.""" to_show = sessions[:limit] if limit else sessions + width = term_width if term_width is not None else 999999 for i, s in enumerate(to_show): reqs = s.get("requests", []) n_msgs = len(reqs) @@ -313,7 +326,11 @@ def list_sessions( label = label.replace("\n", " ").replace("\r", "") date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") - print(f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}") + line = f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}" + # Clip to terminal width + if len(line) > width: + line = line[:width - 1] + print(line) def show_session(session: SessionInfo) -> None: @@ -372,10 +389,11 @@ def show_session(session: SessionInfo) -> None: print() -def search_sessions(sessions: list[SessionInfo], query: str) -> None: +def search_sessions(sessions: list[SessionInfo], query: str, term_width: int | None = None) -> None: """Search all sessions for messages containing query text.""" query_lower = query.lower() hits = 0 + width = term_width if term_width is not None else 999999 for i, s in enumerate(sessions): for req in s.get("requests", []): user = req.get("user", "") @@ -384,7 +402,10 @@ def search_sessions(sessions: list[SessionInfo], query: str) -> None: title = s.get("title") or "(untitled)" date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") - print(f"\n [{date_str}] ({workspace}) {title}") + line1 = f"\n [{date_str}] ({workspace}) {title}" + if len(line1) > width: + line1 = line1[:width - 1] + print(line1) print(f" Session #{i + 1}") # Show the matching message snippet for text, label in [(user, "YOU"), (assistant, "COPILOT")]: @@ -393,11 +414,24 @@ def search_sessions(sessions: list[SessionInfo], query: str) -> None: start = max(0, idx - 40) end = min(len(text), idx + len(query) + 40) snippet = text[start:end].replace("\n", " ") - if start > 0: + has_start_ellipsis = start > 0 + has_end_ellipsis = end < len(text) + if has_start_ellipsis: snippet = "..." + snippet - if end < len(text): + if has_end_ellipsis: snippet = snippet + "..." - print(f" {label}: {snippet}") + prefix = f" {label}: " + line2 = prefix + snippet + # If line is too long, clip but ensure "..." stays at the end + if len(line2) > width: + available = width - len(prefix) + if available >= 3 and (has_start_ellipsis or has_end_ellipsis): + # Clip content but keep "..." at the end + line2 = prefix + snippet[: available - 3] + "..." + else: + # No room for ellipsis or no ellipsis needed, just clip + line2 = line2[:width - 1] + print(line2) hits += 1 if hits == 0: print(f"No messages found matching '{query}'.") @@ -524,9 +558,12 @@ def main() -> None: use_pager = not args.no_pager ctx = smart_pager(pager_cmd) if use_pager else contextlib.nullcontext() + # Get terminal width for clipping list/search output + term_width = get_terminal_width() if use_pager else None + with ctx: if args.search: - search_sessions(sessions, args.search) + search_sessions(sessions, args.search, term_width=term_width) return if args.session: @@ -551,7 +588,7 @@ def main() -> None: print(f"Found {len(sessions)} chat session(s), {n_empty} empty:\n") else: print(f"Found {len(sessions)} chat session(s):\n") - list_sessions(sessions, args.n, show_all=args.all) + list_sessions(sessions, args.n, show_all=args.all, term_width=term_width) print(f"\nUse: python {sys.argv[0]} to view a session") From ce2d5d79d26040ba37cc106865fc4a474919a84b Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Thu, 9 Apr 2026 19:29:31 -0700 Subject: [PATCH 03/13] Add color --- tools/chat_sessions.py | 191 +++++++++++++++++++++++++++++++++-------- 1 file changed, 153 insertions(+), 38 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 0e852e83..2d0c7a30 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -31,10 +31,33 @@ import textwrap from typing import Any +from colorama import Fore, init, Style + VSCODE_USER_DIR = Path.home() / "Library" / "Application Support" / "Code" / "User" # Linux: Path.home() / ".config" / "Code" / "User" # Windows: Path.home() / "AppData" / "Roaming" / "Code" / "User" +# Color settings +use_color = True + + +def should_use_color(args: argparse.Namespace | None = None) -> bool: + """Determine if color should be used based on args and environment.""" + # Check explicit command-line flags first + if args is not None: + if hasattr(args, "color"): + if args.color == "always": + return True + if args.color == "never": + return False + # Check environment variables + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + # Default: use color if output is a TTY + return sys.stdout.isatty() + def find_session_dirs() -> list[Path]: """Find all chatSessions directories across workspaces and global storage.""" @@ -326,10 +349,20 @@ def list_sessions( label = label.replace("\n", " ").replace("\r", "") date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") - line = f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}" + + if use_color: + # Colorize the session listing + line = ( + f" {Fore.CYAN}{i + 1:3d}{Style.RESET_ALL}. " + f"[{Fore.YELLOW}{date_str}{Style.RESET_ALL}] " + f"({Fore.MAGENTA}{workspace}{Style.RESET_ALL}, " + f"{Fore.GREEN}{n_msgs}{Style.RESET_ALL} msgs) {label}" + ) + else: + line = f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}" # Clip to terminal width if len(line) > width: - line = line[:width - 1] + line = line[: width - 1] print(line) @@ -341,12 +374,22 @@ def show_session(session: SessionInfo) -> None: model = session.get("model", "?") session_id = session.get("session_id", "?") - print(f"Session: {title}") - print(f" ID: {session_id}") - print(f" Date: {date_str}") - print(f" Workspace: {workspace}") - print(f" Model: {model}") - print(f" Messages: {len(session.get('requests', []))}") + if use_color: + print(f"Session: {Fore.CYAN}{title}{Style.RESET_ALL}") + print(f" ID: {Fore.YELLOW}{session_id}{Style.RESET_ALL}") + print(f" Date: {Fore.YELLOW}{date_str}{Style.RESET_ALL}") + print(f" Workspace: {Fore.MAGENTA}{workspace}{Style.RESET_ALL}") + print(f" Model: {Fore.GREEN}{model}{Style.RESET_ALL}") + print( + f" Messages: {Fore.CYAN}{len(session.get('requests', []))}{Style.RESET_ALL}" + ) + else: + print(f"Session: {title}") + print(f" ID: {session_id}") + print(f" Date: {date_str}") + print(f" Workspace: {workspace}") + print(f" Model: {model}") + print(f" Messages: {len(session.get('requests', []))}") print("=" * 72) for req in session.get("requests", []): @@ -363,33 +406,65 @@ def show_session(session: SessionInfo) -> None: cancelled = model_state == 4 status = " (cancelled)" if cancelled else "" - print(f"\n--- [{ts}]{status} ---") - print(f"\nYOU: {user_text}") - - if thinking: - wrapped = textwrap.fill( - thinking, width=70, initial_indent=" ", subsequent_indent=" " - ) - print(f"\n\n{wrapped}\n") - - if tools: - for tool_cmd in tools: - if tool_cmd.startswith("["): - print(f"\n {tool_cmd}") - else: - print(f"\n $ {tool_cmd}") - - if assistant_text: - print(f"\nCOPILOT ({model_short}):\n{assistant_text}") - elif tools and not cancelled: + if use_color: print( - f"\nCOPILOT ({model_short}): ({len(tools)} tool call(s), no text response)" + f"\n--- [{Fore.YELLOW}{ts}{Style.RESET_ALL}]{Fore.YELLOW}{status}{Style.RESET_ALL} ---" ) + print(f"\n{Fore.CYAN}YOU{Style.RESET_ALL}: {user_text}") + + if thinking: + wrapped = textwrap.fill( + thinking, width=70, initial_indent=" ", subsequent_indent=" " + ) + print( + f"\n{Fore.MAGENTA}{Style.RESET_ALL}\n{wrapped}\n{Fore.MAGENTA}{Style.RESET_ALL}" + ) + + if tools: + for tool_cmd in tools: + if tool_cmd.startswith("["): + print(f"\n {Fore.GREEN}{tool_cmd}{Style.RESET_ALL}") + else: + print(f"\n {Fore.GREEN}${Style.RESET_ALL} {tool_cmd}") + + if assistant_text: + print( + f"\n{Fore.CYAN}COPILOT{Style.RESET_ALL} ({Fore.GREEN}{model_short}{Style.RESET_ALL}):\n{assistant_text}" + ) + elif tools and not cancelled: + print( + f"\n{Fore.CYAN}COPILOT{Style.RESET_ALL} ({Fore.GREEN}{model_short}{Style.RESET_ALL}): ({len(tools)} tool call(s), no text response)" + ) + else: + print(f"\n--- [{ts}]{status} ---") + print(f"\nYOU: {user_text}") + + if thinking: + wrapped = textwrap.fill( + thinking, width=70, initial_indent=" ", subsequent_indent=" " + ) + print(f"\n\n{wrapped}\n") + + if tools: + for tool_cmd in tools: + if tool_cmd.startswith("["): + print(f"\n {tool_cmd}") + else: + print(f"\n $ {tool_cmd}") + + if assistant_text: + print(f"\nCOPILOT ({model_short}):\n{assistant_text}") + elif tools and not cancelled: + print( + f"\nCOPILOT ({model_short}): ({len(tools)} tool call(s), no text response)" + ) print() -def search_sessions(sessions: list[SessionInfo], query: str, term_width: int | None = None) -> None: +def search_sessions( + sessions: list[SessionInfo], query: str, term_width: int | None = None +) -> None: """Search all sessions for messages containing query text.""" query_lower = query.lower() hits = 0 @@ -404,7 +479,7 @@ def search_sessions(sessions: list[SessionInfo], query: str, term_width: int | N workspace = s.get("workspace", "?") line1 = f"\n [{date_str}] ({workspace}) {title}" if len(line1) > width: - line1 = line1[:width - 1] + line1 = line1[: width - 1] print(line1) print(f" Session #{i + 1}") # Show the matching message snippet @@ -420,17 +495,24 @@ def search_sessions(sessions: list[SessionInfo], query: str, term_width: int | N snippet = "..." + snippet if has_end_ellipsis: snippet = snippet + "..." - prefix = f" {label}: " + + if use_color: + prefix = f" {Fore.CYAN}{label}{Style.RESET_ALL}: " + else: + prefix = f" {label}: " + line2 = prefix + snippet # If line is too long, clip but ensure "..." stays at the end if len(line2) > width: available = width - len(prefix) - if available >= 3 and (has_start_ellipsis or has_end_ellipsis): + if available >= 3 and ( + has_start_ellipsis or has_end_ellipsis + ): # Clip content but keep "..." at the end line2 = prefix + snippet[: available - 3] + "..." else: # No room for ellipsis or no ellipsis needed, just clip - line2 = line2[:width - 1] + line2 = line2[: width - 1] print(line2) hits += 1 if hits == 0: @@ -546,13 +628,29 @@ def main() -> None: default=False, help="Disable pager", ) + parser.add_argument( + "--color", + type=str, + choices=["always", "never", "auto"], + default="auto", + help="When to use color (always, never, auto)", + ) args = parser.parse_args() + # Initialize colorama with autoreset disabled so we can use explicit Style.RESET_ALL + # Use strip=False to preserve ANSI codes even when piped (caller can strip if needed) + init(autoreset=False, strip=False) + global use_color + use_color = should_use_color(args) + pager_cmd = args.pager if args.pager is not None else get_default_pager() sessions = load_all_sessions() if not sessions: - print("No chat sessions found.") + if use_color: + print(f"{Fore.RED}No chat sessions found.{Style.RESET_ALL}") + else: + print("No chat sessions found.") return use_pager = not args.no_pager @@ -584,12 +682,29 @@ def main() -> None: return n_empty = sum(1 for s in sessions if not s.get("requests")) - if n_empty: - print(f"Found {len(sessions)} chat session(s), {n_empty} empty:\n") + if use_color: + if n_empty: + print( + f"Found {Fore.CYAN}{len(sessions)}{Style.RESET_ALL} chat session(s), " + f"{Fore.YELLOW}{n_empty}{Style.RESET_ALL} empty:\n" + ) + else: + print( + f"Found {Fore.CYAN}{len(sessions)}{Style.RESET_ALL} chat session(s):\n" + ) else: - print(f"Found {len(sessions)} chat session(s):\n") + if n_empty: + print(f"Found {len(sessions)} chat session(s), {n_empty} empty:\n") + else: + print(f"Found {len(sessions)} chat session(s):\n") list_sessions(sessions, args.n, show_all=args.all, term_width=term_width) - print(f"\nUse: python {sys.argv[0]} to view a session") + if use_color: + print( + f"\nUse: {Fore.CYAN}python {sys.argv[0]} {Style.RESET_ALL} " + f"to view a session" + ) + else: + print(f"\nUse: python {sys.argv[0]} to view a session") if __name__ == "__main__": From 23527298d6d41b2893fb53997fe09b302f4012d3 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Thu, 9 Apr 2026 19:46:43 -0700 Subject: [PATCH 04/13] Fix rendering of sections --- tools/chat_sessions.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 2d0c7a30..15facfa3 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -28,7 +28,6 @@ import subprocess import sys import termios -import textwrap from typing import Any from colorama import Fore, init, Style @@ -413,11 +412,13 @@ def show_session(session: SessionInfo) -> None: print(f"\n{Fore.CYAN}YOU{Style.RESET_ALL}: {user_text}") if thinking: - wrapped = textwrap.fill( - thinking, width=70, initial_indent=" ", subsequent_indent=" " - ) + # Preserve paragraph structure while indenting + lines = thinking.split("\n") + indented_lines = [" " + line for line in lines] print( - f"\n{Fore.MAGENTA}{Style.RESET_ALL}\n{wrapped}\n{Fore.MAGENTA}{Style.RESET_ALL}" + f"\n{Fore.MAGENTA}{Style.RESET_ALL}\n" + + "\n".join(indented_lines) + + f"\n{Fore.MAGENTA}{Style.RESET_ALL}" ) if tools: @@ -440,10 +441,10 @@ def show_session(session: SessionInfo) -> None: print(f"\nYOU: {user_text}") if thinking: - wrapped = textwrap.fill( - thinking, width=70, initial_indent=" ", subsequent_indent=" " - ) - print(f"\n\n{wrapped}\n") + # Preserve paragraph structure while indenting + lines = thinking.split("\n") + indented_lines = [" " + line for line in lines] + print(f"\n\n" + "\n".join(indented_lines) + "\n") if tools: for tool_cmd in tools: From b3bec8841a0939d8776dfa223495c65dd5aa7f0f Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Thu, 9 Apr 2026 21:38:13 -0700 Subject: [PATCH 05/13] Fix counting of line length (discount ANSI escapes) --- tools/chat_sessions.py | 43 ++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 15facfa3..04b0ffbe 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -31,6 +31,7 @@ from typing import Any from colorama import Fore, init, Style +import re VSCODE_USER_DIR = Path.home() / "Library" / "Application Support" / "Code" / "User" # Linux: Path.home() / ".config" / "Code" / "User" @@ -39,6 +40,14 @@ # Color settings use_color = True +# Regex to match ANSI escape sequences +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +def visible_len(text: str) -> int: + """Return the visible length of text, excluding ANSI escape sequences.""" + return len(ANSI_ESCAPE.sub("", text)) + def should_use_color(args: argparse.Namespace | None = None) -> bool: """Determine if color should be used based on args and environment.""" @@ -359,9 +368,11 @@ def list_sessions( ) else: line = f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}" - # Clip to terminal width - if len(line) > width: - line = line[: width - 1] + # Clip to terminal width (use visible length to account for ANSI codes) + if visible_len(line) > width: + # Remove characters from the end until we're under the width limit + while visible_len(line) > width - 1: + line = line[:-1] print(line) @@ -479,8 +490,10 @@ def search_sessions( date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") line1 = f"\n [{date_str}] ({workspace}) {title}" - if len(line1) > width: - line1 = line1[: width - 1] + if visible_len(line1) > width: + # Remove characters from the end until we're under the width limit + while visible_len(line1) > width - 1: + line1 = line1[:-1] print(line1) print(f" Session #{i + 1}") # Show the matching message snippet @@ -503,17 +516,10 @@ def search_sessions( prefix = f" {label}: " line2 = prefix + snippet - # If line is too long, clip but ensure "..." stays at the end - if len(line2) > width: - available = width - len(prefix) - if available >= 3 and ( - has_start_ellipsis or has_end_ellipsis - ): - # Clip content but keep "..." at the end - line2 = prefix + snippet[: available - 3] + "..." - else: - # No room for ellipsis or no ellipsis needed, just clip - line2 = line2[: width - 1] + # If line is too long, clip using visible length + if visible_len(line2) > width: + while visible_len(line2) > width - 1: + line2 = line2[:-1] print(line2) hits += 1 if hits == 0: @@ -657,8 +663,9 @@ def main() -> None: use_pager = not args.no_pager ctx = smart_pager(pager_cmd) if use_pager else contextlib.nullcontext() - # Get terminal width for clipping list/search output - term_width = get_terminal_width() if use_pager else None + # Get terminal width for clipping list/search output only if stdout is a TTY + # (smart_pager will skip paging if output isn't going to terminal anyway) + term_width = get_terminal_width() if (use_pager and sys.stdout.isatty()) else None with ctx: if args.search: From 75bff51042968353bf74986e698cd9a86ee6006f Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Thu, 9 Apr 2026 22:04:14 -0700 Subject: [PATCH 06/13] Many more issues fixed related to color and clipping --- tools/chat_sessions.py | 87 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 04b0ffbe..c873f13a 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -49,6 +49,50 @@ def visible_len(text: str) -> int: return len(ANSI_ESCAPE.sub("", text)) +def highlight_query(text: str, query: str) -> str: + """Highlight all occurrences of query in text (case-insensitive).""" + if not use_color: + return text + # Replace all occurrences of query with highlighted version (case-insensitive) + pattern = re.compile(re.escape(query), re.IGNORECASE) + highlighted = pattern.sub( + lambda m: f"{Fore.RED}{m.group()}{Style.RESET_ALL}", + text, + ) + return highlighted + + +def clip_to_visible_length(text: str, target_length: int) -> str: + """Clip text to a target visible length, accounting for ANSI escape codes. + + Splits the string into ANSI-code tokens and plain-character tokens, then + reconstructs from the left until the visible character count reaches the + target. This avoids splitting in the middle of an escape sequence. + """ + # Tokenize: alternate between non-ANSI runs and ANSI sequences + tokens = ANSI_ESCAPE.split(text) + ansi_codes = ANSI_ESCAPE.findall(text) + + result = [] + visible = 0 + # Interleave: tokens[0], ansi_codes[0], tokens[1], ansi_codes[1], ... + for i, plain in enumerate(tokens): + remaining = target_length - visible + if len(plain) <= remaining: + result.append(plain) + visible += len(plain) + else: + result.append(plain[:remaining]) + visible += remaining + # Consume any pending ANSI codes to reset state, then stop + if i < len(ansi_codes): + result.append(ansi_codes[i]) + break + if i < len(ansi_codes): + result.append(ansi_codes[i]) # ANSI codes don't count as visible + return "".join(result) + + def should_use_color(args: argparse.Namespace | None = None) -> bool: """Determine if color should be used based on args and environment.""" # Check explicit command-line flags first @@ -351,7 +395,7 @@ def list_sessions( title = s.get("title") first_msg = "" if reqs: - first_msg = reqs[0].get("user", "")[:80] + first_msg = reqs[0].get("user", "") label = title or first_msg or "(empty)" # Remove newlines to prevent formatting issues label = label.replace("\n", " ").replace("\r", "") @@ -370,9 +414,7 @@ def list_sessions( line = f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}" # Clip to terminal width (use visible length to account for ANSI codes) if visible_len(line) > width: - # Remove characters from the end until we're under the width limit - while visible_len(line) > width - 1: - line = line[:-1] + line = clip_to_visible_length(line, width - 1) print(line) @@ -491,20 +533,36 @@ def search_sessions( workspace = s.get("workspace", "?") line1 = f"\n [{date_str}] ({workspace}) {title}" if visible_len(line1) > width: - # Remove characters from the end until we're under the width limit - while visible_len(line1) > width - 1: - line1 = line1[:-1] + line1 = clip_to_visible_length(line1, width - 1) print(line1) print(f" Session #{i + 1}") # Show the matching message snippet for text, label in [(user, "YOU"), (assistant, "COPILOT")]: idx = text.lower().find(query_lower) if idx >= 0: - start = max(0, idx - 40) - end = min(len(text), idx + len(query) + 40) + # Extract enough to fill the line width around the match + # Account for prefix length to leave room for: " YOU/COPILOT: " + prefix_len = len(" YOU: ") # rough estimate + available = max( + 40, width - prefix_len - 10 + ) # -10 for ANSI codes + half_avail = available // 2 + start = max(0, idx - half_avail) + end = min(len(text), idx + len(query) + half_avail) + # Redistribute unused budget when one side hits a boundary + if start == 0 and end < len(text): + end = min(len(text), end + (half_avail - idx)) + elif end == len(text) and start > 0: + start = max( + 0, start - (half_avail - (len(text) - idx - len(query))) + ) snippet = text[start:end].replace("\n", " ") has_start_ellipsis = start > 0 has_end_ellipsis = end < len(text) + + # Highlight the query in the snippet + snippet = highlight_query(snippet, query) + if has_start_ellipsis: snippet = "..." + snippet if has_end_ellipsis: @@ -516,10 +574,9 @@ def search_sessions( prefix = f" {label}: " line2 = prefix + snippet - # If line is too long, clip using visible length + # Clip to terminal width using visible length, preserving trailing "..." if visible_len(line2) > width: - while visible_len(line2) > width - 1: - line2 = line2[:-1] + line2 = clip_to_visible_length(line2, width - 4) + "..." print(line2) hits += 1 if hits == 0: @@ -663,9 +720,9 @@ def main() -> None: use_pager = not args.no_pager ctx = smart_pager(pager_cmd) if use_pager else contextlib.nullcontext() - # Get terminal width for clipping list/search output only if stdout is a TTY - # (smart_pager will skip paging if output isn't going to terminal anyway) - term_width = get_terminal_width() if (use_pager and sys.stdout.isatty()) else None + # Always get terminal width for reasonable snippet extraction and display + # Only used for clipping if stdout is a TTY or using pager + term_width = get_terminal_width() with ctx: if args.search: From cde4d17ce34c05579d3473359db6233caea57a69 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Fri, 10 Apr 2026 21:04:48 -0700 Subject: [PATCH 07/13] Make chat_sessions.py platform agnostic We now look in: - VSCode Server environments (.vscode-server/data/User) - Platform-native installations (.config/Code/User, AppData/Roaming/Code/User, etc.) - Windows installations via WSL mounts (/mnt/c/Users/.../AppData) We support two locations and formats for chat sessions: - chatSessions (old) - GitHub.copilot-chat (new) --- tools/chat_sessions.py | 138 ++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 31 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index c873f13a..affbe60d 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -34,8 +34,45 @@ import re VSCODE_USER_DIR = Path.home() / "Library" / "Application Support" / "Code" / "User" -# Linux: Path.home() / ".config" / "Code" / "User" -# Windows: Path.home() / "AppData" / "Roaming" / "Code" / "User" +# Updated in main() based on platform + + +def _detect_vscode_user_dir() -> list[Path]: + """Detect VS Code user directories for current environment. + + Returns a list of directories to search, in priority order: + 1. VSCode Server (if .vscode-server exists) + 2. Native VS Code installation for the platform + 3. Windows VS Code via WSL mount (if on WSL) + """ + dirs: list[Path] = [] + + # VSCode Server (for remote SSH, WSL, containers, etc.) + vscode_server = Path.home() / ".vscode-server" / "data" / "User" + if vscode_server.is_dir(): + dirs.append(vscode_server) + + # Platform-specific native VS Code + if sys.platform == "linux": + dirs.append(Path.home() / ".config" / "Code" / "User") + elif sys.platform == "win32": + dirs.append(Path.home() / "AppData" / "Roaming" / "Code" / "User") + elif sys.platform == "darwin": + dirs.append(Path.home() / "Library" / "Application Support" / "Code" / "User") + + # Windows via WSL mount (if running on WSL) + if sys.platform == "linux" and Path("/mnt/c").exists(): + win_user = Path("/mnt/c/Users") + if win_user.is_dir(): + # Try to find the current user's home in Windows + for user_dir in win_user.iterdir(): + if user_dir.is_dir(): + vscode_win = user_dir / "AppData" / "Roaming" / "Code" / "User" + if vscode_win.is_dir(): + dirs.append(vscode_win) + break + + return dirs # Color settings use_color = True @@ -112,24 +149,42 @@ def should_use_color(args: argparse.Namespace | None = None) -> bool: def find_session_dirs() -> list[Path]: - """Find all chatSessions directories across workspaces and global storage.""" + """Find all chat session directories across workspaces and global storage. + + Searches for both old format (chatSessions) and new format (GitHub.copilot-chat). + """ dirs: list[Path] = [] - # Per-workspace sessions - ws_root = VSCODE_USER_DIR / "workspaceStorage" - if ws_root.is_dir(): - for entry in ws_root.iterdir(): - chat_dir = entry / "chatSessions" - if chat_dir.is_dir(): - dirs.append(chat_dir) - # Global (empty window) sessions - global_dir = VSCODE_USER_DIR / "globalStorage" / "emptyWindowChatSessions" - if global_dir.is_dir(): - dirs.append(global_dir) + search_dirs = _detect_vscode_user_dir() + + for base_dir in search_dirs: + if not base_dir.is_dir(): + continue + + # Per-workspace sessions (old format) + ws_root = base_dir / "workspaceStorage" + if ws_root.is_dir(): + for entry in ws_root.iterdir(): + if not entry.is_dir(): + continue + # Old format: chatSessions + chat_dir = entry / "chatSessions" + if chat_dir.is_dir(): + dirs.append(chat_dir) + # New format: GitHub.copilot-chat + copilot_dir = entry / "GitHub.copilot-chat" + if copilot_dir.is_dir(): + dirs.append(copilot_dir) + + # Global (empty window) sessions (old format) + global_dir = base_dir / "globalStorage" / "emptyWindowChatSessions" + if global_dir.is_dir(): + dirs.append(global_dir) + return dirs def get_workspace_name(session_dir: Path) -> str: - """Try to resolve a workspace name from workspace.json next to chatSessions.""" + """Try to resolve a workspace name from workspace.json next to session dir.""" ws_json = session_dir.parent / "workspace.json" if ws_json.is_file(): try: @@ -340,19 +395,46 @@ def _parse_request(req: dict[str, Any]) -> dict[str, Any] | None: def load_all_sessions() -> list[SessionInfo]: - """Load all sessions from disk.""" + """Load all sessions from disk. + + Handles both old format (JSON/JSONL files) and new format (GitHub.copilot-chat). + """ sessions: list[SessionInfo] = [] for session_dir in find_session_dirs(): workspace = get_workspace_name(session_dir) - for f in session_dir.iterdir(): - info = None - if f.suffix == ".jsonl": - info = parse_jsonl(f) - elif f.suffix == ".json": - info = parse_json(f) - if info: - info["workspace"] = workspace - sessions.append(info) + + # Check if this is a GitHub.copilot-chat directory (new format) + if "GitHub.copilot-chat" in str(session_dir): + # New format: sessions are directories in chat-session-resources/ + chat_resources = session_dir / "chat-session-resources" + if chat_resources.is_dir(): + for session_uuid_dir in chat_resources.iterdir(): + if not session_uuid_dir.is_dir(): + continue + session_id = session_uuid_dir.name + info: SessionInfo = { + "path": str(session_uuid_dir), + "session_id": session_id, + "title": None, + "creation_date": None, + "model": "", + "requests": [], + "workspace": workspace, + } + # Try to extract basic info from subdirectories + # (new format doesn't have easy-to-parse chat history yet) + sessions.append(info) + else: + # Old format: JSON/JSONL files + for f in session_dir.iterdir(): + parsed_info: SessionInfo | None = None + if f.suffix == ".jsonl": + parsed_info = parse_jsonl(f) + elif f.suffix == ".json": + parsed_info = parse_json(f) + if parsed_info is not None: + parsed_info["workspace"] = workspace + sessions.append(parsed_info) # Sort by creation date (newest first) sessions.sort( @@ -648,12 +730,6 @@ def smart_pager(pager_cmd: str) -> Iterator[None]: def main() -> None: - global VSCODE_USER_DIR - if sys.platform == "linux": - VSCODE_USER_DIR = Path.home() / ".config" / "Code" / "User" - elif sys.platform == "win32": - VSCODE_USER_DIR = Path.home() / "AppData" / "Roaming" / "Code" / "User" - parser = argparse.ArgumentParser(description="Browse VS Code Copilot chat sessions") parser.add_argument( "session", From 66f59d290e26aee5838a04455729842d50654dcd Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sat, 11 Apr 2026 14:32:43 -0700 Subject: [PATCH 08/13] Lots of work on performance. No longer displays request count --- tools/chat_sessions.py | 215 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 19 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index affbe60d..2cf723ad 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -23,6 +23,7 @@ import json import os from pathlib import Path +import re import shutil import struct import subprocess @@ -31,7 +32,6 @@ from typing import Any from colorama import Fore, init, Style -import re VSCODE_USER_DIR = Path.home() / "Library" / "Application Support" / "Code" / "User" # Updated in main() based on platform @@ -74,6 +74,7 @@ def _detect_vscode_user_dir() -> list[Path]: return dirs + # Color settings use_color = True @@ -210,6 +211,90 @@ def _splice(target: list[Any], index: int, items: list[Any]) -> None: target[index : index + len(items)] = items +_RE_CUSTOM_TITLE_JSONL = re.compile( + r'"customTitle"\s*]\s*,\s*"v"\s*:\s*"((?:[^"\\]|\\.)*)"' +) + + +def parse_jsonl_metadata(path: Path) -> SessionInfo | None: + """Fast metadata extraction from a .jsonl chat session file. + + Reads the first line (kind-0 session metadata snapshot) and a few KB + after it (for customTitle patches and first user message) to avoid + reading multi-MB files fully. + Falls back to full parse if the first line isn't a valid kind-0 record. + """ + size = path.stat().st_size + if size == 0: + return None + + with open(path, "rb") as fh: + first_line_bytes = fh.readline() + line1_end = fh.tell() + # Read a few KB more for customTitle patches (kind-1, lines 2-3) + # and possibly the first user message. + extra = fh.read(min(size - line1_end, 4096)).decode("utf-8", errors="replace") + + first_line = first_line_bytes.decode("utf-8", errors="replace") + if not first_line.strip(): + return None + try: + record = json.loads(first_line) + except json.JSONDecodeError: + return None + + if record.get("kind") != 0: + return parse_jsonl(path) # fall back + + info: SessionInfo = { + "path": str(path), + "session_id": path.stem, + "title": None, + "creation_date": None, + "requests": [], + } + + v = record.get("v", {}) + if isinstance(v, dict): + if ts := v.get("creationDate"): + info["creation_date"] = ts + model_info = ( + v.get("inputState", {}).get("selectedModel", {}).get("metadata", {}) + ) + info["model"] = model_info.get("name", "") + if v.get("customTitle"): + info["title"] = v["customTitle"] + # First user message from initial snapshot + reqs = v.get("requests", []) + if reqs and isinstance(reqs[0], dict): + first_user = reqs[0].get("message", {}).get("text", "") + if first_user: + info["requests"].append({"user": first_user}) + + # Look for customTitle patches in the extra bytes after line 1. + # Kind-1 patches for customTitle are small lines near the start of the file. + if not info.get("title") and extra: + m = _RE_CUSTOM_TITLE_JSONL.search(extra) + if m: + info["title"] = m.group(1).replace("\\n", "\n").replace('\\"', '"') + + # Look for first user message in extra bytes (unlikely to be there since + # "message" is deep in the request patch line, but try anyway). + if not info["requests"] and extra: + m = _RE_FIRST_MSG.search(extra) + if m: + first_user = m.group(1).replace("\\n", "\n").replace('\\"', '"') + info["requests"].append({"user": first_user}) + + # If we still have no requests but the extra bytes contain a kind-2 + # request splice, the file has requests (we just can't extract the text + # from a small buffer). + if not info["requests"] and '"requests"' in extra: + info["requests"].append({"user": ""}) + + return info + + def parse_jsonl(path: Path) -> SessionInfo | None: """Parse a .jsonl chat session file. @@ -311,8 +396,75 @@ def parse_jsonl(path: Path) -> SessionInfo | None: return info +# Regexes for fast tail-of-file metadata extraction from JSON files. +_RE_CREATION_DATE = re.compile(r'"creationDate"\s*:\s*(\d+)') +_RE_CUSTOM_TITLE = re.compile(r'"customTitle"\s*:\s*"((?:[^"\\]|\\.)*)"') +_RE_SESSION_ID = re.compile(r'"sessionId"\s*:\s*"([^"]+)"') +# Match "message":{ ... "text": "..." } — the ... allows for "parts" or other +# keys that may appear before "text" in old JSON format (version 3). +# Capture is capped at 200 chars so a closing quote beyond the read buffer +# doesn't prevent the match. +_RE_FIRST_MSG = re.compile( + r'"message"\s*:\s*\{.*?"text"\s*:\s*"((?:[^"\\]|\\.){0,200})', re.DOTALL +) + + +def parse_json_metadata(path: Path) -> SessionInfo | None: + """Fast metadata extraction from a .json chat session file. + + Reads the last 1KB to extract creationDate, customTitle, sessionId + (which live at the end of the file), and the first 2KB to get the + first user message. Falls back to full parse if the tail doesn't + end with the expected closing brace. + """ + size = path.stat().st_size + if size == 0: + return None + + # Read last 1KB for metadata fields that live at the end. + with open(path, "rb") as fh: + fh.seek(max(0, size - 1024)) + tail = fh.read().decode("utf-8", errors="replace") + + # Sanity check: file should end with "}" + if not tail.rstrip().endswith("}"): + return parse_json(path) # fall back to full parse + + m = _RE_CREATION_DATE.search(tail) + creation_date = int(m.group(1)) if m else None + + m = _RE_CUSTOM_TITLE.search(tail) + title = m.group(1) if m else None + + m = _RE_SESSION_ID.search(tail) + session_id = m.group(1) if m else path.stem + + # Read first 4KB for the first user message. + with open(path, "rb") as fh: + head = fh.read(4096).decode("utf-8", errors="replace") + + m = _RE_FIRST_MSG.search(head) + first_user = m.group(1) if m else "" + # Unescape basic JSON escapes in the extracted string. + if first_user: + first_user = first_user.replace("\\n", "\n").replace('\\"', '"') + + # Check whether the file has any requests ("requests": [...]) + has_requests = '"requests"' in head and '"requests": []' not in head + + info: SessionInfo = { + "path": str(path), + "session_id": session_id, + "title": title, + "creation_date": creation_date, + "model": "", + "requests": [{"user": first_user}] if has_requests else [], + } + return info + + def parse_json(path: Path) -> SessionInfo | None: - """Parse a .json chat session file.""" + """Parse a .json chat session file (full parse).""" try: data = json.loads(path.read_text(errors="replace")) except json.JSONDecodeError: @@ -321,7 +473,7 @@ def parse_json(path: Path) -> SessionInfo | None: info: SessionInfo = { "path": str(path), "session_id": data.get("sessionId", path.stem), - "title": None, + "title": data.get("customTitle"), "creation_date": data.get("creationDate"), "model": ( data.get("inputState", {}) @@ -394,12 +546,16 @@ def _parse_request(req: dict[str, Any]) -> dict[str, Any] | None: } -def load_all_sessions() -> list[SessionInfo]: +def load_all_sessions(metadata_only: bool = False) -> list[SessionInfo]: """Load all sessions from disk. Handles both old format (JSON/JSONL files) and new format (GitHub.copilot-chat). + + When metadata_only is True, use fast head+tail extraction instead of + full parsing. Use load_session_by_path() for full parsing. """ sessions: list[SessionInfo] = [] + for session_dir in find_session_dirs(): workspace = get_workspace_name(session_dir) @@ -421,17 +577,23 @@ def load_all_sessions() -> list[SessionInfo]: "requests": [], "workspace": workspace, } - # Try to extract basic info from subdirectories - # (new format doesn't have easy-to-parse chat history yet) sessions.append(info) else: # Old format: JSON/JSONL files for f in session_dir.iterdir(): - parsed_info: SessionInfo | None = None - if f.suffix == ".jsonl": - parsed_info = parse_jsonl(f) - elif f.suffix == ".json": - parsed_info = parse_json(f) + if f.suffix not in (".jsonl", ".json"): + continue + + if metadata_only: + parsed_info = ( + parse_jsonl_metadata(f) + if f.suffix == ".jsonl" + else parse_json_metadata(f) + ) + else: + parsed_info = ( + parse_jsonl(f) if f.suffix == ".jsonl" else parse_json(f) + ) if parsed_info is not None: parsed_info["workspace"] = workspace sessions.append(parsed_info) @@ -444,6 +606,19 @@ def load_all_sessions() -> list[SessionInfo]: return sessions +def load_session_by_path(path_str: str) -> SessionInfo | None: + """Fully parse a single session file by its path.""" + path = Path(path_str) + if path.is_dir(): + # New format directory — no full parse available yet + return None + if path.suffix == ".jsonl": + return parse_jsonl(path) + elif path.suffix == ".json": + return parse_json(path) + return None + + def format_timestamp(ts: int | None) -> str: if not ts: return "?" @@ -471,8 +646,7 @@ def list_sessions( width = term_width if term_width is not None else 999999 for i, s in enumerate(to_show): reqs = s.get("requests", []) - n_msgs = len(reqs) - if n_msgs == 0 and not show_all: + if not reqs and not show_all: continue title = s.get("title") first_msg = "" @@ -489,11 +663,10 @@ def list_sessions( line = ( f" {Fore.CYAN}{i + 1:3d}{Style.RESET_ALL}. " f"[{Fore.YELLOW}{date_str}{Style.RESET_ALL}] " - f"({Fore.MAGENTA}{workspace}{Style.RESET_ALL}, " - f"{Fore.GREEN}{n_msgs}{Style.RESET_ALL} msgs) {label}" + f"({Fore.MAGENTA}{workspace}{Style.RESET_ALL}) {label}" ) else: - line = f" {i + 1:3d}. [{date_str}] ({workspace}, {n_msgs} msgs) {label}" + line = f" {i + 1:3d}. [{date_str}] ({workspace}) {label}" # Clip to terminal width (use visible length to account for ANSI codes) if visible_len(line) > width: line = clip_to_visible_length(line, width - 1) @@ -785,7 +958,9 @@ def main() -> None: pager_cmd = args.pager if args.pager is not None else get_default_pager() - sessions = load_all_sessions() + # For search, we need full parsing; for everything else, metadata suffices. + need_full = args.search is not None + sessions = load_all_sessions(metadata_only=not need_full) if not sessions: if use_color: print(f"{Fore.RED}No chat sessions found.{Style.RESET_ALL}") @@ -810,14 +985,16 @@ def main() -> None: try: idx = int(args.session) - 1 if 0 <= idx < len(sessions): - show_session(sessions[idx]) + full = load_session_by_path(sessions[idx]["path"]) + show_session(full or sessions[idx]) return except ValueError: pass # Try as a session ID for s in sessions: if s.get("session_id") == args.session: - show_session(s) + full = load_session_by_path(s["path"]) + show_session(full or s) return print(f"Session not found: {args.session}") return From ea1e1e7231044b0889cc034d116d0031f11d4231 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sat, 11 Apr 2026 14:47:41 -0700 Subject: [PATCH 09/13] Add session size (kb/Mb) to listing --- tools/chat_sessions.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 2cf723ad..355e4134 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -251,6 +251,7 @@ def parse_jsonl_metadata(path: Path) -> SessionInfo | None: "session_id": path.stem, "title": None, "creation_date": None, + "size": size, "requests": [], } @@ -315,6 +316,7 @@ def parse_jsonl(path: Path) -> SessionInfo | None: "session_id": path.stem, "title": None, "creation_date": None, + "size": path.stat().st_size, "requests": [], } @@ -458,6 +460,7 @@ def parse_json_metadata(path: Path) -> SessionInfo | None: "title": title, "creation_date": creation_date, "model": "", + "size": size, "requests": [{"user": first_user}] if has_requests else [], } return info @@ -475,6 +478,7 @@ def parse_json(path: Path) -> SessionInfo | None: "session_id": data.get("sessionId", path.stem), "title": data.get("customTitle"), "creation_date": data.get("creationDate"), + "size": path.stat().st_size, "model": ( data.get("inputState", {}) .get("selectedModel", {}) @@ -573,6 +577,7 @@ def load_all_sessions(metadata_only: bool = False) -> list[SessionInfo]: "session_id": session_id, "title": None, "creation_date": None, + "size": 0, "model": "", "requests": [], "workspace": workspace, @@ -657,16 +662,22 @@ def list_sessions( label = label.replace("\n", " ").replace("\r", "") date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") + size_kb = s.get("size", 0) / 1024 + if size_kb >= 1024: + size_str = f"{size_kb / 1024:.1f}M" + else: + size_str = f"{size_kb:.0f}K" if use_color: # Colorize the session listing line = ( f" {Fore.CYAN}{i + 1:3d}{Style.RESET_ALL}. " f"[{Fore.YELLOW}{date_str}{Style.RESET_ALL}] " - f"({Fore.MAGENTA}{workspace}{Style.RESET_ALL}) {label}" + f"({Fore.MAGENTA}{workspace}{Style.RESET_ALL}) " + f"{Fore.GREEN}{size_str:>5}{Style.RESET_ALL} {label}" ) else: - line = f" {i + 1:3d}. [{date_str}] ({workspace}) {label}" + line = f" {i + 1:3d}. [{date_str}] ({workspace}) {size_str:>5} {label}" # Clip to terminal width (use visible length to account for ANSI codes) if visible_len(line) > width: line = clip_to_visible_length(line, width - 1) From d0ceddc47bfabcca631a07f84b0abe38736cc010 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sat, 11 Apr 2026 15:01:23 -0700 Subject: [PATCH 10/13] Optimize -n N case -- only look at the 2N newest per stat() --- tools/chat_sessions.py | 67 ++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 355e4134..212397a7 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -550,16 +550,26 @@ def _parse_request(req: dict[str, Any]) -> dict[str, Any] | None: } -def load_all_sessions(metadata_only: bool = False) -> list[SessionInfo]: +def load_all_sessions( + metadata_only: bool = False, limit: int | None = None +) -> list[SessionInfo]: """Load all sessions from disk. Handles both old format (JSON/JSONL files) and new format (GitHub.copilot-chat). When metadata_only is True, use fast head+tail extraction instead of full parsing. Use load_session_by_path() for full parsing. + + When limit is set and metadata_only is True, use file mtime to + pre-sort candidates and only parse the most recent ones. This + avoids the I/O cost of reading all files over slow filesystems. """ sessions: list[SessionInfo] = [] + # Collect candidate files: (path, workspace, suffix). + # For new-format directories, create SessionInfo immediately (no parsing). + candidates: list[tuple[Path, str]] = [] + for session_dir in find_session_dirs(): workspace = get_workspace_name(session_dir) @@ -586,22 +596,36 @@ def load_all_sessions(metadata_only: bool = False) -> list[SessionInfo]: else: # Old format: JSON/JSONL files for f in session_dir.iterdir(): - if f.suffix not in (".jsonl", ".json"): - continue - - if metadata_only: - parsed_info = ( - parse_jsonl_metadata(f) - if f.suffix == ".jsonl" - else parse_json_metadata(f) - ) - else: - parsed_info = ( - parse_jsonl(f) if f.suffix == ".jsonl" else parse_json(f) - ) - if parsed_info is not None: - parsed_info["workspace"] = workspace - sessions.append(parsed_info) + if f.suffix in (".jsonl", ".json"): + candidates.append((f, workspace)) + + # When we have a limit and only need metadata, use mtime to pre-sort + # so we only parse the most recent files. + if metadata_only and limit and len(candidates) > limit: + # stat each file for mtime (cheap compared to open+read+parse) + mtime_candidates: list[tuple[float, Path, str]] = [] + for f, workspace in candidates: + try: + mtime_candidates.append((f.stat().st_mtime, f, workspace)) + except OSError: + continue + mtime_candidates.sort(reverse=True) + # Parse 2x the limit to allow for empty sessions being filtered out. + candidates = [(f, ws) for _, f, ws in mtime_candidates[: limit * 2]] + + # Parse the candidate files. + for f, workspace in candidates: + if metadata_only: + parsed_info = ( + parse_jsonl_metadata(f) + if f.suffix == ".jsonl" + else parse_json_metadata(f) + ) + else: + parsed_info = parse_jsonl(f) if f.suffix == ".jsonl" else parse_json(f) + if parsed_info is not None: + parsed_info["workspace"] = workspace + sessions.append(parsed_info) # Sort by creation date (newest first) sessions.sort( @@ -971,7 +995,9 @@ def main() -> None: # For search, we need full parsing; for everything else, metadata suffices. need_full = args.search is not None - sessions = load_all_sessions(metadata_only=not need_full) + # Pass limit so load_all_sessions can skip parsing old files when -n is set. + listing_limit = args.n if not need_full and not args.session else None + sessions = load_all_sessions(metadata_only=not need_full, limit=listing_limit) if not sessions: if use_color: print(f"{Fore.RED}No chat sessions found.{Style.RESET_ALL}") @@ -1011,7 +1037,10 @@ def main() -> None: return n_empty = sum(1 for s in sessions if not s.get("requests")) - if use_color: + if listing_limit: + # With -n, we only parsed a subset, so don't report total counts. + print() + elif use_color: if n_empty: print( f"Found {Fore.CYAN}{len(sessions)}{Style.RESET_ALL} chat session(s), " From 23f70318c94da1fa3d7b9688261cb932f3369348 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 12 Apr 2026 11:13:46 -0700 Subject: [PATCH 11/13] Make it platform-agnostic. On Windows use a built-in pager instead of more (which doesn't understand ANSI escapes) --- tools/chat_sessions.py | 161 ++++++++++++++++++++++++++++++++--------- uv.lock | 6 +- 2 files changed, 128 insertions(+), 39 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 212397a7..7978fc8b 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -18,17 +18,14 @@ from collections.abc import Iterator import contextlib import datetime -import fcntl import io import json import os from pathlib import Path import re import shutil -import struct import subprocess import sys -import termios from typing import Any from colorama import Fore, init, Style @@ -657,11 +654,8 @@ def format_timestamp(ts: int | None) -> str: def get_terminal_width() -> int: - """Get terminal character width using ioctl.""" - try: - return struct.unpack("HHHH", fcntl.ioctl(0, termios.TIOCGWINSZ, b"\0" * 8))[1] - except (OSError, struct.error, IOError): - return 80 # default fallback + """Get terminal character width.""" + return shutil.get_terminal_size(fallback=(80, 24)).columns def list_sessions( @@ -875,7 +869,7 @@ def search_sessions( print(f"\n{hits} match(es) found.") -def get_default_pager() -> str: +def get_default_pager() -> str | None: """Determine the pager, using the same fallback chain as git.""" # 1. git config core.pager try: @@ -894,47 +888,134 @@ def get_default_pager() -> str: # 3. PAGER env if pager := os.environ.get("PAGER"): return pager - # 4. less - return "less" + # 4. Platform default: less on Unix, built-in on Windows. + if sys.platform != "win32": + return "less" + return None + + +def _read_one_key() -> str: + """Read a single keypress without echo. Returns the character, or '' for + unrecognised special keys (e.g. arrow keys on Windows).""" + # Platform-specific imports are inside the function because msvcrt is + # Windows-only and termios/tty/select are Unix-only. + if sys.platform == "win32": + import msvcrt + + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): # start of a two-byte special key + msvcrt.getwch() # discard second byte + return "" + return ch + else: + import select + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + # Drain the rest of any escape sequence (e.g. arrow keys). + if ch == "\x1b": + while select.select([sys.stdin], [], [], 0.05)[0]: + sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + return ch @contextlib.contextmanager -def smart_pager(pager_cmd: str) -> Iterator[None]: - """Capture output, then pipe through pager only if it exceeds terminal height.""" +def builtin_pager() -> Iterator[None]: + """Built-in forward-only pager: Space=next page, Enter=next line, q=quit.""" if not sys.stdout.isatty(): yield return buf = io.StringIO() old_stdout = sys.stdout - sys.stdout = buf + sys.stdout = buf # type: ignore[assignment] try: yield finally: sys.stdout = old_stdout output = buf.getvalue() - term_lines = shutil.get_terminal_size().lines - n_lines = output.count("\n") + lines = output.splitlines(keepends=True) + page_size = max(1, shutil.get_terminal_size().lines - 1) - if n_lines < term_lines: + if len(lines) <= page_size: old_stdout.write(output) - else: - env = os.environ.copy() - # less: quit-if-one-screen, raw-control-chars, no-init - env.setdefault("LESS", "FRX") - try: - proc = subprocess.Popen( - pager_cmd, - shell=True, - stdin=subprocess.PIPE, - encoding="utf-8", - errors="replace", - env=env, - ) - proc.communicate(input=output) - except (OSError, BrokenPipeError): - old_stdout.write(output) + old_stdout.flush() + return + + # Show first page. + pos = min(page_size, len(lines)) + old_stdout.write("".join(lines[:pos])) + old_stdout.flush() + + prompt = "--More-- (Space=page, Enter=line, q=quit) " + while pos < len(lines): + old_stdout.write(prompt) + old_stdout.flush() + key = _read_one_key() + # Erase the prompt line. + old_stdout.write("\r" + " " * len(prompt) + "\r") + old_stdout.flush() + if key in ("q", "Q", "\x1b", "\x03"): # q, Q, ESC, Ctrl-C + break + elif key in ("\r", "\n"): # Enter — one more line + old_stdout.write(lines[pos]) + old_stdout.flush() + pos += 1 + else: # Space or anything else — next full page + end = min(pos + page_size, len(lines)) + old_stdout.write("".join(lines[pos:end])) + old_stdout.flush() + pos = end + + +@contextlib.contextmanager +def smart_pager(pager_cmd: str) -> Iterator[None]: + """Pipe stdout directly through an external pager process. + + For ``less``, LESS=FRX causes it to exit automatically when all output + fits on one screen. + """ + if not sys.stdout.isatty(): + yield + return + + env = os.environ.copy() + # less: quit-if-one-screen, raw-control-chars, no-init + env.setdefault("LESS", "FRX") + try: + proc = subprocess.Popen( + pager_cmd, + shell=True, + stdin=subprocess.PIPE, + encoding="utf-8", + errors="replace", + env=env, + ) + except OSError: + yield + return + + old_stdout = sys.stdout + sys.stdout = proc.stdin # type: ignore[assignment] + try: + yield + except BrokenPipeError: + pass + finally: + sys.stdout = old_stdout + try: + proc.stdin.close() # type: ignore[union-attr] + except OSError: + pass + proc.wait() def main() -> None: @@ -968,7 +1049,7 @@ def main() -> None: "--pager", type=str, default=None, - help="Pager command (default: from git config, then $GIT_PAGER, $PAGER, less)", + help="Pager command (default: from git config, then $GIT_PAGER, $PAGER, built-in)", ) parser.add_argument( "--no-pager", @@ -991,7 +1072,10 @@ def main() -> None: global use_color use_color = should_use_color(args) - pager_cmd = args.pager if args.pager is not None else get_default_pager() + explicit_pager = args.pager + configured_pager = ( + explicit_pager if explicit_pager is not None else get_default_pager() + ) # For search, we need full parsing; for everything else, metadata suffices. need_full = args.search is not None @@ -1006,7 +1090,12 @@ def main() -> None: return use_pager = not args.no_pager - ctx = smart_pager(pager_cmd) if use_pager else contextlib.nullcontext() + if not use_pager: + ctx: contextlib.AbstractContextManager[None] = contextlib.nullcontext() + elif configured_pager is not None: + ctx = smart_pager(configured_pager) + else: + ctx = builtin_pager() # Always get terminal width for reasonable snippet extraction and display # Only used for clipping if stdout is a TTY or using pager diff --git a/uv.lock b/uv.lock index 5701a7c7..623f4bb2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -2386,7 +2386,6 @@ version = "0.4.0.dev0" source = { editable = "." } dependencies = [ { name = "azure-identity" }, - { name = "black" }, { name = "colorama" }, { name = "mcp", extra = ["cli"] }, { name = "numpy" }, @@ -2410,6 +2409,7 @@ logfire = [ dev = [ { name = "azure-mgmt-authorization" }, { name = "azure-mgmt-keyvault" }, + { name = "black" }, { name = "coverage" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, @@ -2428,7 +2428,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "azure-identity", specifier = ">=1.22.0" }, - { name = "black", specifier = ">=25.12.0" }, { name = "colorama", specifier = ">=0.4.6" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.1.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.12.1" }, @@ -2449,6 +2448,7 @@ provides-extras = ["logfire"] dev = [ { name = "azure-mgmt-authorization", specifier = ">=4.0.0" }, { name = "azure-mgmt-keyvault", specifier = ">=12.1.1" }, + { name = "black", specifier = ">=25.12.0" }, { name = "coverage", extras = ["toml"], specifier = ">=7.9.1" }, { name = "google-api-python-client", specifier = ">=2.184.0" }, { name = "google-auth-httplib2", specifier = ">=0.2.0" }, From 855a88fe2d269f5073ff484a3fe98ee3adb0b803 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 12 Apr 2026 11:59:21 -0700 Subject: [PATCH 12/13] Address @bmerkle's remaining review comments (according to Claude) --- tools/chat_sessions.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 7978fc8b..8c88439f 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -23,6 +23,7 @@ import os from pathlib import Path import re +import shlex import shutil import subprocess import sys @@ -30,9 +31,6 @@ from colorama import Fore, init, Style -VSCODE_USER_DIR = Path.home() / "Library" / "Application Support" / "Code" / "User" -# Updated in main() based on platform - def _detect_vscode_user_dir() -> list[Path]: """Detect VS Code user directories for current environment. @@ -191,7 +189,7 @@ def get_workspace_name(session_dir: Path) -> str: if folder: # "file:///Users/guido/typeagent-py" -> "typeagent-py" return folder.rstrip("/").rsplit("/", 1)[-1] - except Exception: + except (json.JSONDecodeError, OSError): pass if "emptyWindowChatSessions" in str(session_dir): return "(no workspace)" @@ -803,7 +801,10 @@ def show_session(session: SessionInfo) -> None: def search_sessions( sessions: list[SessionInfo], query: str, term_width: int | None = None ) -> None: - """Search all sessions for messages containing query text.""" + """Search all sessions for messages containing query text. + + Note: Search includes only user and assistant messages, not thinking or tool calls. + """ query_lower = query.lower() hits = 0 width = term_width if term_width is not None else 999999 @@ -831,15 +832,16 @@ def search_sessions( 40, width - prefix_len - 10 ) # -10 for ANSI codes half_avail = available // 2 + # Compute initial start/end with match centered start = max(0, idx - half_avail) end = min(len(text), idx + len(query) + half_avail) - # Redistribute unused budget when one side hits a boundary - if start == 0 and end < len(text): - end = min(len(text), end + (half_avail - idx)) - elif end == len(text) and start > 0: - start = max( - 0, start - (half_avail - (len(text) - idx - len(query))) - ) + # If we hit a boundary, use the extra space on the other side + left_unused = idx - start + right_unused = end - (idx + len(query)) + if start == 0: + end = min(len(text), end + left_unused) + elif end == len(text): + start = max(0, start - right_unused) snippet = text[start:end].replace("\n", " ") has_start_ellipsis = start > 0 has_end_ellipsis = end < len(text) @@ -992,8 +994,8 @@ def smart_pager(pager_cmd: str) -> Iterator[None]: env.setdefault("LESS", "FRX") try: proc = subprocess.Popen( - pager_cmd, - shell=True, + shlex.split(pager_cmd), + shell=False, stdin=subprocess.PIPE, encoding="utf-8", errors="replace", From 4f614aa31ef5994a87633b7a625c1cbea64c42ee Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 12 Apr 2026 12:08:32 -0700 Subject: [PATCH 13/13] Shorten search subheadings to 1 colorized line --- tools/chat_sessions.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/chat_sessions.py b/tools/chat_sessions.py index 8c88439f..683898c9 100755 --- a/tools/chat_sessions.py +++ b/tools/chat_sessions.py @@ -814,13 +814,21 @@ def search_sessions( assistant = req.get("assistant", "") if query_lower in user.lower() or query_lower in assistant.lower(): title = s.get("title") or "(untitled)" + title = title.replace("\n", " ").replace("\r", "") date_str = format_timestamp(s.get("creation_date")) workspace = s.get("workspace", "?") - line1 = f"\n [{date_str}] ({workspace}) {title}" + if use_color: + line1 = ( + f"\n{Fore.CYAN}{i + 1:3d}{Style.RESET_ALL}. " + f"[{Fore.YELLOW}{date_str}{Style.RESET_ALL}] " + f"({Fore.MAGENTA}{workspace}{Style.RESET_ALL}) " + f"{Fore.GREEN}{title}{Style.RESET_ALL}" + ) + else: + line1 = f"\n{i + 1}. [{date_str}] ({workspace}) {title}" if visible_len(line1) > width: line1 = clip_to_visible_length(line1, width - 1) print(line1) - print(f" Session #{i + 1}") # Show the matching message snippet for text, label in [(user, "YOU"), (assistant, "COPILOT")]: idx = text.lower().find(query_lower)