""" codex_sessions_tui_v3.py A fast, dependency-free TUI for browsing local Codex JSONL sessions, showing forks as a nested lineage tree, previewing recent user prompts, and starting or resuming chats directly in the current terminal. Python 3.10+ recommended. """ from __future__ import annotations import argparse import concurrent.futures import curses import datetime as dt import datetime import stat import tempfile import json import hashlib import os import re import signal import shlex import shutil import sqlite3 import subprocess import sys import textwrap import time import urllib.error import urllib.parse import urllib.request from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Iterable, Iterator, Sequence try: import fcntl except ImportError: # pragma: no cover - Codex CLI session locks are POSIX-only. fcntl = None # type: ignore[assignment] APP_NAME = "Codex Session Navigator · fork tree" CACHE_VERSION = 3 DEFAULT_PREVIEW_COUNT = 4 DEFAULT_SETTINGS_FILE = Path("~/.codex/tpy_conf") SCROLLBACK_CHOICES = ("old", "new") DEFAULT_SCROLLBACK = "old" DEFAULT_CATALOG_SOURCE = "https://codexfix.h.cr/catalog.php" PUBLIC_CATALOG_SOURCE = "https://raw.githubusercontent.com/openai/codex/main/codex-rs/models-manager/models.json" MAX_CATALOG_BYTES = 4 * 1024 * 1024 DEFAULT_MODEL_CHOICES = ( "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.2", ) DEFAULT_REASONING_EFFORT_CHOICES = ( "minimal", "low", "medium", "high", "xhigh", "max", "ultra", ) UUID_RE = re.compile( r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" ) SPACE_RE = re.compile(r"\s+") NOISE_PREFIXES = ( "# AGENTS.md instructions", "AGENTS.md instructions", "", "", "", "", "", ) # Normalization algorithm from https://file.ax/z4ef9f DROP = object() # Item il cui contenuto può essere tranquillamente mantenuto # rimuovendo il Response API "id". # # IMPORTANTE: # call_id NON viene mai toccato. PORTABLE_ITEMS = { "message", "function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "local_shell_call", "local_shell_call_output", "computer_call", "computer_call_output", "web_search_call", "image_generation_call", "tool_search_call", "tool_search_output", } # Questi contengono stato opaco/provider-specifico. # In particolare reasoning rs_* con store=false causa: # # Item with id 'rs_...' not found # # Il reasoning non è il contesto conversazionale visibile: # messaggi, chiamate tool e output restano. NON_PORTABLE_ITEMS = { "reasoning", } class Stats: def __init__(self): self.ids_removed = 0 self.reasoning_removed = 0 self.response_wrappers_removed = 0 self.json_errors = 0 self.function_calls = 0 self.function_outputs = 0 self.missing_call_ids = [] self.changes = [] def sanitize_item(obj, stats, path): """ Sanifica un singolo Responses API item. """ if not isinstance(obj, dict): return obj item_type = obj.get("type") # # Reasoning # # Non è portabile tra provider/store=false perché può contenere # encrypted_content e riferimenti rs_* non più esistenti. # if item_type in NON_PORTABLE_ITEMS: stats.reasoning_removed += 1 stats.changes.append( f"{path}: rimosso {item_type} " f"id={obj.get('id', '')}" ) return DROP # # Portable response items. # # NON cambiamo "call_id". # # Rimuoviamo solo il campo "id" dell'ITEM. # if item_type in PORTABLE_ITEMS: if item_type == "function_call": stats.function_calls += 1 if item_type == "function_call_output": stats.function_outputs += 1 call_id = obj.get("call_id") if not isinstance(call_id, str) or not call_id: stats.missing_call_ids.append(path) if "id" in obj: old_id = obj["id"] stats.ids_removed += 1 stats.changes.append( f"{path}: {item_type}: rimosso id={old_id!r}" ) obj = dict(obj) obj.pop("id", None) # # Continua comunque ricorsivamente. # return sanitize_children(obj, stats, path) def sanitize_children(obj, stats, path): if isinstance(obj, list): result = [] for i, item in enumerate(obj): cleaned = sanitize( item, stats, f"{path}[{i}]" ) if cleaned is DROP: continue result.append(cleaned) return result if isinstance(obj, dict): result = {} for key, value in obj.items(): cleaned = sanitize( value, stats, f"{path}.{key}" ) if cleaned is DROP: continue result[key] = cleaned return result return obj def sanitize(obj, stats, path="$"): if isinstance(obj, dict): # # Formato rollout: # # { # "type": "response_item", # "payload": { # "type": "function_call", # ... # } # } # if obj.get("type") == "response_item": payload = obj.get("payload") if isinstance(payload, dict): cleaned_payload = sanitize_item( payload, stats, path + ".payload" ) if cleaned_payload is DROP: stats.response_wrappers_removed += 1 return DROP result = {} for key, value in obj.items(): if key == "payload": result[key] = cleaned_payload else: cleaned = sanitize( value, stats, path + "." + key ) if cleaned is not DROP: result[key] = cleaned return result # # Item Responses API che si trova direttamente dentro # input[], history[], compacted structures, ecc. # item_type = obj.get("type") if ( item_type in PORTABLE_ITEMS or item_type in NON_PORTABLE_ITEMS ): return sanitize_item(obj, stats, path) return sanitize_children(obj, stats, path) if isinstance(obj, list): return sanitize_children(obj, stats, path) return obj def scan_remaining_bad_ids(obj, found, path="$"): """ Cerca qualunque campo chiamato "id" che contenga ancora i prefissi che tipicamente rompono il replay. """ if isinstance(obj, dict): if "id" in obj and isinstance(obj["id"], str): value = obj["id"] if value.startswith(( "call_", "toolu_", "item_", )): found.append( (path, obj.get("type"), value) ) for key, value in obj.items(): scan_remaining_bad_ids( value, found, f"{path}.{key}" ) elif isinstance(obj, list): for i, value in enumerate(obj): scan_remaining_bad_ids( value, found, f"{path}[{i}]" ) def process(path: Path, dry_run=False, verbose=False): stats = Stats() output_lines = [] with path.open( "r", encoding="utf-8", errors="replace" ) as src: for lineno, line in enumerate(src, 1): if not line.strip(): output_lines.append(line) continue try: obj = json.loads(line) except Exception as e: stats.json_errors += 1 print( f"WARNING JSON riga {lineno}: {e}", file=sys.stderr ) output_lines.append(line) continue cleaned = sanitize( obj, stats, f"line[{lineno}]" ) if cleaned is DROP: continue output_lines.append( json.dumps( cleaned, ensure_ascii=False, separators=(",", ":") ) + "\n" ) # # Seconda passata di controllo. # remaining = [] for lineno, line in enumerate(output_lines, 1): if not line.strip(): continue try: obj = json.loads(line) except Exception: continue scan_remaining_bad_ids( obj, remaining, f"output-line[{lineno}]" ) if verbose: print("\nMODIFICHE:") for change in stats.changes: print(" -", change) print() print("RISULTATO") print("=========") print(f"ID item rimossi: {stats.ids_removed}") print(f"Reasoning rimossi: {stats.reasoning_removed}") print( f"Wrapper reasoning rimossi: " f"{stats.response_wrappers_removed}" ) print(f"Function call trovate: {stats.function_calls}") print(f"Function output trovati: {stats.function_outputs}") print(f"JSON non parseabili: {stats.json_errors}") if stats.missing_call_ids: print() print("ATTENZIONE: function_call_output senza call_id:") for x in stats.missing_call_ids: print(" -", x) if remaining: print() print("ATTENZIONE: sono rimasti ID sospetti:") for path_, type_, value in remaining: print( f" - {path_}: " f"type={type_!r} id={value!r}" ) else: print() print("Nessun id call_/toolu_/item_ rimasto come campo id.") if dry_run: print() print("DRY RUN: nessun file modificato.") return None timestamp = datetime.datetime.now().strftime( "%Y%m%d-%H%M%S" ) backup = Path( str(path) + f".backup-{timestamp}" ) shutil.copy2(path, backup) mode = stat.S_IMODE(path.stat().st_mode) fd, tmpname = tempfile.mkstemp( dir=path.parent, prefix=".codexfix-", suffix=".jsonl" ) os.close(fd) tmp = Path(tmpname) try: with tmp.open( "w", encoding="utf-8", newline="\n" ) as dst: dst.writelines(output_lines) os.chmod(tmp, mode) os.replace(tmp, path) except Exception: try: tmp.unlink() except Exception: pass raise print() print("Backup:") print(backup) print() print("File corretto:") print(path) return backup def unique_strings(values: Iterable[Any]) -> list[str]: result: list[str] = [] seen: set[str] = set() for value in values: if not isinstance(value, str): continue value = value.strip() if not value or value in seen: continue seen.add(value) result.append(value) return result def validate_model_catalog(value: Any) -> dict[str, Any]: if not isinstance(value, dict) or not isinstance(value.get("models"), list): raise ValueError("Expected a Codex catalog with a models array") seen: set[str] = set() for model in value["models"]: if not isinstance(model, dict): raise ValueError("Invalid model definition") slug = model.get("slug") if not isinstance(slug, str) or not slug.strip() or slug in seen: raise ValueError("Model IDs must be nonempty and unique") if not isinstance(model.get("display_name"), str): raise ValueError(f"Missing display_name for {slug}") if not isinstance(model.get("supported_reasoning_levels"), list): raise ValueError(f"Missing reasoning settings for {slug}") if not (isinstance(model.get("model_messages"), dict) or isinstance(model.get("base_instructions"), str)): raise ValueError(f"Missing full model instructions for {slug}") seen.add(slug) return value def configured_catalog_path(codex_home: Path) -> Path | None: try: source = (codex_home / "config.toml").read_text(encoding="utf-8") except OSError: return None try: import tomllib value = tomllib.loads(source).get("model_catalog_json") except (ImportError, ValueError, TypeError): # Only inspect top-level keys on Python 3.10. value = None for line in source.splitlines(): if line.lstrip().startswith("["): break match = re.fullmatch(r'''\s*model_catalog_json\s*=\s*["']([^"']+)["']\s*(?:#.*)?''', line) if match: value = match.group(1) break if not isinstance(value, str) or not value: return None path = Path(value).expanduser() return path if path.is_absolute() else codex_home / path class CatalogNoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args: Any, **kwargs: Any) -> None: return None def fetch_model_catalog(url: str) -> dict[str, Any]: if urllib.parse.urlsplit(url).scheme != "https": raise ValueError("Catalog sources must use HTTPS") request = urllib.request.Request(url, headers={ "Accept": "application/json", "User-Agent": "codexfix-catalog-sync/1", }) # Public feeds only: never attach provider or OpenAI credentials. with urllib.request.build_opener(CatalogNoRedirect).open(request, timeout=4) as response: contents = response.read(MAX_CATALOG_BYTES + 1) if len(contents) > MAX_CATALOG_BYTES: raise ValueError("Catalog exceeds the download size limit") catalog = validate_model_catalog(json.loads(contents)) if not catalog["models"]: raise ValueError("The source catalog is empty") return catalog def write_catalog_atomic(path: Path, catalog: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, name = tempfile.mkstemp(prefix=".catalog-", dir=path.parent) temporary = Path(name) try: with os.fdopen(fd, "w", encoding="utf-8") as stream: json.dump(catalog, stream, ensure_ascii=False, indent=2) stream.write("\n") stream.flush() os.fsync(stream.fileno()) if path.exists(): os.chmod(temporary, stat.S_IMODE(path.stat().st_mode)) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def codex_version(codex_bin: str) -> tuple[int, int, int] | None: try: result = subprocess.run([codex_bin, "--version"], capture_output=True, text=True, timeout=3) match = re.search(r"\b(\d+)\.(\d+)\.(\d+)", result.stdout) return tuple(map(int, match.groups())) if match else None except (OSError, subprocess.TimeoutExpired): return None @dataclass(frozen=True) class CatalogSync: path: Path | None = None added: tuple[str, ...] = () source: str = "" backup: Path | None = None error: str = "" def sync_model_catalog( codex_home: Path, source_url: str = DEFAULT_CATALOG_SOURCE, codex_bin: str = "codex", ) -> CatalogSync: """Add missing complete definitions, preserving every existing model and root field.""" path = configured_catalog_path(codex_home) or codex_home / "hcr-models.json" cache_name = "hcr-catalog-source.json" if source_url == DEFAULT_CATALOG_SOURCE else ( "catalog-source-" + hashlib.sha256(source_url.encode()).hexdigest()[:16] + ".json" ) cache = codex_home / "cache" / cache_name sources = [source_url] if source_url == DEFAULT_CATALOG_SOURCE: sources.append(PUBLIC_CATALOG_SOURCE) incoming = None failures: list[str] = [] selected_source = "" for url in sources: try: incoming = fetch_model_catalog(url) selected_source = url break except (OSError, ValueError) as exc: failures.append(f"{urllib.parse.urlsplit(url).hostname}: {exc}") if incoming is None: try: incoming = validate_model_catalog(json.loads(cache.read_text(encoding="utf-8"))) selected_source = "cached source" except (OSError, ValueError): return CatalogSync(path=path if path.exists() else None, error="; ".join(failures)) version = codex_version(codex_bin) codex_home.mkdir(parents=True, exist_ok=True) # Serialize only the read/merge/write, keeping network waits outside the lock. with (codex_home / "catalog-sync.lock").open("ab") as lock: if fcntl is not None: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) if path.exists(): current = validate_model_catalog(json.loads(path.read_text(encoding="utf-8"))) else: previous_cache = codex_home / "models_cache.json" try: current = validate_model_catalog(json.loads(previous_cache.read_text(encoding="utf-8"))) except (OSError, ValueError): current = {"models": []} existing = {model["slug"] for model in current["models"]} additions = [] for model in incoming["models"]: if model["slug"] in existing: continue minimum = model.get("minimal_client_version") match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", minimum) if isinstance(minimum, str) else None if match and (version is None or tuple(map(int, match.groups())) > version): continue additions.append(model) backup = None if additions or not path.exists(): merged = dict(current) merged["models"] = [*current["models"], *additions] # Keep new shared metadata too, without replacing local values. for key, value in incoming.items(): merged.setdefault(key, value) if not merged["models"]: return CatalogSync(error="No source models support the installed Codex version") if path.exists(): backup_root = codex_home / "catalog-backups" backup_root.mkdir(parents=True, exist_ok=True, mode=0o700) fd, name = tempfile.mkstemp(prefix=path.name + ".", suffix=".bak", dir=backup_root) os.close(fd) backup = Path(name) shutil.copy2(path, backup) write_catalog_atomic(path, merged) if selected_source != "cached source": try: write_catalog_atomic(cache, incoming) except OSError: pass # The working catalog is already saved; caching is optional. return CatalogSync(path, tuple(model["slug"] for model in additions), selected_source, backup) def catalog_model_choices(path: Path | None) -> list[str]: if path is None: return [] try: catalog = validate_model_catalog(json.loads(path.read_text(encoding="utf-8"))) return [model["slug"] for model in catalog["models"] if model.get("visibility") != "hide"] except (OSError, ValueError): return [] def load_saved_settings(path: Path) -> dict[str, str]: try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} if not isinstance(raw, dict): return {} settings = { key: value.strip() for key, value in raw.items() if key in {"model_provider", "model", "model_reasoning_effort", "scrollback"} and isinstance(value, str) } if settings.get("scrollback") not in SCROLLBACK_CHOICES: settings.pop("scrollback", None) return settings def save_settings( path: Path, provider: str, model: str, reasoning_effort: str, scrollback: str = DEFAULT_SCROLLBACK, ) -> None: payload = { "model_provider": provider, "model": model, "model_reasoning_effort": reasoning_effort, "scrollback": scrollback, } path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_name(path.name + ".tmp") temp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") try: os.chmod(temp, 0o600) except OSError: pass temp.replace(path) def codex_config_choices( codex_home: Path, model_catalog: Path | None = None, ) -> tuple[list[str], list[str], list[str], str, str, str]: """Return provider/model/effort choices and Codex's configured defaults.""" config_path = codex_home / "config.toml" try: source = config_path.read_text(encoding="utf-8") except OSError: return ( [], catalog_model_choices(model_catalog) or list(DEFAULT_MODEL_CHOICES), list(DEFAULT_REASONING_EFFORT_CHOICES), "", "", "", ) providers: list[str] = [] models: list[str] = [] configured_provider = "" configured_model = "" configured_reasoning_effort = "" try: import tomllib data = tomllib.loads(source) configured_provider = str(data.get("model_provider", "")).strip() configured_model = str(data.get("model", "")).strip() configured_reasoning_effort = str( data.get("model_reasoning_effort", "") ).strip() provider_table = data.get("model_providers", {}) if isinstance(provider_table, dict): providers.extend(str(key) for key in provider_table) for details in provider_table.values(): if isinstance(details, dict): models.append(details.get("model", "")) tui = data.get("tui", {}) if isinstance(tui, dict): availability = tui.get("model_availability_nux", {}) if isinstance(availability, dict): models.extend(str(key) for key in availability) except (ImportError, ValueError, TypeError): # Python 3.10 fallback: enough TOML parsing for names and simple strings. section = "" for raw_line in source.splitlines(): line = raw_line.strip() match = re.fullmatch(r"\[model_providers\.([^]]+)\]", line) if match: section = "provider" providers.append(match.group(1).strip('"')) continue if line.startswith("["): section = line continue pair = re.fullmatch( r'(model_provider|model|model_reasoning_effort)' r'\s*=\s*["\']([^"\']+)["\']', line, ) if not pair: continue key, value = pair.groups() if key == "model_provider" and not section: configured_provider = value elif key == "model" and not section: configured_model = value elif key == "model_reasoning_effort" and not section: configured_reasoning_effort = value elif key == "model" and section == "provider": models.append(value) catalog_models = catalog_model_choices(model_catalog or configured_catalog_path(codex_home)) return ( unique_strings([configured_provider, *providers]), unique_strings([configured_model, *models, *(catalog_models or DEFAULT_MODEL_CHOICES)]), unique_strings( [configured_reasoning_effort, *DEFAULT_REASONING_EFFORT_CHOICES] ), configured_provider, configured_model, configured_reasoning_effort, ) @dataclass(slots=True) class Session: path: str session_id: str started_at: str last_at: str cwd: str title: str messages: list[str] size: int mtime_ns: int forked_from_id: str = "" history_base_id: str = "" history_base_end_ordinal: int = 0 message_ordinals: list[int] = field(default_factory=list) inherited_message_count: int = 0 bad_lines: int = 0 skipped_large_lines: int = 0 _search_blob: str = field(default="", repr=False) @property def first_message(self) -> str: return self.messages[0] if self.messages else "[no real user message found]" @property def project(self) -> str: if not self.cwd or self.cwd == "[unknown-cwd]": return "[unknown]" path = Path(self.cwd) return path.name or str(path) @property def message_count(self) -> int: return self.inherited_message_count + len(self.messages) @property def local_message_count(self) -> int: return len(self.messages) @property def display_date(self) -> str: stamp = self.started_at or self.last_at if stamp: return stamp[:10] return fallback_date_from_path(Path(self.path)) @property def search_blob(self) -> str: if not self._search_blob: self._search_blob = "\n".join( [ self.session_id, self.forked_from_id, self.history_base_id, self.cwd, self.title, self.first_message, *self.messages, self.path, ] ).casefold() return self._search_blob def to_cache(self) -> dict[str, Any]: data = asdict(self) data.pop("_search_blob", None) data["message_count"] = self.message_count return data @classmethod def from_cache(cls, data: dict[str, Any]) -> "Session": allowed = { "path", "session_id", "started_at", "last_at", "cwd", "title", "messages", "size", "mtime_ns", "forked_from_id", "history_base_id", "history_base_end_ordinal", "message_ordinals", "inherited_message_count", "bad_lines", "skipped_large_lines", } clean = {key: value for key, value in data.items() if key in allowed} return cls(**clean) @dataclass(frozen=True, slots=True) class LockOwner: pid: int command: str start_time: str @dataclass(frozen=True, slots=True) class ThreadLock: path: Path locked: bool owners: tuple[LockOwner, ...] = () error: str = "" def process_identity(pid: int) -> tuple[str, str]: """Return a short command and Linux start-time token for PID reuse checks.""" proc_dir = Path("/proc") / str(pid) try: raw = (proc_dir / "cmdline").read_bytes().replace(b"\0", b" ").strip() command = raw.decode("utf-8", errors="replace") except OSError: command = "" try: stat_fields = (proc_dir / "stat").read_text(encoding="utf-8").rsplit(") ", 1)[1].split() # Field 22 is process starttime; the suffix starts at field 3. start_time = stat_fields[19] except (OSError, IndexError): start_time = "" return normalize_text(command, 120) or "[unknown command]", start_time def process_is_same(pid: int, start_time: str) -> bool: """True only while the original process still exists (not a reused PID).""" if pid <= 0: return False try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: pass if not start_time: return True return process_identity(pid)[1] == start_time def lock_owner_pids(path: Path) -> list[int]: """Find Linux flock owners for path by device/inode via /proc/locks.""" try: target = path.stat() lines = Path("/proc/locks").read_text(encoding="utf-8").splitlines() except OSError: return [] target_major = os.major(target.st_dev) target_minor = os.minor(target.st_dev) result: list[int] = [] for line in lines: fields = line.split() if len(fields) < 6 or fields[1] not in {"FLOCK", "POSIX", "OFDLCK"}: continue try: pid = int(fields[4]) major_hex, minor_hex, inode_text = fields[5].split(":", 2) matches = ( int(major_hex, 16) == target_major and int(minor_hex, 16) == target_minor and int(inode_text) == target.st_ino ) except (ValueError, IndexError): continue if matches and pid > 0 and pid not in result: result.append(pid) return result def inspect_thread_lock(codex_home: Path, session_id: str) -> ThreadLock: """Probe the kernel lock, not merely the persistent .lock pathname.""" path = codex_home / "thread-writer-locks" / f"{session_id}.lock" if not path.exists(): return ThreadLock(path=path, locked=False) if fcntl is None: return ThreadLock(path=path, locked=False, error="fcntl is unavailable") try: with path.open("rb") as stream: try: fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: owners = [] for pid in lock_owner_pids(path): command, start_time = process_identity(pid) if process_is_same(pid, start_time): owners.append(LockOwner(pid, command, start_time)) return ThreadLock(path=path, locked=True, owners=tuple(owners)) else: fcntl.flock(stream.fileno(), fcntl.LOCK_UN) return ThreadLock(path=path, locked=False) except OSError as exc: return ThreadLock(path=path, locked=False, error=str(exc)) def provider_migration_backup(codex_home: Path, session_id: str) -> Path: root = codex_home / "provider-migrations" / session_id root.mkdir(parents=True, exist_ok=True, mode=0o700) stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ-") return Path(tempfile.mkdtemp(prefix=stamp, dir=root)) def migrate_session_provider( codex_home: Path, session: Session, provider: str, ) -> Path | None: """Change the saved route only; never run the transcript sanitizer. Codex 0.166 validates the provider in its state database before applying resume overrides. Updating that one column preserves every other field, the entire rollout, and the history database's byte offsets. The rollout's original session_meta is historical metadata, not the current route when a state row exists. Only unindexed legacy sessions need a header edit. """ if not provider: return None if not UUID_RE.fullmatch(session.session_id): raise ValueError("Invalid session ID for provider migration") if fcntl is None: raise RuntimeError("Provider migration needs POSIX thread locking") lock_path = codex_home / "thread-writer-locks" / f"{session.session_id}.lock" lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("ab") as lock: try: fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as exc: raise RuntimeError("The session is still in use; provider was not changed") from exc databases = [ path for path in codex_home.glob("state_*.sqlite") if re.fullmatch(r"state_\d+\.sqlite", path.name) ] if databases: database = max(databases, key=lambda p: int(p.stem.split("_")[1])) connection = sqlite3.connect( database.resolve().as_uri() + "?mode=rw", uri=True, timeout=5, ) try: with connection: connection.execute("BEGIN IMMEDIATE") table = connection.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='threads'" ).fetchone() row = connection.execute( "SELECT model_provider FROM threads WHERE id=?", (session.session_id,), ).fetchone() if table else None if row is not None: if row[0] == provider: return None backup = provider_migration_backup(codex_home, session.session_id) journal = backup / "provider.json" with journal.open("x", encoding="utf-8") as stream: json.dump({ "database": str(database.resolve()), "thread_id": session.session_id, "previous_model_provider": row[0], "model_provider": provider, }, stream, indent=2) stream.write("\n") stream.flush() os.fsync(stream.fileno()) connection.execute( "UPDATE threads SET model_provider=? WHERE id=?", (provider, session.session_id), ) return backup finally: connection.close() return migrate_unindexed_provider(codex_home, session, provider) def migrate_unindexed_provider( codex_home: Path, session: Session, provider: str, ) -> Path | None: """Retag a legacy header, copying all subsequent transcript bytes verbatim. Called with the thread writer lock held. Refuse to shift byte offsets if a partially removed state database has left an existing history index. """ path = Path(session.path).resolve() with path.open("rb") as source: header = source.readline() item = json.loads(header) if not isinstance(item, dict): raise ValueError("Session has no session_meta header") meta = item.get("payload", {}) if item.get("type") != "session_meta" or not isinstance(meta, dict): raise ValueError("Session has no session_meta header") if meta.get("id", meta.get("session_id")) != session.session_id: raise ValueError("Session header ID does not match the selected chat") if meta.get("model_provider", "openai") == provider: return None meta["model_provider"] = provider replacement = json.dumps(item, ensure_ascii=False, separators=(",", ":")).encode("utf-8") newline = b"\r\n" if header.endswith(b"\r\n") else b"\n" if header.endswith(b"\n") else b"" # Preserve existing offsets when the new header fits in the old space. replacement = replacement.ljust(len(header) - len(newline), b" ") + newline if len(replacement) != len(header): for database in codex_home.glob("thread_history_*.sqlite"): connection = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True) try: tables = connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() for (table,) in tables: quoted = '"' + table.replace('"', '""') + '"' columns = connection.execute(f"PRAGMA table_info({quoted})").fetchall() if any(column[1] == "thread_id" for column in columns): if connection.execute( f"SELECT 1 FROM {quoted} WHERE thread_id=? LIMIT 1", (session.session_id,), ).fetchone(): raise RuntimeError("History index exists without a state row; restore the state database before migrating") finally: connection.close() backup = provider_migration_backup(codex_home, session.session_id) shutil.copy2(path, backup / path.name) fd, name = tempfile.mkstemp(prefix=".codex-provider-", dir=path.parent) temporary = Path(name) try: with os.fdopen(fd, "wb") as target: target.write(replacement) shutil.copyfileobj(source, target) target.flush() os.fsync(target.fileno()) shutil.copystat(path, temporary) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) return backup def normalize_text(value: str, max_chars: int) -> str: value = SPACE_RE.sub(" ", value).strip() if len(value) > max_chars: return value[: max_chars - 1] + "…" return value def is_real_user_text(value: str) -> bool: stripped = value.lstrip() return bool(stripped) and not stripped.startswith(NOISE_PREFIXES) def parse_iso(value: str) -> dt.datetime | None: if not value: return None try: normalized = value[:-1] + "+00:00" if value.endswith("Z") else value parsed = dt.datetime.fromisoformat(normalized) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=dt.timezone.utc) return parsed except ValueError: return None def timestamp_sort_value(value: str, fallback_ns: int = 0) -> float: parsed = parse_iso(value) if parsed is not None: return parsed.timestamp() return fallback_ns / 1_000_000_000 def fallback_date_from_path(path: Path) -> str: try: day = path.parent.name month = path.parent.parent.name year = path.parent.parent.parent.name except IndexError: return "[unknown-date]" if ( len(year) == 4 and len(month) == 2 and len(day) == 2 and year.isdigit() and month.isdigit() and day.isdigit() ): return f"{year}-{month}-{day}" return "[unknown-date]" def fallback_id_from_path(path: Path) -> str: stem = path.stem.removeprefix("rollout-") match = UUID_RE.search(stem) return match.group(1) if match else stem def iter_bounded_jsonl( path: Path, max_line_bytes: int ) -> Iterator[tuple[str | None, bool]]: """ Yield (decoded_line, was_too_large). A bounded reader avoids retaining a pathological multi-gigabyte JSONL line. Oversized lines are drained and skipped. """ with path.open("rb") as handle: first = True while True: raw = handle.readline(max_line_bytes + 1) if not raw: break too_large = len(raw) > max_line_bytes if too_large: while raw and not raw.endswith(b"\n"): raw = handle.readline(max_line_bytes + 1) yield None, True first = False continue encoding = "utf-8-sig" if first else "utf-8" first = False try: yield raw.decode(encoding, errors="replace"), False except UnicodeError: yield raw.decode("utf-8", errors="replace"), False def content_texts(content: Any) -> Iterable[str]: if isinstance(content, str): yield content return if not isinstance(content, list): return for part in content: if isinstance(part, str): yield part continue if not isinstance(part, dict): continue part_type = part.get("type") if part_type in {"input_text", "text"}: text = part.get("text") if isinstance(text, str): yield text def possible_thread_title(payload: dict[str, Any]) -> str: payload_type = str(payload.get("type", "")) if payload_type not in { "thread_name_updated", "thread_name", "conversation_name_updated", }: return "" for key in ("thread_name", "name", "title"): value = payload.get(key) if isinstance(value, str) and value.strip(): return value return "" def append_message(messages: list[str], value: str, max_chars: int) -> bool: normalized = normalize_text(value, max_chars) if not is_real_user_text(normalized): return False messages.append(normalized) return True def parse_session_file( path: Path, max_line_bytes: int, max_message_chars: int ) -> Session | None: try: stat = path.stat() except OSError: return None session_id = "" started_at = "" last_at = "" cwd = "" title = "" messages: list[str] = [] message_ordinals: list[int] = [] event_messages: list[str] = [] event_message_ordinals: list[int] = [] forked_from_id = "" history_base_id = "" history_base_end_ordinal = 0 bad_lines = 0 skipped_large_lines = 0 try: for raw, too_large in iter_bounded_jsonl(path, max_line_bytes): if too_large: skipped_large_lines += 1 continue if raw is None or not raw.strip(): continue try: item = json.loads(raw) except json.JSONDecodeError: bad_lines += 1 continue if not isinstance(item, dict): continue item_timestamp = item.get("timestamp") if isinstance(item_timestamp, str) and item_timestamp: if not started_at: started_at = item_timestamp last_at = item_timestamp item_type = item.get("type") payload = item.get("payload") if not isinstance(payload, dict): payload = {} if item_type == "session_meta": payload_timestamp = payload.get("timestamp") if isinstance(payload_timestamp, str) and payload_timestamp: started_at = payload_timestamp if not last_at: last_at = payload_timestamp payload_cwd = payload.get("cwd") if isinstance(payload_cwd, str) and payload_cwd: cwd = payload_cwd payload_id = payload.get("id") if isinstance(payload_id, str) and payload_id: session_id = payload_id payload_forked_from = payload.get("forked_from_id") if isinstance(payload_forked_from, str): forked_from_id = payload_forked_from history_base = payload.get("history_base") if isinstance(history_base, dict): base_id = history_base.get("thread_id") if isinstance(base_id, str): history_base_id = base_id end_ordinal = history_base.get("end_ordinal_exclusive") if isinstance(end_ordinal, int): history_base_end_ordinal = end_ordinal for key in ("title", "thread_name", "name"): value = payload.get(key) if isinstance(value, str) and value.strip(): title = normalize_text(value, max_message_chars) break candidate_title = possible_thread_title(payload) if candidate_title: title = normalize_text(candidate_title, max_message_chars) if ( item_type == "response_item" and payload.get("type") == "message" and payload.get("role") == "user" ): for value in content_texts(payload.get("content")): if append_message(messages, value, max_message_chars): ordinal = item.get("ordinal") message_ordinals.append(ordinal if isinstance(ordinal, int) else 0) if item_type == "event_msg" and payload.get("type") == "user_message": value = payload.get("message") if isinstance(value, str): if append_message(event_messages, value, max_message_chars): ordinal = item.get("ordinal") event_message_ordinals.append( ordinal if isinstance(ordinal, int) else 0 ) except (OSError, MemoryError): return None # Some Codex generations stored each prompt twice: once as response_item # and once as event_msg. Prefer response_item as the canonical stream, and # use event_msg only for older logs that have no response_item prompts. if not messages and event_messages: messages = event_messages message_ordinals = event_message_ordinals if not session_id: session_id = fallback_id_from_path(path) if not cwd: cwd = "[unknown-cwd]" if not started_at: fallback_date = fallback_date_from_path(path) started_at = "" if fallback_date.startswith("[") else fallback_date if not last_at: last_at = started_at return Session( path=str(path), session_id=session_id, started_at=started_at, last_at=last_at, cwd=cwd, title=title, messages=messages, size=stat.st_size, mtime_ns=stat.st_mtime_ns, forked_from_id=forked_from_id, history_base_id=history_base_id, history_base_end_ordinal=history_base_end_ordinal, message_ordinals=message_ordinals, bad_lines=bad_lines, skipped_large_lines=skipped_large_lines, ) def populate_inherited_message_counts(sessions: Sequence[Session]) -> None: """Resolve Codex's paginated history links into logical prompt counts.""" by_id = {session.session_id: session for session in sessions} memo: dict[tuple[str, int | None], int] = {} def logical_count( session: Session, end_ordinal_exclusive: int | None = None, visiting: frozenset[str] = frozenset(), ) -> int: key = (session.session_id, end_ordinal_exclusive) if key in memo: return memo[key] if session.session_id in visiting: return 0 next_visiting = visiting | {session.session_id} inherited = 0 base = by_id.get(session.history_base_id) if base is not None and base.session_id != session.session_id: cutoff = session.history_base_end_ordinal or None inherited = logical_count(base, cutoff, next_visiting) if end_ordinal_exclusive is None: local = len(session.messages) else: local = sum( 1 for ordinal in session.message_ordinals if ordinal <= 0 or ordinal < end_ordinal_exclusive ) total = inherited + local memo[key] = total return total for session in sessions: total = logical_count(session) session.inherited_message_count = max(0, total - len(session.messages)) class SessionIndex: def __init__( self, base_dir: Path, cache_file: Path, use_cache: bool, workers: int, max_line_bytes: int, max_message_chars: int, ) -> None: self.base_dir = base_dir self.cache_file = cache_file self.use_cache = use_cache self.workers = workers self.max_line_bytes = max_line_bytes self.max_message_chars = max_message_chars self._memory_cache: dict[str, Session] = {} def _load_disk_cache(self) -> dict[str, Session]: if not self.use_cache: return {} try: raw = json.loads(self.cache_file.read_text(encoding="utf-8")) if raw.get("version") != CACHE_VERSION: return {} entries = raw.get("sessions", {}) if not isinstance(entries, dict): return {} result: dict[str, Session] = {} for path, data in entries.items(): if isinstance(path, str) and isinstance(data, dict): try: result[path] = Session.from_cache(data) except (TypeError, ValueError): continue return result except (OSError, json.JSONDecodeError): return {} def _save_disk_cache(self, sessions: Sequence[Session]) -> None: if not self.use_cache: return payload = { "version": CACHE_VERSION, "base_dir": str(self.base_dir), "saved_at": dt.datetime.now(dt.timezone.utc).isoformat(), "sessions": {session.path: session.to_cache() for session in sessions}, } try: self.cache_file.parent.mkdir(parents=True, exist_ok=True) temp = self.cache_file.with_suffix(self.cache_file.suffix + ".tmp") temp.write_text( json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8", ) temp.replace(self.cache_file) except OSError: pass def scan(self, force: bool = False) -> list[Session]: files = list(self.base_dir.rglob("rollout-*.jsonl")) cached = {} if force else (self._memory_cache or self._load_disk_cache()) sessions: list[Session] = [] to_parse: list[Path] = [] for path in files: key = str(path) try: stat = path.stat() except OSError: continue existing = cached.get(key) if ( existing is not None and existing.size == stat.st_size and existing.mtime_ns == stat.st_mtime_ns ): sessions.append(existing) else: to_parse.append(path) def parse(path: Path) -> Session | None: return parse_session_file( path, max_line_bytes=self.max_line_bytes, max_message_chars=self.max_message_chars, ) if to_parse: if self.workers <= 1: parsed = map(parse, to_parse) else: executor = concurrent.futures.ThreadPoolExecutor( max_workers=self.workers, thread_name_prefix="codex-session-scan", ) parsed = executor.map(parse, to_parse) try: sessions.extend(session for session in parsed if session is not None) finally: if self.workers > 1: executor.shutdown(wait=True) populate_inherited_message_counts(sessions) self._memory_cache = {session.path: session for session in sessions} self._save_disk_cache(sessions) return sessions def human_size(size: int) -> str: value = float(size) for unit in ("B", "KiB", "MiB", "GiB", "TiB"): if value < 1024 or unit == "TiB": return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}" value /= 1024 return f"{size}B" def human_age(value: str, fallback_ns: int) -> str: timestamp = timestamp_sort_value(value, fallback_ns) if timestamp <= 0: return "unknown" seconds = max(0, int(time.time() - timestamp)) if seconds < 60: return f"{seconds}s ago" minutes = seconds // 60 if minutes < 60: return f"{minutes}m ago" hours = minutes // 60 if hours < 48: return f"{hours}h ago" days = hours // 24 if days < 60: return f"{days}d ago" months = days // 30 if months < 24: return f"{months}mo ago" return f"{days // 365}y ago" def arrange_sessions_as_tree( sessions: Sequence[Session], mode: str = "activity", reverse: bool = True, ) -> tuple[list[Session], dict[str, int]]: """Return a preorder forest and the indentation depth of each session.""" by_id = {session.session_id: session for session in sessions} children: dict[str, list[Session]] = {session.session_id: [] for session in sessions} roots: list[Session] = [] for session in sessions: parent = by_id.get(session.forked_from_id) if parent is None or parent.session_id == session.session_id: roots.append(session) else: children[parent.session_id].append(session) def sort_key(session: Session) -> Any: if mode == "started": return timestamp_sort_value(session.started_at, session.mtime_ns) if mode == "project": return (session.project.casefold(), session.cwd.casefold()) if mode == "messages": return ( session.message_count, timestamp_sort_value(session.last_at, session.mtime_ns), ) return timestamp_sort_value(session.last_at, session.mtime_ns) def branch_key(session: Session, visiting: frozenset[str] = frozenset()) -> Any: if session.session_id in visiting: return sort_key(session) values = [sort_key(session)] next_visiting = visiting | {session.session_id} values.extend(branch_key(child, next_visiting) for child in children[session.session_id]) return (max if reverse else min)(values) roots.sort(key=branch_key, reverse=reverse) for siblings in children.values(): siblings.sort(key=sort_key, reverse=reverse) ordered: list[Session] = [] depths: dict[str, int] = {} visited: set[str] = set() def visit(session: Session, depth: int) -> None: if session.session_id in visited: return visited.add(session.session_id) ordered.append(session) depths[session.session_id] = depth for child in children[session.session_id]: visit(child, depth + 1) for root in roots: visit(root, 0) # A malformed cycle should not make sessions disappear. for session in sorted(sessions, key=sort_key, reverse=reverse): visit(session, 0) return ordered, depths def clip(text: str, width: int) -> str: if width <= 0: return "" text = text.replace("\t", " ") if len(text) <= width: return text if width == 1: return "…" return text[: width - 1] + "…" def safe_addstr( window: curses.window, y: int, x: int, text: str, width: int | None = None, attr: int = 0, ) -> None: try: max_y, max_x = window.getmaxyx() if y < 0 or y >= max_y or x < 0 or x >= max_x: return available = max_x - x if width is not None: available = min(available, width) if available <= 0: return window.addnstr(y, x, text, available, attr) except curses.error: pass def draw_box_title( window: curses.window, y: int, x: int, width: int, title: str, attr: int ) -> None: if width < 4: return safe_addstr(window, y, x, "┌" + "─" * (width - 2) + "┐", width, attr) safe_addstr(window, y, x + 2, f" {clip(title, width - 6)} ", width - 4, attr) class Navigator: SORTS = ("activity", "started", "project", "messages") SELECTOR_ROWS = 5 def __init__( self, stdscr: curses.window, index: SessionIndex, sessions: list[Session], args: argparse.Namespace, ) -> None: self.stdscr = stdscr self.index = index self.all_sessions = sessions self.sessions: list[Session] = [] self.session_depths: dict[str, int] = {} self.args = args self.query = "" self.current_cwd_only = args.current_dir self.sort_mode = 0 self.sort_desc = True self.selected = 0 self.top = 0 self.preview_count = args.preview_count self.status = getattr(args, "catalog_sync_status", "") self.help_open = False self.model_provider = args.model_provider or "" self.model = args.model or "" self.reasoning_effort = args.reasoning_effort or "" self.scrollback = args.scrollback self.provider_choices = unique_strings( [self.model_provider, *args.provider_choices] ) self.model_choices = unique_strings([self.model, *args.model_choices]) self.reasoning_effort_choices = unique_strings( [self.reasoning_effort, *args.reasoning_effort_choices] ) self.colors: dict[str, int] = {} self.current_dir = str(Path.cwd().resolve()) if args.codex_home: self.codex_home = Path(args.codex_home).expanduser() elif index.base_dir.name == "sessions": self.codex_home = index.base_dir.parent else: self.codex_home = Path( os.environ.get("CODEX_HOME", str(Path.home() / ".codex")) ).expanduser() self._configure_curses() self.apply_filter(keep_session_id=None) def _configure_curses(self) -> None: self.stdscr.keypad(True) self.stdscr.timeout(-1) try: curses.curs_set(0) except curses.error: pass if curses.has_colors(): curses.start_color() try: curses.use_default_colors() except curses.error: pass curses.init_pair(1, curses.COLOR_CYAN, -1) curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN) curses.init_pair(3, curses.COLOR_YELLOW, -1) curses.init_pair(4, curses.COLOR_GREEN, -1) curses.init_pair(5, curses.COLOR_RED, -1) self.colors = { "accent": curses.color_pair(1), "selected": curses.color_pair(2) | curses.A_BOLD, "warning": curses.color_pair(3), "good": curses.color_pair(4), "error": curses.color_pair(5), } else: self.colors = { "accent": curses.A_BOLD, "selected": curses.A_REVERSE | curses.A_BOLD, "warning": curses.A_BOLD, "good": curses.A_BOLD, "error": curses.A_BOLD, } @property def selected_session(self) -> Session | None: session_index = self.selected - self.SELECTOR_ROWS if session_index < 0 or session_index >= len(self.sessions): return None return self.sessions[session_index] @property def item_count(self) -> int: return self.SELECTOR_ROWS + len(self.sessions) def apply_filter(self, keep_session_id: str | None) -> None: terms = [term.casefold() for term in self.query.split() if term.strip()] eligible = [] for session in self.all_sessions: if self.current_cwd_only: try: session_cwd = str(Path(session.cwd).expanduser().resolve()) except OSError: session_cwd = session.cwd if session_cwd != self.current_dir: continue eligible.append(session) if terms: by_id = {session.session_id: session for session in eligible} included = { session.session_id for session in eligible if all(term in session.search_blob for term in terms) } # Keep the ancestry visible around search hits. A matching fork # should never lose its indentation and look like a root session. for matched_id in tuple(included): cursor = by_id.get(matched_id) seen: set[str] = set() while cursor is not None and cursor.forked_from_id not in seen: parent_id = cursor.forked_from_id if not parent_id: break seen.add(parent_id) parent = by_id.get(parent_id) if parent is None: break included.add(parent_id) cursor = parent filtered = [ session for session in eligible if session.session_id in included ] else: filtered = eligible self.sessions, self.session_depths = arrange_sessions_as_tree( filtered, mode=self.SORTS[self.sort_mode], reverse=self.sort_desc, ) if keep_session_id: for index, session in enumerate(self.sessions): if session.session_id == keep_session_id: self.selected = index + self.SELECTOR_ROWS break else: self.selected = min(self.selected, self.item_count - 1) else: self.selected = min(self.selected, self.item_count - 1) self.top = min(self.top, self.selected) def run(self) -> None: while True: self.draw() key = self.stdscr.getch() if self.help_open: self.help_open = False continue if key in (ord("q"), ord("Q")): return if key in (curses.KEY_UP, ord("k")): self.move(-1) elif key in (curses.KEY_DOWN, ord("j")): self.move(1) elif key in (curses.KEY_PPAGE,): self.move(-max(1, self.visible_rows() - 1)) elif key in (curses.KEY_NPAGE,): self.move(max(1, self.visible_rows() - 1)) elif key in (curses.KEY_HOME, ord("g")): self.selected = 0 elif key in (curses.KEY_END, ord("G")): self.selected = self.item_count - 1 elif key == ord("/"): self.prompt_search() elif key == 27: if self.query: current = self.selected_session self.query = "" self.apply_filter(current.session_id if current else None) self.status = "Search cleared" elif self.current_cwd_only: self.current_cwd_only = False self.apply_filter(None) self.status = "Showing all working directories" elif key in (ord("s"),): current = self.selected_session self.sort_mode = (self.sort_mode + 1) % len(self.SORTS) self.sort_desc = self.SORTS[self.sort_mode] != "project" self.apply_filter(current.session_id if current else None) self.status = f"Sort: {self.SORTS[self.sort_mode]}" elif key == ord("S"): current = self.selected_session self.sort_desc = not self.sort_desc self.apply_filter(current.session_id if current else None) self.status = "Sort direction reversed" elif key in (ord("c"), ord("C")): current = self.selected_session self.current_cwd_only = not self.current_cwd_only self.apply_filter(current.session_id if current else None) self.status = ( "Current working directory only" if self.current_cwd_only else "Showing all working directories" ) elif key in (ord("m"), ord("M")): counts = [3, 4, 6, 10] try: index = counts.index(self.preview_count) except ValueError: index = 1 self.preview_count = counts[(index + 1) % len(counts)] self.status = f"Previewing last {self.preview_count} user prompts" elif key in (ord("r"), ord("R")): self.reload(force=key == ord("R")) elif key in (10, 13, curses.KEY_ENTER): if self.selected == 0: self.select_provider() elif self.selected == 1: self.select_model() elif self.selected == 2: self.select_reasoning_effort() elif self.selected == 3: self.select_scrollback() elif self.selected == 4: self.new_chat() else: session = self.selected_session if session: self.resume(session) elif key in (ord("o"), ord("O")): session = self.selected_session if session: self.open_log(session) elif key in (ord("y"), ord("Y")): session = self.selected_session if session: self.copy_id(session) elif key in (ord("x"), ord("X")): self.operations_menu() elif key == ord("?"): self.help_open = True def visible_rows(self) -> int: height, width = self.stdscr.getmaxyx() body_height = max(1, height - 4) if width >= 110: return max(1, body_height - 2) list_height = max(7, int(body_height * 0.56)) return max(1, list_height - 2) def move(self, delta: int) -> None: self.selected = max(0, min(self.item_count - 1, self.selected + delta)) def ensure_visible(self, rows: int) -> None: if self.selected < self.top: self.top = self.selected elif self.selected >= self.top + rows: self.top = self.selected - rows + 1 max_top = max(0, self.item_count - rows) self.top = max(0, min(self.top, max_top)) def draw(self) -> None: self.stdscr.erase() height, width = self.stdscr.getmaxyx() if height < 12 or width < 55: safe_addstr( self.stdscr, 0, 0, "Terminal too small. Resize to at least 55×12.", width, self.colors["error"], ) self.stdscr.refresh() return title = f" {APP_NAME} " base = clip(str(self.index.base_dir), max(10, width - len(title) - 4)) safe_addstr( self.stdscr, 0, 0, title, len(title), self.colors["selected"], ) safe_addstr( self.stdscr, 0, len(title) + 1, base, width - len(title) - 2, curses.A_DIM, ) flags = [] if self.query: flags.append(f"search={self.query!r}") if self.current_cwd_only: flags.append("cwd=current") direction = "desc" if self.sort_desc else "asc" summary = ( f"{len(self.sessions)}/{len(self.all_sessions)} sessions " f"sort={self.SORTS[self.sort_mode]}:{direction}" ) if flags: summary += " " + " ".join(flags) safe_addstr(self.stdscr, 1, 0, clip(summary, width), width, curses.A_BOLD) body_y = 2 body_h = height - 4 if width >= 110: left_w = max(56, min(78, int(width * 0.58))) right_x = left_w + 1 right_w = width - right_x self.draw_list(body_y, 0, body_h, left_w) for divider_y in range(body_y, body_y + body_h): safe_addstr( self.stdscr, divider_y, left_w, "│", 1, curses.A_DIM, ) self.draw_preview(body_y, right_x, body_h, right_w) else: list_h = max(7, int(body_h * 0.56)) preview_y = body_y + list_h self.draw_list(body_y, 0, list_h, width) safe_addstr( self.stdscr, preview_y, 0, "─" * width, width, curses.A_DIM, ) self.draw_preview(preview_y + 1, 0, body_h - list_h - 1, width) footer = ( "↑↓/jk move Enter choose/launch / search c cwd s sort " "x operations r reload o log y copy ID ? help q quit" ) safe_addstr( self.stdscr, height - 2, 0, clip(footer, width), width, curses.A_REVERSE, ) status = self.status or "Ready" safe_addstr( self.stdscr, height - 1, 0, clip(status, width), width, curses.A_DIM, ) if self.help_open: self.draw_help() self.stdscr.refresh() def draw_list(self, y: int, x: int, height: int, width: int) -> None: if height <= 2 or width <= 8: return header_attr = self.colors["accent"] | curses.A_BOLD if width >= 68: date_w, project_w, count_w = 10, 18, 4 prompt_w = max(8, width - date_w - project_w - count_w - 6) header = ( f"{'DATE':<{date_w}} {'PROJECT':<{project_w}} " f"{'#':>{count_w}} {'FIRST USER PROMPT':<{prompt_w}}" ) else: date_w, project_w, count_w = 10, 14, 3 prompt_w = max(8, width - date_w - project_w - count_w - 6) header = ( f"{'DATE':<{date_w}} {'PROJECT':<{project_w}} " f"{'#':>{count_w}} {'FIRST PROMPT':<{prompt_w}}" ) safe_addstr(self.stdscr, y, x, clip(header, width), width, header_attr) rows = height - 1 self.ensure_visible(rows) for row, item_index in enumerate( range(self.top, min(self.item_count, self.top + rows)) ): attr = ( self.colors["selected"] if item_index == self.selected else curses.A_NORMAL ) if item_index < self.SELECTOR_ROWS: if item_index == 4: line = "+ CREATE NEW CHAT" safe_addstr( self.stdscr, y + 1 + row, x, line, width, attr ) continue selectors = ( ("MODEL PROVIDER", self.model_provider), ("MODEL", self.model), ("REASONING EFFORT", self.reasoning_effort), ("SCROLLBACK", self.scrollback), ) label, value = selectors[item_index] value = value or "Codex default" line = f"⚙ {label:<16} {clip(value, max(8, width - 22))}" safe_addstr(self.stdscr, y + 1 + row, x, line, width, attr) continue session = self.sessions[item_index - self.SELECTOR_ROWS] depth = self.session_depths.get(session.session_id, 0) fork_prefix = (" " * (depth - 1) + "> ") if depth else "" prompt = fork_prefix + (session.title or session.first_message) line = ( f"{session.display_date:<{date_w}} " f"{clip(session.project, project_w):<{project_w}} " f"{session.message_count:>{count_w}} " f"{clip(prompt, prompt_w):<{prompt_w}}" ) safe_addstr(self.stdscr, y + 1 + row, x, line, width, attr) if not self.sessions and self.top == 0 and rows > self.SELECTOR_ROWS: message = "No sessions match the current filters." safe_addstr( self.stdscr, y + 1 + self.SELECTOR_ROWS, x + 2, clip(message, width - 4), width - 4, self.colors["warning"], ) def draw_preview(self, y: int, x: int, height: int, width: int) -> None: if height <= 1 or width <= 8: return if self.selected < self.SELECTOR_ROWS: if self.selected == 4: safe_addstr( self.stdscr, y, x, clip("Create new chat", width), width, self.colors["accent"] | curses.A_BOLD, ) description = ( "Starts a new Codex chat in the navigator's current " "directory using the selected provider, model, and " "reasoning effort and scrollback mode." ) for offset, line in enumerate( textwrap.wrap(description, max(8, width)), start=2 ): if y + offset >= y + height: break safe_addstr( self.stdscr, y + offset, x, line, width, curses.A_DIM ) if height > 7: safe_addstr( self.stdscr, y + 7, x, "Press Enter to launch Codex.", width, self.colors["good"], ) return selector_details = ( ( "Choose model provider", self.model_provider, "This provider is passed to Codex when a session is resumed.", ), ( "Choose model", self.model, "This model is passed with --model when a session is resumed.", ), ( "Choose reasoning effort", self.reasoning_effort, "This reasoning effort is passed to Codex when a session is resumed.", ), ( "Choose Scrollback", self.scrollback, "old: inline mode with terminal scrollback. " "new: alternate-screen interface. " "Applies to both new and resumed chats.", ), ) title, value, description = selector_details[self.selected] safe_addstr( self.stdscr, y, x, clip(title, width), width, self.colors["accent"] | curses.A_BOLD, ) safe_addstr( self.stdscr, y + 2, x, clip(f"Current: {value or 'Codex default'}", width), width, curses.A_BOLD, ) for offset, line in enumerate( textwrap.wrap(description, max(8, width)), start=4 ): if y + offset >= y + height: break safe_addstr(self.stdscr, y + offset, x, line, width, curses.A_DIM) if height > 7: safe_addstr( self.stdscr, y + 7, x, "Press Enter to select. Changes are saved automatically.", width, self.colors["good"], ) return session = self.selected_session if session is None: safe_addstr( self.stdscr, y, x, "Select a session to preview it.", width, curses.A_DIM, ) return row = y title = session.title or session.project safe_addstr( self.stdscr, row, x, clip(title, width), width, self.colors["accent"] | curses.A_BOLD, ) row += 1 meta_lines = [ f"ID: {session.session_id}", f"Started: {session.started_at or session.display_date}", f"Activity: {human_age(session.last_at, session.mtime_ns)}", ( f"Messages: {session.message_count} total · " f"{session.inherited_message_count} inherited + " f"{session.local_message_count} local" ), f"CWD: {session.cwd}", f"Log: {human_size(session.size)} · {session.path}", ] if session.forked_from_id: meta_lines.insert(1, f"Forked from: {session.forked_from_id}") if session.bad_lines or session.skipped_large_lines: meta_lines.append( f"Parser: {session.bad_lines} malformed, " f"{session.skipped_large_lines} oversized line(s) skipped" ) for meta in meta_lines: if row >= y + height: return for wrapped in textwrap.wrap( meta, width=max(8, width), replace_whitespace=True, drop_whitespace=True, ) or [""]: if row >= y + height: return attr = ( self.colors["warning"] if meta.startswith("Parser:") else curses.A_DIM ) safe_addstr(self.stdscr, row, x, wrapped, width, attr) row += 1 if row < y + height: safe_addstr( self.stdscr, row, x, f"Recent local user prompts (last {self.preview_count}):", width, curses.A_BOLD, ) row += 1 recent = session.messages[-self.preview_count :] if not recent: recent = ["[no real user message found]"] start_number = max( 1, session.inherited_message_count + len(session.messages) - len(recent) + 1, ) for offset, message in enumerate(recent): if row >= y + height: return prefix = f"{start_number + offset:>3}. " wrapped = textwrap.wrap( message, width=max(8, width - len(prefix)), replace_whitespace=True, drop_whitespace=True, ) or [""] for line_index, wrapped_line in enumerate(wrapped): if row >= y + height: return leader = prefix if line_index == 0 else " " * len(prefix) safe_addstr( self.stdscr, row, x, leader + wrapped_line, width, curses.A_NORMAL, ) row += 1 if row < y + height: row += 1 def draw_help(self) -> None: height, width = self.stdscr.getmaxyx() box_w = min(78, width - 4) box_h = min(24, height - 4) y = (height - box_h) // 2 x = (width - box_w) // 2 try: window = curses.newwin(box_h, box_w, y, x) window.erase() window.box() safe_addstr( window, 0, 2, " Keys ", box_w - 4, self.colors["selected"], ) lines = [ "↑/↓, j/k Move selection", "PgUp/PgDn Move one page", "g/G First / last session", "Enter Change settings, create a chat, or resume", "/ Search ID, project, path, title, or prompt text", "Esc Clear search, then clear current-CWD filter", "c Toggle current working directory filter", "s / S Cycle sort / reverse sort direction", "m Cycle recent-prompt count: 3, 4, 6, 10", "r / R Reload changed files / force full reparse", "o Open selected JSONL in $PAGER", "y Copy selected session ID when clipboard tool exists", "x Operations on selected session", "? Show this help", "q Quit", "", "Codex runs with inherited stdin/stdout, so the resumed session", "is attached directly to this terminal. Exiting Codex returns here.", "", "Press any key to close.", ] for index, line in enumerate(lines[: box_h - 2], start=1): safe_addstr(window, index, 2, clip(line, box_w - 4), box_w - 4) window.refresh() except curses.error: pass def choose_setting( self, title: str, choices: Sequence[str], current: str, *, operations: bool = False ) -> tuple[bool, str]: custom_marker = "\0custom" values = list(choices) if operations else ["", *unique_strings([current, *choices]), custom_marker] labels = [ "Codex default" if value == "" else "Custom value…" if value == custom_marker else value for value in values ] selected = values.index(current) if current in values else 0 top = 0 while True: screen_h, screen_w = self.stdscr.getmaxyx() box_w = min(max(48, max(map(len, labels)) + 8), screen_w - 4) box_h = min(max(7, len(values) + 4), screen_h - 4) visible = max(1, box_h - 4) if selected < top: top = selected elif selected >= top + visible: top = selected - visible + 1 y = (screen_h - box_h) // 2 x = (screen_w - box_w) // 2 try: window = curses.newwin(box_h, box_w, y, x) window.keypad(True) window.erase() window.box() safe_addstr( window, 0, 2, f" {clip(title, box_w - 6)} ", box_w - 4, self.colors["selected"], ) for row, choice_index in enumerate( range(top, min(len(values), top + visible)), start=2 ): attr = ( self.colors["selected"] if choice_index == selected else curses.A_NORMAL ) safe_addstr( window, row, 2, labels[choice_index], box_w - 4, attr ) safe_addstr( window, box_h - 2, 2, "↑↓ choose · Enter accept · Esc cancel", box_w - 4, curses.A_DIM, ) window.refresh() key = window.getch() except curses.error: return False, current if key in (27, ord("q"), ord("Q")): return False, current if key in (curses.KEY_UP, ord("k")): selected = (selected - 1) % len(values) elif key in (curses.KEY_DOWN, ord("j")): selected = (selected + 1) % len(values) elif key in (10, 13, curses.KEY_ENTER): value = values[selected] if value != custom_marker: return True, value custom = self.prompt_setting_value(title) if custom is not None: return True, custom def prompt_setting_value(self, title: str) -> str | None: height, width = self.stdscr.getmaxyx() prompt = f"{title}: " self.stdscr.move(height - 1, 0) self.stdscr.clrtoeol() safe_addstr( self.stdscr, height - 1, 0, prompt, len(prompt), self.colors["accent"] | curses.A_BOLD, ) try: curses.curs_set(1) except curses.error: pass curses.echo() try: raw = self.stdscr.getstr( height - 1, len(prompt), max(1, width - len(prompt) - 1) ) value = raw.decode("utf-8", errors="replace").strip() return value or None except curses.error: return None finally: curses.noecho() try: curses.curs_set(0) except curses.error: pass def confirm(self, title: str, message: str) -> bool: """Show a blocking yes/no modal; default to no for destructive actions.""" while True: screen_h, screen_w = self.stdscr.getmaxyx() box_w = min(76, max(44, screen_w - 8)) wrapped = textwrap.wrap(message, max(20, box_w - 4)) or [""] box_h = min(screen_h - 4, max(7, len(wrapped) + 5)) y = max(0, (screen_h - box_h) // 2) x = max(0, (screen_w - box_w) // 2) try: window = curses.newwin(box_h, box_w, y, x) window.keypad(True) window.erase() window.box() safe_addstr( window, 0, 2, f" {clip(title, box_w - 6)} ", box_w - 4, self.colors["warning"] | curses.A_BOLD, ) for row, line in enumerate(wrapped[: box_h - 4], start=2): safe_addstr(window, row, 2, line, box_w - 4) safe_addstr( window, box_h - 2, 2, "y Yes · n No (default) · Esc cancel", box_w - 4, curses.A_DIM, ) window.refresh() key = window.getch() except curses.error: return False if key in (ord("y"), ord("Y")): return True if key in (ord("n"), ord("N"), 10, 13, curses.KEY_ENTER, 27): return False def unlock_for_resume(self, session: Session) -> bool: """Confirm and terminate the actual kernel-lock owner before resuming.""" lock = inspect_thread_lock(self.codex_home, session.session_id) if lock.error: self.status = f"Could not inspect session lock: {lock.error}" return False if not lock.locked: return True if not self.confirm( "Session locked", "I detected this session is locked. Are you sure you want to " "unlock it forcefully?", ): self.status = "Resume cancelled; the session is still locked" return False # Recheck after the prompt: the owner may have exited while the modal was open. lock = inspect_thread_lock(self.codex_home, session.session_id) if not lock.locked: self.status = "The session lock was already released" return True if not lock.owners: self.status = ( "The lock is active, but its PID could not be identified; " "refusing to unlink a live lock" ) return False owner_lines = [ f"PID {owner.pid}: {owner.command}" for owner in lock.owners ] owner_word = "PID is" if len(owner_lines) == 1 else "PIDs are" if not self.confirm( "Active lock owner", f"The lock owner {owner_word} still active. " + " | ".join(owner_lines) + " — kill it and release the lock?", ): self.status = "Resume cancelled; active lock owner was not killed" return False for owner in lock.owners: if owner.pid == os.getpid() or not process_is_same( owner.pid, owner.start_time ): continue try: os.kill(owner.pid, signal.SIGTERM) except ProcessLookupError: continue except OSError as exc: self.status = f"Could not terminate PID {owner.pid}: {exc}" return False deadline = time.monotonic() + 1.5 while time.monotonic() < deadline: current = inspect_thread_lock(self.codex_home, session.session_id) if not current.locked: self.status = "Session unlocked; resuming…" return True time.sleep(0.05) # The user already approved killing these exact, identity-checked owners. for owner in lock.owners: if owner.pid != os.getpid() and process_is_same( owner.pid, owner.start_time ): try: os.kill(owner.pid, signal.SIGKILL) except ProcessLookupError: pass except OSError as exc: self.status = f"Could not kill PID {owner.pid}: {exc}" return False deadline = time.monotonic() + 1.5 while time.monotonic() < deadline: current = inspect_thread_lock(self.codex_home, session.session_id) if not current.locked: self.status = "Session unlocked; resuming…" return True time.sleep(0.05) self.status = "The session is still locked after terminating its owner" return False def persist_model_settings(self) -> bool: try: save_settings( self.args.settings_file, self.model_provider, self.model, self.reasoning_effort, self.scrollback, ) except OSError as exc: self.status = f"Could not save {self.args.settings_file}: {exc}" return False return True def select_provider(self) -> None: accepted, value = self.choose_setting( "Model provider", self.provider_choices, self.model_provider ) if not accepted: self.status = "Provider selection cancelled" return self.model_provider = value if value and value not in self.provider_choices: self.provider_choices.append(value) if self.persist_model_settings(): self.status = f"Provider: {value or 'Codex default'} (saved)" def select_model(self) -> None: accepted, value = self.choose_setting( "Model", self.model_choices, self.model ) if not accepted: self.status = "Model selection cancelled" return self.model = value if value and value not in self.model_choices: self.model_choices.append(value) if self.persist_model_settings(): self.status = f"Model: {value or 'Codex default'} (saved)" def select_reasoning_effort(self) -> None: accepted, value = self.choose_setting( "Reasoning effort", self.reasoning_effort_choices, self.reasoning_effort, ) if not accepted: self.status = "Reasoning effort selection cancelled" return self.reasoning_effort = value if value and value not in self.reasoning_effort_choices: self.reasoning_effort_choices.append(value) if self.persist_model_settings(): self.status = f"Reasoning effort: {value or 'Codex default'} (saved)" def select_scrollback(self) -> None: accepted, value = self.choose_setting( "Scrollback", SCROLLBACK_CHOICES, self.scrollback, operations=True ) if not accepted: self.status = "Scrollback selection cancelled" return self.scrollback = value if self.persist_model_settings(): self.status = f"Scrollback: {value} (saved)" def prompt_search(self) -> None: height, width = self.stdscr.getmaxyx() prompt = "Search: " self.stdscr.move(height - 1, 0) self.stdscr.clrtoeol() safe_addstr( self.stdscr, height - 1, 0, prompt, len(prompt), self.colors["accent"] | curses.A_BOLD, ) try: curses.curs_set(1) except curses.error: pass curses.echo() try: raw = self.stdscr.getstr( height - 1, len(prompt), max(1, width - len(prompt) - 1), ) query = raw.decode("utf-8", errors="replace").strip() current = self.selected_session self.query = query self.selected = 0 self.top = 0 self.apply_filter(current.session_id if current and query else None) self.status = f"Search: {query}" if query else "Search cleared" except curses.error: self.status = "Search cancelled" finally: curses.noecho() try: curses.curs_set(0) except curses.error: pass def reload(self, force: bool) -> None: current = self.selected_session self.status = "Scanning session logs…" self.draw() self.all_sessions = self.index.scan(force=force) self.apply_filter(current.session_id if current else None) self.status = ( f"Loaded {len(self.all_sessions)} sessions" + (" (full reparse)" if force else "") ) def suspend_and_run( self, command: Sequence[str], cwd: str | None = None, env: dict[str, str] | None = None, return_prompt: bool = True, ) -> int: curses.def_prog_mode() curses.endwin() try: printable = shlex.join(command) print(f"\n$ {printable}") if cwd: print(f"cwd: {cwd}") completed = subprocess.run( list(command), cwd=cwd, env=env, check=False, ) code = completed.returncode except FileNotFoundError: print(f"\nCommand not found: {command[0]}", file=sys.stderr) code = 127 except OSError as exc: print(f"\nCould not launch command: {exc}", file=sys.stderr) code = 126 finally: if return_prompt: try: input("\nPress Enter to return to the session navigator…") except EOFError: pass curses.reset_prog_mode() self.stdscr.keypad(True) try: curses.curs_set(0) except curses.error: pass self.stdscr.clear() self.stdscr.refresh() return code def selected_codex_options(self) -> list[str]: options: list[str] = [] catalog = getattr(self.args, "catalog_json", None) if catalog: options.extend(["-c", "model_catalog_json=" + json.dumps(str(catalog))]) # Existing daemons keep their catalog in memory. New and resumed # chats must read the file that was just synchronized. if "--no-daemon" not in self.args.codex_arg: options.append("--no-daemon") if self.model_provider: # subprocess receives arguments directly, so shell quotes are neither # needed nor wanted here. Escape the value as a TOML basic string. provider = ( self.model_provider .replace("\\", "\\\\") .replace('"', '\\"') ) options.extend(["-c", f'model_provider="{provider}"']) if self.model: options.extend(["--model", self.model]) if self.reasoning_effort: reasoning_effort = ( self.reasoning_effort .replace("\\", "\\\\") .replace('"', '\\"') ) options.extend( ["-c", f'model_reasoning_effort="{reasoning_effort}"'] ) if self.scrollback == "old": options.append("--no-alt-screen") else: # Explicitly enable it even when config.toml sets "never". # Official Codex has no --alt-screen flag. options.extend(["-c", 'tui.alternate_screen="always"']) return options def codex_environment(self) -> dict[str, str]: env = os.environ.copy() if self.args.codex_home: env["CODEX_HOME"] = str(Path(self.args.codex_home).expanduser()) elif self.index.base_dir.name == "sessions": env["CODEX_HOME"] = str(self.index.base_dir.parent) return env def extra_codex_options(self) -> list[str]: # Scrollback owns this flag. Avoid duplicate flags rejected by official # Codex, and let selecting "new" undo an older --codex-arg override. return [arg for arg in self.args.codex_arg if arg != "--no-alt-screen"] def new_chat(self) -> None: command = [ self.args.codex_bin, *self.extra_codex_options(), *self.selected_codex_options(), ] code = self.suspend_and_run(command, env=self.codex_environment()) self.status = ( f"Codex exited with status {code}" if code else "Returned from new chat" ) self.reload(force=False) def resume(self, session: Session) -> None: if not self.unlock_for_resume(session): return try: backup = migrate_session_provider(self.codex_home, session, self.model_provider) except (OSError, ValueError, RuntimeError, sqlite3.Error) as exc: self.status = f"Could not migrate provider: {exc}" return command = [ self.args.codex_bin, *self.extra_codex_options(), "resume", *self.selected_codex_options(), ] if self.model_provider and "--no-daemon" not in command: # A shared daemon validates against its own launch configuration, # even when the resume request explicitly selects another provider. command.append("--no-daemon") command.append(session.session_id) launch_cwd: str | None = None if not self.args.no_chdir and session.cwd != "[unknown-cwd]": expanded = Path(session.cwd).expanduser() if expanded.is_dir(): launch_cwd = str(expanded) else: self.status = ( f"Recorded CWD does not exist; launching from {Path.cwd()}" ) code = self.suspend_and_run( command, cwd=launch_cwd, env=self.codex_environment() ) self.status = ( f"Codex exited with status {code}" if code else f"Returned from {session.session_id[:8]}…" ) if backup: self.status += f" · Provider migrated to {self.model_provider}; backup: {backup}" self.reload(force=False) def operations_menu(self) -> None: session = self.selected_session if session is None: self.status = "Select a chat first" return accepted, operation = self.choose_setting( "Chat operations", ["Normalize tool calls", "Export chat to md"], "Normalize tool calls", operations=True, ) if not accepted: return try: if operation == "Normalize tool calls": lock = inspect_thread_lock(self.codex_home, session.session_id) if lock.locked or lock.error: self.status = "Cannot normalize: session is active or lock cannot be checked" return if not self.confirm( "Normalize tool calls", "Remove item IDs and reasoning from this chat? call_id is preserved. A backup will be created.", ): return curses.def_prog_mode() curses.endwin() try: backup = process(Path(session.path)) input("\nPress Enter to return to the session navigator…") finally: curses.reset_prog_mode() self.stdscr.keypad(True) self.stdscr.clear() self.stdscr.refresh() self.reload(force=True) self.status = f"Normalized; backup: {backup}" else: value = self.prompt_setting_value("Save Markdown to (empty cancels)") if value is None: self.status = "Export cancelled" return destination = Path(value).expanduser() if destination.is_dir(): destination = destination / f"{session.session_id}.md" destination = export_chat_markdown(session, destination) self.status = f"Exported: {destination}" except (OSError, ValueError) as exc: self.status = f"Operation failed: {exc}" def open_log(self, session: Session) -> None: pager = os.environ.get("PAGER", "") if pager: command = shlex.split(pager) + [session.path] elif shutil.which("less"): command = ["less", "-R", session.path] elif shutil.which("more"): command = ["more", session.path] else: self.status = "No $PAGER, less, or more command found" return code = self.suspend_and_run(command) self.status = f"Pager exited with status {code}" if code else "Closed log" def copy_id(self, session: Session) -> None: candidates: list[list[str]] = [] if sys.platform == "darwin": candidates.append(["pbcopy"]) if os.environ.get("WAYLAND_DISPLAY"): candidates.append(["wl-copy"]) candidates.extend([["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]) for command in candidates: if not shutil.which(command[0]): continue try: subprocess.run( command, input=session.session_id, text=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self.status = "Session ID copied to clipboard" return except (OSError, subprocess.CalledProcessError): continue self.status = f"No clipboard tool found; ID: {session.session_id}" def export_chat_markdown(session: Session, destination: Path) -> Path: """Export this log's messages and tool activity without truncating text.""" records = [] with Path(session.path).open(encoding="utf-8") as source: for number, line in enumerate(source, 1): if not line.strip(): continue try: records.append(json.loads(line)) except ValueError as exc: raise ValueError(f"Invalid JSON on line {number}") from exc has_messages = any( isinstance(row, dict) and row.get("type") == "response_item" and isinstance(row.get("payload"), dict) and row["payload"].get("type") == "message" for row in records ) sections = [f"# Chat {session.session_id}", f"Source: {session.path}"] for row in records: if not isinstance(row, dict): continue payload = row.get("payload") if not isinstance(payload, dict): continue kind = payload.get("type", "") if row.get("type") == "response_item": if kind == "message": parts = payload.get("content", []) text = parts if isinstance(parts, str) else "\n\n".join( part.get("text", part.get("refusal", "")) for part in parts if isinstance(part, dict) and isinstance(part.get("text", part.get("refusal", "")), str) ) if text: sections.append(f"## {str(payload.get('role', 'message')).title()}\n\n{text}") elif kind in PORTABLE_ITEMS: body = json.dumps(payload, ensure_ascii=False, indent=2) fence = "`" * max(3, max((len(m.group()) + 1 for m in re.finditer(r"`+", body)), default=3)) sections.append(f"## Tool: {kind}\n\n{fence}json\n{body}\n{fence}") elif not has_messages and row.get("type") == "event_msg" and kind in {"user_message", "agent_message"}: text = payload.get("message", "") if isinstance(text, str) and text: role = "User" if kind == "user_message" else "Assistant" sections.append(f"## {role}\n\n{text}") with destination.open("x", encoding="utf-8") as output: output.write("\n\n".join(sections) + "\n") return destination def print_json(sessions: Sequence[Session]) -> None: json.dump( [session.to_cache() for session in sessions], sys.stdout, ensure_ascii=False, indent=2, ) print() def print_plain(sessions: Sequence[Session]) -> None: ordered, depths = arrange_sessions_as_tree( sessions, mode="started", reverse=False, ) for session in ordered: depth = depths.get(session.session_id, 0) fork_prefix = (" " * (depth - 1) + "> ") if depth else "" print( f"{fork_prefix}{session.display_date} - {session.cwd} - " f"{session.message_count} messages " f"({session.inherited_message_count} inherited + " f"{session.local_message_count} local) - " f"{session.first_message} - codex resume {session.session_id}" ) def build_parser() -> argparse.ArgumentParser: default_codex_home = Path( os.environ.get("CODEX_HOME", str(Path.home() / ".codex")) ).expanduser() default_cache = ( Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "codex-session-navigator" / "index-v3.json" ) parser = argparse.ArgumentParser( description=( "Browse Codex session logs in a curses TUI and start or resume " "chats directly in the terminal." ) ) parser.add_argument( "session_dir", nargs="?", type=Path, default=default_codex_home / "sessions", help="session directory (default: $CODEX_HOME/sessions or ~/.codex/sessions)", ) parser.add_argument( "--preview-count", type=int, default=DEFAULT_PREVIEW_COUNT, help="number of recent user prompts shown in the preview pane (default: 4)", ) parser.add_argument( "--current-dir", action="store_true", help="start filtered to sessions whose recorded CWD is the current directory", ) parser.add_argument( "--no-chdir", action="store_true", help="resume from the navigator's current directory instead of the session CWD", ) parser.add_argument( "--codex-bin", default="codex", help="Codex executable name or path (default: codex)", ) parser.add_argument( "--codex-arg", action="append", default=[], help="global argument placed before 'resume'; repeat as needed", ) parser.add_argument( "--codex-home", type=Path, help="explicit CODEX_HOME used when launching Codex", ) parser.add_argument( "--catalog-source", default=DEFAULT_CATALOG_SOURCE, help="public HTTPS model catalog (default: h.cr, with public Codex GitHub fallback)", ) catalog_mode = parser.add_mutually_exclusive_group() catalog_mode.add_argument( "--no-catalog-sync", action="store_true", help="use the existing catalog without checking for new models", ) catalog_mode.add_argument( "--sync-models", action="store_true", help="add missing models and exit without opening the navigator", ) parser.add_argument( "--model-provider", help="model provider override (otherwise use the saved selection)", ) parser.add_argument( "--model", help="model override, for example --model gpt-5.6-sol", ) parser.add_argument( "--reasoning-effort", help="reasoning effort override, for example --reasoning-effort high", ) parser.add_argument( "--scrollback", choices=SCROLLBACK_CHOICES, help="scrollback mode: old (terminal history) or new (alternate screen); " "otherwise use the saved selection (default: old)", ) parser.add_argument( "--no-alt-screen", dest="scrollback", action="store_const", const="old", help="alias for --scrollback old, matching official Codex", ) parser.add_argument( "--settings-file", type=Path, default=DEFAULT_SETTINGS_FILE, help=f"saved provider/model/reasoning/scrollback path (default: {DEFAULT_SETTINGS_FILE})", ) parser.add_argument( "--workers", type=int, default=min(16, max(2, (os.cpu_count() or 2) + 2)), help="parallel file-parser workers (default: based on CPU count, capped at 16)", ) parser.add_argument( "--max-line-mib", type=float, default=8.0, help="skip individual JSONL lines larger than this many MiB (default: 8)", ) parser.add_argument( "--max-message-chars", type=int, default=4000, help="maximum stored characters per user prompt (default: 4000)", ) parser.add_argument( "--cache-file", type=Path, default=default_cache, help=f"metadata cache path (default: {default_cache})", ) parser.add_argument( "--no-cache", action="store_true", help="disable the on-disk metadata cache", ) parser.add_argument( "--plain", action="store_true", help="print the original one-line format instead of opening the TUI", ) parser.add_argument( "--json", action="store_true", help="print parsed session metadata as JSON instead of opening the TUI", ) return parser _ORIGINAL_STDIO: list[Any] = [] _TTY_STREAMS: list[Any] = [] def attach_tui_to_controlling_terminal() -> None: """Move the interactive process onto the controlling terminal. ``curl URL | python3`` leaves file descriptor 0 attached to the source-code pipe. Merely replacing ``sys.stdin`` is not enough: curses, subprocesses, pagers, and Codex itself use file descriptors 0/1/2. This function opens the controlling terminal once, duplicates it onto all three conventional descriptors, then creates fresh Python text streams so ``input()`` does not retain the pipe's EOF state. """ if sys.stdin.isatty() and sys.stdout.isatty() and sys.stderr.isatty(): return if os.name != "posix": raise RuntimeError( "interactive stdin/stdout are unavailable and /dev/tty is POSIX-only" ) flags = os.O_RDWR if hasattr(os, "O_CLOEXEC"): flags |= os.O_CLOEXEC try: tty_fd = os.open("/dev/tty", flags) except OSError as exc: raise RuntimeError(f"cannot open controlling terminal /dev/tty: {exc}") from exc # Keep the original wrappers alive. They own fd 0/1/2; allowing them to be # garbage-collected after dup2() could close the newly attached terminal. _ORIGINAL_STDIO.extend((sys.stdin, sys.stdout, sys.stderr)) try: for target_fd in (0, 1, 2): os.dup2(tty_fd, target_fd, inheritable=True) finally: if tty_fd > 2: os.close(tty_fd) encoding = ( getattr(sys.__stdout__, "encoding", None) or os.environ.get("PYTHONIOENCODING", "").split(":", 1)[0] or "utf-8" ) # Use duplicates rather than wrappers that own fd 0/1/2 directly. curses # and child processes retain the canonical descriptors, while Python gets # fresh buffering/decoder state after the source pipe reached EOF. tty_in = os.fdopen( os.dup(0), "r", buffering=1, encoding=encoding, errors="replace", closefd=True, ) tty_out = os.fdopen( os.dup(1), "w", buffering=1, encoding=encoding, errors="replace", closefd=True, ) tty_err = os.fdopen( os.dup(2), "w", buffering=1, encoding=encoding, errors="replace", closefd=True, ) _TTY_STREAMS.extend((tty_in, tty_out, tty_err)) sys.stdin = tty_in sys.stdout = tty_out sys.stderr = tty_err # Fail here with a useful message instead of letting curses fail later with # a vague setupterm/cbreak error. if not (os.isatty(0) and os.isatty(1) and os.isatty(2)): raise RuntimeError("/dev/tty was opened, but descriptors 0/1/2 are not TTYs") def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) base_dir = args.session_dir.expanduser().resolve() args.settings_file = args.settings_file.expanduser() codex_home = ( args.codex_home.expanduser() if args.codex_home else Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))).expanduser() ) tui_mode = not args.json and not args.plain args.catalog_json = None args.catalog_sync_status = "" sync = CatalogSync() try: args.catalog_json = configured_catalog_path(codex_home) if args.catalog_json is None and (codex_home / "hcr-models.json").exists(): args.catalog_json = codex_home / "hcr-models.json" if (tui_mode or args.sync_models) and not args.no_catalog_sync: sync = sync_model_catalog(codex_home, args.catalog_source, args.codex_bin) args.catalog_json = sync.path or args.catalog_json if sync.added: args.catalog_sync_status = "Added models: " + ", ".join(sync.added) elif sync.error: args.catalog_sync_status = "Catalog refresh failed; using existing models" except (OSError, ValueError) as exc: sync = CatalogSync(error=str(exc)) args.catalog_sync_status = "Catalog refresh failed; using existing models" if args.sync_models: if sync.error: print(f"Catalog sync failed: {sync.error}", file=sys.stderr) return 1 print("Added models: " + ", ".join(sync.added) if sync.added else "No models to add") print(f"Catalog: {sync.path}\nSource: {sync.source}") if sync.backup: print(f"Backup: {sync.backup}") return 0 ( provider_choices, model_choices, reasoning_effort_choices, configured_provider, configured_model, configured_reasoning_effort, ) = codex_config_choices(codex_home, args.catalog_json) saved = load_saved_settings(args.settings_file) cli_provider = args.model_provider cli_model = args.model cli_reasoning_effort = args.reasoning_effort args.model_provider = ( cli_provider.strip() if cli_provider is not None else saved.get("model_provider", configured_provider) ) args.model = ( cli_model.strip() if cli_model is not None else saved.get("model", configured_model) ) args.reasoning_effort = ( cli_reasoning_effort.strip() if cli_reasoning_effort is not None else saved.get("model_reasoning_effort", configured_reasoning_effort) ) args.scrollback = args.scrollback or saved.get("scrollback", DEFAULT_SCROLLBACK) args.provider_choices = provider_choices args.model_choices = model_choices args.reasoning_effort_choices = reasoning_effort_choices if not base_dir.is_dir(): parser.error(f"session directory not found: {base_dir}") if args.preview_count < 1: parser.error("--preview-count must be at least 1") if args.max_line_mib <= 0: parser.error("--max-line-mib must be greater than 0") if args.max_message_chars < 100: parser.error("--max-message-chars must be at least 100") if args.workers < 1: parser.error("--workers must be at least 1") if tui_mode: try: attach_tui_to_controlling_terminal() except RuntimeError as exc: print( f"The TUI needs a controlling terminal: {exc}. " "Use --plain or --json in a non-interactive environment.", file=sys.stderr, ) return 2 if not args.settings_file.exists(): # Initialize persistent defaults, but keep explicit CLI overrides # one-shot so CLI selections remain true overrides. try: save_settings( args.settings_file, saved.get("model_provider", configured_provider), saved.get("model", configured_model), saved.get( "model_reasoning_effort", configured_reasoning_effort ), saved.get("scrollback", DEFAULT_SCROLLBACK), ) except OSError: pass index = SessionIndex( base_dir=base_dir, cache_file=args.cache_file.expanduser(), use_cache=not args.no_cache, workers=args.workers, max_line_bytes=int(args.max_line_mib * 1024 * 1024), max_message_chars=args.max_message_chars, ) sessions = index.scan() if args.json: print_json(sessions) return 0 if args.plain: print_plain(sessions) return 0 if not sessions: print(f"No Codex session logs found under {base_dir}", file=sys.stderr) return 0 try: curses.wrapper(lambda stdscr: Navigator(stdscr, index, sessions, args).run()) except KeyboardInterrupt: return 130 return 0 if __name__ == "__main__": raise SystemExit(main())