From 760fed4613aeaa58c11002af97099bac15706cb6 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Tue, 7 Jul 2026 10:40:42 +0800 Subject: [PATCH] feat: add request table preferences --- README.md | 11 +- codex_retry_gateway_tui.py | 474 ++++++++++++++++++++++++++++++++++--- tests/test_payload.py | 78 ++++++ 3 files changed, 529 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1d64714..6a3f103 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Configuration: - `--install` - `--refresh-seconds` / `CODEX_RETRY_GATEWAY_TUI_REFRESH_SECONDS` - `--timeout` / `CODEX_RETRY_GATEWAY_TUI_TIMEOUT` +- request-table preferences auto-save to `~/.config/codex-retry-gateway-tui/request-table.json` Environment variables: @@ -71,7 +72,7 @@ uv run codex-retry-gateway-tui Views: - overview: current gateway status and summary counts -- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, cached ratio, retry note, and `usage_last_updated_at` +- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at` - logs: recent gateway logs - profiles: saved profiles, active profile default selection, profile actions @@ -81,14 +82,20 @@ Controls: - `2` requests - `3` logs - `4` profiles +- `,` / `.` select request-table column for visibility/sort actions +- `v` toggle the selected requests column visible/hidden +- `c` sort requests by the selected column +- `C` toggle requests sort ascending/descending - `e` edit selected profile in `$VISUAL` / `$EDITOR` - `/` filter - `r` refresh +- `m` switch request table compact/wide width profile +- `[` / `]` narrow or widen the requests usage column - `p` probe selected profile - `s` switch to selected profile - `w` save selected profile snapshot - `d` delete selected inactive profile -- `o` open selected profile upstream URL +- `u` open selected profile upstream URL This client uses only the public gateway admin API and does not need SSH or secrets. diff --git a/codex_retry_gateway_tui.py b/codex_retry_gateway_tui.py index 69b6ae9..9e3170c 100644 --- a/codex_retry_gateway_tui.py +++ b/codex_retry_gateway_tui.py @@ -29,6 +29,7 @@ DEFAULT_API_URL = DEFAULT_GATEWAY_URL DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url" DEFAULT_ACCESS_KEY_FILE = "~/.config/codex-retry-gateway-tui/access-key" +DEFAULT_REQUEST_TABLE_PREFERENCES_FILE = "~/.config/codex-retry-gateway-tui/request-table.json" DEFAULT_GATEWAY_STATE_FILE = "~/.codex-retry-gateway/state.json" DEFAULT_GATEWAY_JSON_CONFIG_FILE = "~/.codex-retry-gateway/config/config.json" DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui/raw/branch/main/pyproject.toml" @@ -50,6 +51,33 @@ DEFAULT_PROFILE_ENDPOINTS = [ "/v1/responses", "/v1/chat/completions", ] +REQUEST_TABLE_COLUMNS = ( + {"key": "seq", "label": "Seq", "width_key": "seq"}, + {"key": "req_id", "label": "Req ID", "width_key": "req_id"}, + {"key": "resp_id", "label": "Resp ID", "width_key": "resp_id"}, + {"key": "thread", "label": "Thread", "width_key": "thread"}, + {"key": "started", "label": "Started", "width_key": "started"}, + {"key": "status", "label": "Status", "width_key": "status"}, + {"key": "path", "label": "Path", "width_key": "path"}, + {"key": "model", "label": "Model", "width_key": "model"}, + {"key": "effort", "label": "Effort", "width_key": "effort"}, + {"key": "reasoning", "label": "R.Tok", "width_key": "reasoning"}, + {"key": "usage", "label": "Usage", "width_key": "usage"}, + {"key": "req_bytes", "label": "Req Size", "width_key": "req"}, + {"key": "resp_bytes", "label": "Resp Size", "width_key": "resp"}, + {"key": "chunks", "label": "Chunks", "width_key": "chunks"}, + {"key": "first", "label": "First", "width_key": "first"}, + {"key": "duration", "label": "Duration", "width_key": "duration"}, + {"key": "updated", "label": "Updated", "width_key": "updated"}, + {"key": "note", "label": "Note", "width_key": "note"}, + {"key": "round", "label": "Round", "width_key": "round"}, +) +REQUEST_TABLE_COLUMN_BY_KEY = {column["key"]: column for column in REQUEST_TABLE_COLUMNS} +REQUEST_TABLE_COLUMN_KEYS = tuple(column["key"] for column in REQUEST_TABLE_COLUMNS) +REQUEST_TABLE_COLUMN_KEY_SET = set(REQUEST_TABLE_COLUMN_KEYS) +REQUEST_TABLE_CELL_INDEX = {key: index for index, key in enumerate(REQUEST_TABLE_COLUMN_KEYS)} +DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS +DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq" INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git" INSTALL_COMMAND_ARGS = [ "uv", @@ -148,6 +176,15 @@ def access_key_file_path() -> Path: return Path(os.environ.get("CODEX_RETRY_GATEWAY_TUI_ACCESS_KEY_FILE", DEFAULT_ACCESS_KEY_FILE)).expanduser() +def request_table_preferences_file_path() -> Path: + return Path( + os.environ.get( + "CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE", + DEFAULT_REQUEST_TABLE_PREFERENCES_FILE, + ) + ).expanduser() + + def default_status_url() -> str: return configured_url( ("CODEX_RETRY_GATEWAY_TUI_STATUS_URL",), @@ -164,6 +201,57 @@ def default_access_key() -> str: ) +def request_table_visible_columns(value: Any) -> list[str]: + requested = [] + if isinstance(value, (list, tuple, set)): + requested = [str(item or "").strip() for item in value] + visible = [] + seen: set[str] = set() + for column_key in REQUEST_TABLE_COLUMN_KEYS: + if column_key not in requested or column_key in seen: + continue + visible.append(column_key) + seen.add(column_key) + return visible or list(DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS) + + +def normalize_request_table_sort_column(value: Any) -> str: + column_key = str(value or "").strip() + if column_key in REQUEST_TABLE_COLUMN_KEY_SET: + return column_key + return DEFAULT_REQUEST_TABLE_SORT_COLUMN + + +def normalize_request_table_preferences(data: Any) -> dict[str, Any]: + source = data if isinstance(data, dict) else {} + return { + "visible_columns": request_table_visible_columns(source.get("visible_columns")), + "sort_column": normalize_request_table_sort_column(source.get("sort_column")), + "sort_reverse": parse_bool_value(source.get("sort_reverse"), True), + } + + +def load_request_table_preferences() -> dict[str, Any]: + path = request_table_preferences_file_path() + return normalize_request_table_preferences(read_json_object(str(path))) + + +def write_request_table_preferences(preferences: dict[str, Any]) -> Path: + path = request_table_preferences_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + try: + path.parent.chmod(0o700) + except OSError: + pass + normalized = normalize_request_table_preferences(preferences) + path.write_text(json.dumps(normalized, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + pass + return path + + def normalize_gateway_url(api_url: str) -> str: value = str(api_url or "").strip() if not value: @@ -476,6 +564,82 @@ def request_effort_text(row: dict[str, Any]) -> str: return str(row.get("reasoning_effort") or "").strip() or "-" +def request_model_text(row: dict[str, Any]) -> str: + return str(row.get("model") or row.get("requested_model") or row.get("forwarded_model") or "").strip() + + +def normalize_request_retry_firsts(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, (list, tuple)): + return [] + firsts = [] + for item in value: + if not isinstance(item, dict): + continue + firsts.append( + { + "slot": parse_int_value(item.get("slot")), + "first_delay_ms": item.get("first_response_delay_ms", item.get("first_delay_ms", item.get("first_ms"))), + "outcome": str(item.get("outcome") or "").strip(), + "status_code": item.get("status_code", item.get("upstream_status_code", item.get("status"))), + "reasoning_effort": str(item.get("reasoning_effort") or "").strip(), + "reasoning_tokens": item.get("reasoning_tokens"), + "reasoning": str(item.get("reasoning") or item.get("reason") or item.get("error") or "").strip(), + } + ) + return firsts + + +def request_retry_round_text(row: dict[str, Any]) -> str: + round_number = parse_int_value(row.get("reasoning_retry_current_round")) + width = parse_int_value(row.get("reasoning_retry_current_width")) + if round_number is None and width is None: + return "-" + if round_number is None: + return f"-({max(0, width or 0)})" + if width is None: + return str(round_number) + return f"{round_number}({max(0, width)})" + + +def request_retry_first_summary(first: dict[str, Any]) -> str: + bits = [] + slot = parse_int_value(first.get("slot")) + if slot is not None: + bits.append(f"slot {slot}") + first_delay = format_duration_ms_as_seconds(first.get("first_delay_ms")) + if first_delay != "-": + bits.append(f"first {first_delay}") + outcome = str(first.get("outcome") or "").strip() + status_code = first.get("status_code") + status_bits = [bit for bit in (outcome, str(status_code).strip() if status_code not in (None, "") else "") if bit] + if status_bits: + bits.append(f"status {'/'.join(status_bits)}") + reasoning_bits = [] + if first.get("reasoning_effort"): + reasoning_bits.append(str(first["reasoning_effort"])) + reasoning_tokens = parse_int_value(first.get("reasoning_tokens")) + if reasoning_tokens is not None: + reasoning_bits.append(format_count(reasoning_tokens)) + reasoning = str(first.get("reasoning") or "").strip() + if reasoning_bits or reasoning: + detail = "/".join(reasoning_bits) + if reasoning: + detail = f"{detail} {short_text(reasoning, 40)}".strip() + bits.append(f"reasoning {detail}".strip()) + return " ".join(bits) + + +def request_retry_wave_summary(row: dict[str, Any]) -> str: + summaries = [] + for first in row.get("reasoning_retry_current_firsts") or []: + if not isinstance(first, dict): + continue + summary = request_retry_first_summary(first) + if summary: + summaries.append(summary) + return "; ".join(summaries) + + def request_retry_note(row: dict[str, Any]) -> str: attempts = as_int(row.get("upstream_attempt_count")) if attempts <= 1: @@ -499,7 +663,7 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None = short_time(row["started_at"]), f"{status_symbol(row)} {row.get('status_code') or '-'}", short_text(row["path"], limits.get("path", 22)), - short_text(row["model"] or row["requested_model"] or row["forwarded_model"], limits.get("model", 16)), + short_text(request_model_text(row), limits.get("model", 16)), short_text(request_effort_text(row), limits.get("effort", 10)), request_reasoning_tokens_text(row), short_text(request_usage_summary(row), limits.get("usage", 24)), @@ -510,9 +674,96 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None = format_duration_ms_as_seconds(row["duration_ms"]), request_updated_elapsed(row), short_text(retry_note, note_width), + short_text(request_retry_round_text(row), limits.get("round", 10)), ) +def request_table_column_label(column_key: str) -> str: + column = REQUEST_TABLE_COLUMN_BY_KEY.get(column_key) + return str(column["label"]) if column else column_key + + +def request_sort_value(row: dict[str, Any], column_key: str) -> Any: + if column_key == "seq": + return parse_int_value(row.get("seq")) + if column_key == "req_id": + text = request_id_text(row) + return None if text == "-" else text.lower() + if column_key == "resp_id": + text = response_id_text(row) + return None if text == "-" else text.lower() + if column_key == "thread": + text = str(row.get("thread_id") or "").strip() + return text.lower() if text else None + if column_key == "started": + started = parse_datetime(row.get("started_at")) + return started.timestamp() if started else None + if column_key == "status": + return parse_int_value(row.get("status_code")) + if column_key == "path": + text = str(row.get("path") or "").strip() + return text.lower() if text else None + if column_key == "model": + text = request_model_text(row) + return text.lower() if text else None + if column_key == "effort": + text = str(row.get("reasoning_effort") or "").strip() + return text.lower() if text else None + if column_key == "reasoning": + return parse_int_value(row.get("reasoning_tokens")) + if column_key == "usage": + total_tokens = parse_int_value(row.get("total_tokens")) + if total_tokens is not None: + return total_tokens + input_tokens = parse_int_value(row.get("input_tokens")) or 0 + output_tokens = parse_int_value(row.get("output_tokens")) or 0 + cached_tokens = parse_int_value(row.get("cached_tokens")) or 0 + combined = input_tokens + output_tokens + cached_tokens + return combined if combined else None + if column_key == "req_bytes": + return parse_int_value(row.get("request_body_bytes")) + if column_key == "resp_bytes": + return parse_int_value(row.get("response_bytes_received")) + if column_key == "chunks": + return parse_int_value(row.get("stream_chunk_count")) + if column_key == "first": + return parse_int_value(row.get("first_response_delay_ms")) + if column_key == "duration": + return parse_int_value(row.get("duration_ms")) + if column_key == "updated": + updated = parse_datetime(request_updated_at(row)) + return updated.timestamp() if updated else None + if column_key == "note": + text = request_retry_note(row) + return text.lower() if text else None + if column_key == "round": + round_number = parse_int_value(row.get("reasoning_retry_current_round")) + width = parse_int_value(row.get("reasoning_retry_current_width")) + if round_number is None and width is None: + return None + return (round_number or 0, width or 0) + return None + + +def sort_request_rows( + rows: list[dict[str, Any]], + sort_column: str = DEFAULT_REQUEST_TABLE_SORT_COLUMN, + *, + reverse: bool = True, +) -> list[dict[str, Any]]: + column_key = normalize_request_table_sort_column(sort_column) + known: list[tuple[Any, dict[str, Any]]] = [] + missing: list[dict[str, Any]] = [] + for row in rows: + value = request_sort_value(row, column_key) + if value is None or value == "": + missing.append(row) + continue + known.append((value, row)) + known.sort(key=lambda item: item[0], reverse=reverse) + return [row for _, row in known] + missing + + def status_kind(value: Any) -> str: text = str(value or "").strip().lower() if text in {"ok", "operational", "success"}: @@ -715,8 +966,12 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool: "output_tokens", "total_tokens", "cached_tokens", + "reasoning_retry_current_round", + "reasoning_retry_current_width", ) ).lower() + current_firsts = json.dumps(row.get("reasoning_retry_current_firsts") or [], ensure_ascii=False).lower() + haystack = f"{haystack} {current_firsts}" return needle in haystack @@ -780,6 +1035,9 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li "response_bytes_received": entry.get("response_bytes_received"), "stream_chunk_count": entry.get("stream_chunk_count"), "first_response_delay_ms": entry.get("first_response_delay_ms"), + "reasoning_retry_current_round": parse_int_value(entry.get("reasoning_retry_current_round")), + "reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")), + "reasoning_retry_current_firsts": normalize_request_retry_firsts(entry.get("reasoning_retry_current_firsts")), "duration_ms": entry.get("duration_ms"), "started_at": entry.get("started_at"), "finished_at": entry.get("finished_at"), @@ -818,6 +1076,9 @@ def render_request_detail(row: dict[str, Any]) -> str: f"finished {short_time(row['finished_at'])}", f"updated {request_updated_elapsed(row)}", ] + retry_round = request_retry_round_text(row) + if retry_round != "-": + bits.append(f"retry round {retry_round}") if row.get("model"): bits.append(f"model {row['model']}") if row.get("requested_model") and row.get("requested_model") != row.get("model"): @@ -840,6 +1101,9 @@ def render_request_detail(row: dict[str, Any]) -> str: bits.append(f"upstream path {row['upstream_path']}") if row.get("upstream_auth_mode"): bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}") + retry_wave = request_retry_wave_summary(row) + if retry_wave: + bits.append(f"current wave {retry_wave}") return " | ".join(bits) @@ -1112,6 +1376,8 @@ def fetch_dashboard_snapshot( access_key: str = "", filter_text: str = "", current_view: str = "overview", + request_sort_column: str = DEFAULT_REQUEST_TABLE_SORT_COLUMN, + request_sort_reverse: bool = True, ) -> dict[str, Any]: gateway_root = gateway_admin_url(gateway_url) endpoints = { @@ -1143,7 +1409,11 @@ def fetch_dashboard_snapshot( "payload": payload, "status_payload": payload, "status_error": " | ".join(errors), - "requests": normalize_request_rows(requests_source, filter_text), + "requests": sort_request_rows( + normalize_request_rows(requests_source, filter_text), + request_sort_column, + reverse=request_sort_reverse, + ), "logs": normalize_log_rows(logs_source), "profiles": normalize_profile_rows( profiles_source, @@ -1199,6 +1469,11 @@ def run_textual( ("3", "show_logs", "Logs"), ("4", "show_profiles", "Profiles"), ("m", "toggle_request_table_density", "Wide/Compact"), + (",", "select_previous_request_column", "Prev Column"), + (".", "select_next_request_column", "Next Column"), + ("v", "toggle_request_column_visibility", "Show/Hide Column"), + ("c", "sort_requests_by_selected_column", "Sort Column"), + ("shift+c", "toggle_request_sort_direction", "Sort Asc/Desc"), ("shift+left", "scroll_table_left", "Scroll Left"), ("shift+right", "scroll_table_right", "Scroll Right"), ("[", "narrow_usage_column", "Narrow Usage"), @@ -1225,7 +1500,7 @@ def run_textual( self.log_by_key: dict[str, dict[str, Any]] = {} self.current_view = "overview" self.filter_mode = "requests" - self.last_request_id = "" + self.last_request_key = "" self.loading = False self.executor = ThreadPoolExecutor(max_workers=1) self.refresh_generation = 0 @@ -1237,6 +1512,10 @@ def run_textual( "profiles": "profiles", } self.request_table_column_keys: dict[str, Any] = {} + self.request_table_preferences = load_request_table_preferences() + self.request_table_sort_column = self.request_table_preferences["sort_column"] + self.request_table_sort_reverse = bool(self.request_table_preferences["sort_reverse"]) + self.request_table_selected_column = self.request_table_sort_column self.request_table_density = "compact" self.request_table_width_profiles = { "compact": { @@ -1258,6 +1537,7 @@ def run_textual( "duration": 8, "updated": 8, "note": 22, + "round": 10, }, "wide": { "seq": 7, @@ -1278,6 +1558,7 @@ def run_textual( "duration": 8, "updated": 8, "note": 48, + "round": 10, }, } self.request_usage_width = self.request_table_width_profiles[self.request_table_density]["usage"] @@ -1300,27 +1581,7 @@ def run_textual( requests = self.query_one("#requests_table", DataTable) requests.cursor_type = "row" requests.zebra_stripes = True - width_profile = self.request_table_width_profiles[self.request_table_density] - self.request_table_column_keys = { - "seq": requests.add_column("Seq", width=width_profile["seq"], key="seq"), - "req_id": requests.add_column("Req ID", width=width_profile["req_id"], key="req_id"), - "resp_id": requests.add_column("Resp ID", width=width_profile["resp_id"], key="resp_id"), - "thread": requests.add_column("Thread", width=width_profile["thread"], key="thread"), - "started": requests.add_column("Started", width=width_profile["started"], key="started"), - "status": requests.add_column("Status", width=width_profile["status"], key="status"), - "path": requests.add_column("Path", width=width_profile["path"], key="path"), - "model": requests.add_column("Model", width=width_profile["model"], key="model"), - "effort": requests.add_column("Effort", width=width_profile["effort"], key="effort"), - "reasoning": requests.add_column("R.Tok", width=width_profile["reasoning"], key="reasoning"), - "usage": requests.add_column("Usage", width=self.request_usage_width, key="usage"), - "req_bytes": requests.add_column("Req Size", width=width_profile["req"], key="req_bytes"), - "resp_bytes": requests.add_column("Resp Size", width=width_profile["resp"], key="resp_bytes"), - "chunks": requests.add_column("Chunks", width=width_profile["chunks"], key="chunks"), - "first": requests.add_column("First", width=width_profile["first"], key="first"), - "duration": requests.add_column("Duration", width=width_profile["duration"], key="duration"), - "updated": requests.add_column("Updated", width=width_profile["updated"], key="updated"), - "note": requests.add_column("Note", width=width_profile["note"], key="note"), - } + self._rebuild_request_table_columns() logs = self.query_one("#logs_table", DataTable) logs.cursor_type = "row" @@ -1346,7 +1607,7 @@ def run_textual( self.query_one("#logs_table").display = active_table == "logs" self.query_one("#profiles_table").display = active_table == "profiles" self.query_one("#controls", Static).update( - "views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | m compact/wide | [ ] usage width | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream" + "views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | m compact/wide | , . column | v hide/show | c sort | C asc/desc | [ ] usage width | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream" ) self._focus_table() self._update_table_headers() @@ -1361,12 +1622,82 @@ def run_textual( self.query_one("#paths", Static).update(state_summary(self.payload)) def _update_table_headers(self) -> None: - counts = f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)}" + selected_label = request_table_column_label(self.request_table_selected_column) + selected_visible = self.request_table_selected_column in self.request_table_preferences["visible_columns"] + sort_label = request_table_column_label(self.request_table_sort_column) + sort_direction = "desc" if self.request_table_sort_reverse else "asc" + counts = ( + f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)} | " + f"req cols {len(self.request_table_preferences['visible_columns'])}/{len(REQUEST_TABLE_COLUMNS)} | " + f"selected {selected_label} ({'shown' if selected_visible else 'hidden'}) | sort {sort_label} {sort_direction}" + ) self.query_one("#tables", Static).update(counts) def _current_filter(self) -> str: return self.query_one("#filter", Input).value.strip() + def _visible_request_column_keys(self) -> list[str]: + return request_table_visible_columns(self.request_table_preferences.get("visible_columns")) + + def _request_table_actions_enabled(self) -> bool: + if self.visible_tables.get(self.current_view, "requests") == "requests": + return True + self._set_status("switch to requests or overview first") + return False + + def _persist_request_table_preferences(self) -> bool: + self.request_table_preferences = normalize_request_table_preferences(self.request_table_preferences) + self.request_table_sort_column = self.request_table_preferences["sort_column"] + self.request_table_sort_reverse = bool(self.request_table_preferences["sort_reverse"]) + try: + write_request_table_preferences(self.request_table_preferences) + except Exception as exc: + self._set_status(f"save request table prefs error: {exc}") + return False + return True + + def _request_row_key(self, row: dict[str, Any]) -> str: + return f"{row['seq']}:{row['request_id']}" + + def _selected_request_key(self) -> str: + row = self._selected_request_row() + if row: + return self._request_row_key(row) + return self.last_request_key + + def _select_request_row(self, row_key: str) -> bool: + if not row_key: + return False + table = self.query_one("#requests_table", DataTable) + for index, row in enumerate(self.requests): + if self._request_row_key(row) == row_key: + return self._select_table_row(table, index) + return False + + def _rebuild_request_table_columns(self) -> None: + table = self.query_one("#requests_table", DataTable) + selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0 + selected_row_key = self._selected_request_key() + width_profile = self.request_table_width_profiles[self.request_table_density] + table.clear(columns=True) + self.request_table_column_keys = {} + for column in REQUEST_TABLE_COLUMNS: + column_key = column["key"] + if column_key not in self._visible_request_column_keys(): + continue + width = self.request_usage_width if column_key == "usage" else width_profile[column["width_key"]] + self.request_table_column_keys[column_key] = table.add_column( + column["label"], + width=width, + key=column_key, + ) + if self.requests: + self.render_requests() + if not self._select_request_row(selected_row_key): + self._select_table_row(table, min(selected_row_index, len(self.requests) - 1)) + else: + self.request_by_key = {} + def _current_table_widget(self) -> DataTable | None: table_id = self.visible_tables.get(self.current_view, "requests") if table_id == "logs": @@ -1437,6 +1768,8 @@ def run_textual( access_key=access_key, filter_text=current_filter, current_view=self.current_view, + request_sort_column=self.request_table_sort_column, + request_sort_reverse=self.request_table_sort_reverse, ) result["generation"] = generation self.call_from_thread(self._apply_refresh_result, result) @@ -1446,6 +1779,7 @@ def run_textual( def _apply_refresh_result(self, result: dict[str, Any]) -> None: if result.get("generation") != self.refresh_generation: return + selected_request_key = self._selected_request_key() self.payload = result["payload"] self.status_payload = result["status_payload"] self.status_error = result["status_error"] @@ -1453,6 +1787,7 @@ def run_textual( self.logs = result["logs"] self.profiles = result["profiles"] self.active_profile_name = result.get("active_profile_name") or "" + self.last_request_key = selected_request_key self.loading = False self.render_all() if self.current_view == "profiles": @@ -1495,15 +1830,19 @@ def run_textual( def render_requests(self) -> None: table = self.query_one("#requests_table", DataTable) selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0 + selected_row_key = self._selected_request_key() text_limits = self._request_text_limits(table) table.clear() self.request_by_key = {} for row in self.requests: - key = f"{row['seq']}:{row['request_id']}" + key = self._request_row_key(row) self.request_by_key[key] = row - table.add_row(*request_row_cells(row, text_limits), key=key) + cells = request_row_cells(row, text_limits) + visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in self._visible_request_column_keys()] + table.add_row(*visible_cells, key=key) if self.requests: - self._select_table_row(table, min(selected_row_index, len(self.requests) - 1)) + if not self._select_request_row(selected_row_key): + self._select_table_row(table, min(selected_row_index, len(self.requests) - 1)) def render_logs(self) -> None: table = self.query_one("#logs_table", DataTable) @@ -1600,8 +1939,9 @@ def run_textual( "effort": width_profile["effort"], "usage": self.request_usage_width, "note": width_profile["note"], + "round": width_profile["round"], } - for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "usage", "note"): + for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "usage", "note", "round"): key = self.request_table_column_keys.get(name) column = table.columns.get(key) if key is not None else None if column is None or not getattr(column, "width", 0): @@ -1611,6 +1951,7 @@ def run_textual( def _rerender_requests_after_layout_change(self) -> None: self.render_requests() + self._update_table_headers() if self.current_view not in {"overview", "requests"}: return row = self._selected_request_row() @@ -1624,9 +1965,10 @@ def run_textual( width_profile = self.request_table_width_profiles[density] for name, key in self.request_table_column_keys.items(): column = table.columns.get(key) - if column is None or name not in width_profile: + width_key = REQUEST_TABLE_COLUMN_BY_KEY.get(name, {}).get("width_key", name) + if column is None or width_key not in width_profile: continue - column.width = width_profile[name] + column.width = width_profile[width_key] self.request_table_density = density self.request_usage_width = width_profile["usage"] self._rerender_requests_after_layout_change() @@ -1636,6 +1978,73 @@ def run_textual( next_density = "wide" if self.request_table_density == "compact" else "compact" self._apply_request_table_width_profile(next_density) + def _step_request_column(self, offset: int) -> None: + if not self._request_table_actions_enabled(): + return + try: + index = REQUEST_TABLE_COLUMN_KEYS.index(self.request_table_selected_column) + except ValueError: + index = 0 + self.request_table_selected_column = REQUEST_TABLE_COLUMN_KEYS[(index + offset) % len(REQUEST_TABLE_COLUMN_KEYS)] + visibility = "shown" if self.request_table_selected_column in self.request_table_preferences["visible_columns"] else "hidden" + self._update_table_headers() + self._set_status(f"selected request column {request_table_column_label(self.request_table_selected_column)} ({visibility})") + + def action_select_previous_request_column(self) -> None: + self._step_request_column(-1) + + def action_select_next_request_column(self) -> None: + self._step_request_column(1) + + def action_toggle_request_column_visibility(self) -> None: + if not self._request_table_actions_enabled(): + return + visible_columns = self._visible_request_column_keys() + column_key = self.request_table_selected_column + if column_key in visible_columns: + if len(visible_columns) <= 1: + self._set_status("keep at least one request column visible") + return + visible_columns = [key for key in visible_columns if key != column_key] + action = "hidden" + else: + visible_columns.append(column_key) + action = "shown" + self.request_table_preferences["visible_columns"] = request_table_visible_columns(visible_columns) + if not self._persist_request_table_preferences(): + return + self._rebuild_request_table_columns() + self._rerender_requests_after_layout_change() + self._set_status(f"request column {request_table_column_label(column_key)} {action}") + + def _apply_request_sort(self, column_key: str, reverse: bool) -> None: + if not self._request_table_actions_enabled(): + return + selected_request_key = self._selected_request_key() + self.request_table_sort_column = normalize_request_table_sort_column(column_key) + self.request_table_sort_reverse = bool(reverse) + self.request_table_preferences["sort_column"] = self.request_table_sort_column + self.request_table_preferences["sort_reverse"] = self.request_table_sort_reverse + if not self._persist_request_table_preferences(): + return + self.requests = sort_request_rows( + self.requests, + self.request_table_sort_column, + reverse=self.request_table_sort_reverse, + ) + self.last_request_key = selected_request_key + self._rerender_requests_after_layout_change() + self._set_status( + f"request sort {request_table_column_label(self.request_table_sort_column)} " + f"{'desc' if self.request_table_sort_reverse else 'asc'}" + ) + + def action_sort_requests_by_selected_column(self) -> None: + self._apply_request_sort(self.request_table_selected_column, self.request_table_sort_reverse) + + def action_toggle_request_sort_direction(self) -> None: + self._apply_request_sort(self.request_table_sort_column, not self.request_table_sort_reverse) + def action_widen_usage_column(self) -> None: self._set_request_usage_width(self.request_usage_width + 4) @@ -1742,6 +2151,7 @@ def run_textual( def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: if event.data_table.id == "requests_table": key = str(event.row_key.value) + self.last_request_key = key row = self.request_by_key.get(key) if row: self.render_detail(row, kind="request") diff --git a/tests/test_payload.py b/tests/test_payload.py index e64f04e..1bdfb75 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -210,6 +210,84 @@ class CodexRetryGatewayTUITests(unittest.TestCase): "Selected model is at capacity. Please try a different model.", ) + def test_request_rows_capture_retry_round_and_wave_firsts(self) -> None: + mod = load_module() + payload = { + "entries": [ + { + "seq": 3, + "request_id": "req_round", + "response_id": "resp_round", + "thread_id": "thread_round", + "path": "/responses", + "status_code": 200, + "reasoning_retry_current_round": 3, + "reasoning_retry_current_width": 2, + "reasoning_retry_current_firsts": [ + { + "slot": 1, + "first_response_delay_ms": 1200, + "outcome": "ok", + "status_code": 200, + "reasoning_effort": "high", + "reasoning_tokens": 516, + }, + { + "slot": 2, + "first_delay_ms": 1800, + "outcome": "retry", + "status_code": 429, + "reason": "capacity", + }, + ], + } + ] + } + rows = mod.normalize_request_rows(payload) + row = rows[0] + self.assertEqual(row["reasoning_retry_current_round"], 3) + self.assertEqual(row["reasoning_retry_current_width"], 2) + self.assertEqual(len(row["reasoning_retry_current_firsts"]), 2) + self.assertEqual(mod.request_retry_round_text(row), "3(2)") + self.assertIn("slot 1", mod.request_retry_wave_summary(row)) + self.assertIn("retry round 3(2)", mod.render_request_detail(row)) + self.assertIn("current wave slot 1 first 1.2s", mod.render_request_detail(row)) + self.assertEqual(mod.request_row_cells(row)[18], "3(2)") + + def test_request_table_preferences_round_trip(self) -> None: + mod = load_module() + with tempfile.TemporaryDirectory() as tmpdir: + prefs_path = Path(tmpdir) / "request-table.json" + with mock.patch.dict( + os.environ, + {"CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE": str(prefs_path)}, + clear=False, + ): + saved_path = mod.write_request_table_preferences( + { + "visible_columns": ["round", "seq", "status", "unknown"], + "sort_column": "round", + "sort_reverse": False, + } + ) + self.assertEqual(saved_path, prefs_path) + loaded = mod.load_request_table_preferences() + self.assertEqual(loaded["visible_columns"], ["seq", "status", "round"]) + self.assertEqual(loaded["sort_column"], "round") + self.assertFalse(loaded["sort_reverse"]) + + def test_sort_request_rows_supports_custom_column_and_missing_values(self) -> None: + mod = load_module() + rows = [ + {"seq": 1, "request_id": "req_1", "duration_ms": 5000, "reasoning_retry_current_round": 1, "reasoning_retry_current_width": 1}, + {"seq": 2, "request_id": "req_2", "duration_ms": None, "reasoning_retry_current_round": None, "reasoning_retry_current_width": None}, + {"seq": 3, "request_id": "req_3", "duration_ms": 1200, "reasoning_retry_current_round": 3, "reasoning_retry_current_width": 2}, + ] + by_duration = mod.sort_request_rows(rows, "duration", reverse=False) + self.assertEqual([row["request_id"] for row in by_duration], ["req_3", "req_1", "req_2"]) + by_round = mod.sort_request_rows(rows, "round", reverse=True) + self.assertEqual([row["request_id"] for row in by_round], ["req_3", "req_1", "req_2"]) + def test_request_rows_read_usage_from_nested_usage_object(self) -> None: mod = load_module() payload = {