feat: add request table preferences

This commit is contained in:
2026-07-07 10:40:42 +08:00
parent 087a9fb0cc
commit 760fed4613
3 changed files with 529 additions and 34 deletions
+9 -2
View File
@@ -50,6 +50,7 @@ Configuration:
- `--install` - `--install`
- `--refresh-seconds` / `CODEX_RETRY_GATEWAY_TUI_REFRESH_SECONDS` - `--refresh-seconds` / `CODEX_RETRY_GATEWAY_TUI_REFRESH_SECONDS`
- `--timeout` / `CODEX_RETRY_GATEWAY_TUI_TIMEOUT` - `--timeout` / `CODEX_RETRY_GATEWAY_TUI_TIMEOUT`
- request-table preferences auto-save to `~/.config/codex-retry-gateway-tui/request-table.json`
Environment variables: Environment variables:
@@ -71,7 +72,7 @@ uv run codex-retry-gateway-tui
Views: Views:
- overview: current gateway status and summary counts - 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 - logs: recent gateway logs
- profiles: saved profiles, active profile default selection, profile actions - profiles: saved profiles, active profile default selection, profile actions
@@ -81,14 +82,20 @@ Controls:
- `2` requests - `2` requests
- `3` logs - `3` logs
- `4` profiles - `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` - `e` edit selected profile in `$VISUAL` / `$EDITOR`
- `/` filter - `/` filter
- `r` refresh - `r` refresh
- `m` switch request table compact/wide width profile
- `[` / `]` narrow or widen the requests usage column
- `p` probe selected profile - `p` probe selected profile
- `s` switch to selected profile - `s` switch to selected profile
- `w` save selected profile snapshot - `w` save selected profile snapshot
- `d` delete selected inactive profile - `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. This client uses only the public gateway admin API and does not need SSH or secrets.
+442 -32
View File
@@ -29,6 +29,7 @@ DEFAULT_API_URL = DEFAULT_GATEWAY_URL
DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url" DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url"
DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url"
DEFAULT_ACCESS_KEY_FILE = "~/.config/codex-retry-gateway-tui/access-key" 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_STATE_FILE = "~/.codex-retry-gateway/state.json"
DEFAULT_GATEWAY_JSON_CONFIG_FILE = "~/.codex-retry-gateway/config/config.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" 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/responses",
"/v1/chat/completions", "/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 = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git"
INSTALL_COMMAND_ARGS = [ INSTALL_COMMAND_ARGS = [
"uv", "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() 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: def default_status_url() -> str:
return configured_url( return configured_url(
("CODEX_RETRY_GATEWAY_TUI_STATUS_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: def normalize_gateway_url(api_url: str) -> str:
value = str(api_url or "").strip() value = str(api_url or "").strip()
if not value: 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 "-" 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: def request_retry_note(row: dict[str, Any]) -> str:
attempts = as_int(row.get("upstream_attempt_count")) attempts = as_int(row.get("upstream_attempt_count"))
if attempts <= 1: 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"]), short_time(row["started_at"]),
f"{status_symbol(row)} {row.get('status_code') or '-'}", f"{status_symbol(row)} {row.get('status_code') or '-'}",
short_text(row["path"], limits.get("path", 22)), 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)), short_text(request_effort_text(row), limits.get("effort", 10)),
request_reasoning_tokens_text(row), request_reasoning_tokens_text(row),
short_text(request_usage_summary(row), limits.get("usage", 24)), 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"]), format_duration_ms_as_seconds(row["duration_ms"]),
request_updated_elapsed(row), request_updated_elapsed(row),
short_text(retry_note, note_width), 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: def status_kind(value: Any) -> str:
text = str(value or "").strip().lower() text = str(value or "").strip().lower()
if text in {"ok", "operational", "success"}: if text in {"ok", "operational", "success"}:
@@ -715,8 +966,12 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
"output_tokens", "output_tokens",
"total_tokens", "total_tokens",
"cached_tokens", "cached_tokens",
"reasoning_retry_current_round",
"reasoning_retry_current_width",
) )
).lower() ).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 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"), "response_bytes_received": entry.get("response_bytes_received"),
"stream_chunk_count": entry.get("stream_chunk_count"), "stream_chunk_count": entry.get("stream_chunk_count"),
"first_response_delay_ms": entry.get("first_response_delay_ms"), "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"), "duration_ms": entry.get("duration_ms"),
"started_at": entry.get("started_at"), "started_at": entry.get("started_at"),
"finished_at": entry.get("finished_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"finished {short_time(row['finished_at'])}",
f"updated {request_updated_elapsed(row)}", 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"): if row.get("model"):
bits.append(f"model {row['model']}") bits.append(f"model {row['model']}")
if row.get("requested_model") and row.get("requested_model") != row.get("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']}") bits.append(f"upstream path {row['upstream_path']}")
if row.get("upstream_auth_mode"): if row.get("upstream_auth_mode"):
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}") 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) return " | ".join(bits)
@@ -1112,6 +1376,8 @@ def fetch_dashboard_snapshot(
access_key: str = "", access_key: str = "",
filter_text: str = "", filter_text: str = "",
current_view: str = "overview", current_view: str = "overview",
request_sort_column: str = DEFAULT_REQUEST_TABLE_SORT_COLUMN,
request_sort_reverse: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
gateway_root = gateway_admin_url(gateway_url) gateway_root = gateway_admin_url(gateway_url)
endpoints = { endpoints = {
@@ -1143,7 +1409,11 @@ def fetch_dashboard_snapshot(
"payload": payload, "payload": payload,
"status_payload": payload, "status_payload": payload,
"status_error": " | ".join(errors), "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), "logs": normalize_log_rows(logs_source),
"profiles": normalize_profile_rows( "profiles": normalize_profile_rows(
profiles_source, profiles_source,
@@ -1199,6 +1469,11 @@ def run_textual(
("3", "show_logs", "Logs"), ("3", "show_logs", "Logs"),
("4", "show_profiles", "Profiles"), ("4", "show_profiles", "Profiles"),
("m", "toggle_request_table_density", "Wide/Compact"), ("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+left", "scroll_table_left", "Scroll Left"),
("shift+right", "scroll_table_right", "Scroll Right"), ("shift+right", "scroll_table_right", "Scroll Right"),
("[", "narrow_usage_column", "Narrow Usage"), ("[", "narrow_usage_column", "Narrow Usage"),
@@ -1225,7 +1500,7 @@ def run_textual(
self.log_by_key: dict[str, dict[str, Any]] = {} self.log_by_key: dict[str, dict[str, Any]] = {}
self.current_view = "overview" self.current_view = "overview"
self.filter_mode = "requests" self.filter_mode = "requests"
self.last_request_id = "" self.last_request_key = ""
self.loading = False self.loading = False
self.executor = ThreadPoolExecutor(max_workers=1) self.executor = ThreadPoolExecutor(max_workers=1)
self.refresh_generation = 0 self.refresh_generation = 0
@@ -1237,6 +1512,10 @@ def run_textual(
"profiles": "profiles", "profiles": "profiles",
} }
self.request_table_column_keys: dict[str, Any] = {} 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_density = "compact"
self.request_table_width_profiles = { self.request_table_width_profiles = {
"compact": { "compact": {
@@ -1258,6 +1537,7 @@ def run_textual(
"duration": 8, "duration": 8,
"updated": 8, "updated": 8,
"note": 22, "note": 22,
"round": 10,
}, },
"wide": { "wide": {
"seq": 7, "seq": 7,
@@ -1278,6 +1558,7 @@ def run_textual(
"duration": 8, "duration": 8,
"updated": 8, "updated": 8,
"note": 48, "note": 48,
"round": 10,
}, },
} }
self.request_usage_width = self.request_table_width_profiles[self.request_table_density]["usage"] 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 = self.query_one("#requests_table", DataTable)
requests.cursor_type = "row" requests.cursor_type = "row"
requests.zebra_stripes = True requests.zebra_stripes = True
width_profile = self.request_table_width_profiles[self.request_table_density] self._rebuild_request_table_columns()
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"),
}
logs = self.query_one("#logs_table", DataTable) logs = self.query_one("#logs_table", DataTable)
logs.cursor_type = "row" logs.cursor_type = "row"
@@ -1346,7 +1607,7 @@ def run_textual(
self.query_one("#logs_table").display = active_table == "logs" self.query_one("#logs_table").display = active_table == "logs"
self.query_one("#profiles_table").display = active_table == "profiles" self.query_one("#profiles_table").display = active_table == "profiles"
self.query_one("#controls", Static).update( 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._focus_table()
self._update_table_headers() self._update_table_headers()
@@ -1361,12 +1622,82 @@ def run_textual(
self.query_one("#paths", Static).update(state_summary(self.payload)) self.query_one("#paths", Static).update(state_summary(self.payload))
def _update_table_headers(self) -> None: 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) self.query_one("#tables", Static).update(counts)
def _current_filter(self) -> str: def _current_filter(self) -> str:
return self.query_one("#filter", Input).value.strip() 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: def _current_table_widget(self) -> DataTable | None:
table_id = self.visible_tables.get(self.current_view, "requests") table_id = self.visible_tables.get(self.current_view, "requests")
if table_id == "logs": if table_id == "logs":
@@ -1437,6 +1768,8 @@ def run_textual(
access_key=access_key, access_key=access_key,
filter_text=current_filter, filter_text=current_filter,
current_view=self.current_view, current_view=self.current_view,
request_sort_column=self.request_table_sort_column,
request_sort_reverse=self.request_table_sort_reverse,
) )
result["generation"] = generation result["generation"] = generation
self.call_from_thread(self._apply_refresh_result, result) 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: def _apply_refresh_result(self, result: dict[str, Any]) -> None:
if result.get("generation") != self.refresh_generation: if result.get("generation") != self.refresh_generation:
return return
selected_request_key = self._selected_request_key()
self.payload = result["payload"] self.payload = result["payload"]
self.status_payload = result["status_payload"] self.status_payload = result["status_payload"]
self.status_error = result["status_error"] self.status_error = result["status_error"]
@@ -1453,6 +1787,7 @@ def run_textual(
self.logs = result["logs"] self.logs = result["logs"]
self.profiles = result["profiles"] self.profiles = result["profiles"]
self.active_profile_name = result.get("active_profile_name") or "" self.active_profile_name = result.get("active_profile_name") or ""
self.last_request_key = selected_request_key
self.loading = False self.loading = False
self.render_all() self.render_all()
if self.current_view == "profiles": if self.current_view == "profiles":
@@ -1495,15 +1830,19 @@ def run_textual(
def render_requests(self) -> None: def render_requests(self) -> None:
table = self.query_one("#requests_table", DataTable) 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_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) text_limits = self._request_text_limits(table)
table.clear() table.clear()
self.request_by_key = {} self.request_by_key = {}
for row in self.requests: for row in self.requests:
key = f"{row['seq']}:{row['request_id']}" key = self._request_row_key(row)
self.request_by_key[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: 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: def render_logs(self) -> None:
table = self.query_one("#logs_table", DataTable) table = self.query_one("#logs_table", DataTable)
@@ -1600,8 +1939,9 @@ def run_textual(
"effort": width_profile["effort"], "effort": width_profile["effort"],
"usage": self.request_usage_width, "usage": self.request_usage_width,
"note": width_profile["note"], "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) key = self.request_table_column_keys.get(name)
column = table.columns.get(key) if key is not None else None column = table.columns.get(key) if key is not None else None
if column is None or not getattr(column, "width", 0): 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: def _rerender_requests_after_layout_change(self) -> None:
self.render_requests() self.render_requests()
self._update_table_headers()
if self.current_view not in {"overview", "requests"}: if self.current_view not in {"overview", "requests"}:
return return
row = self._selected_request_row() row = self._selected_request_row()
@@ -1624,9 +1965,10 @@ def run_textual(
width_profile = self.request_table_width_profiles[density] width_profile = self.request_table_width_profiles[density]
for name, key in self.request_table_column_keys.items(): for name, key in self.request_table_column_keys.items():
column = table.columns.get(key) 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 continue
column.width = width_profile[name] column.width = width_profile[width_key]
self.request_table_density = density self.request_table_density = density
self.request_usage_width = width_profile["usage"] self.request_usage_width = width_profile["usage"]
self._rerender_requests_after_layout_change() self._rerender_requests_after_layout_change()
@@ -1636,6 +1978,73 @@ def run_textual(
next_density = "wide" if self.request_table_density == "compact" else "compact" next_density = "wide" if self.request_table_density == "compact" else "compact"
self._apply_request_table_width_profile(next_density) 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: def action_widen_usage_column(self) -> None:
self._set_request_usage_width(self.request_usage_width + 4) 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: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id == "requests_table": if event.data_table.id == "requests_table":
key = str(event.row_key.value) key = str(event.row_key.value)
self.last_request_key = key
row = self.request_by_key.get(key) row = self.request_by_key.get(key)
if row: if row:
self.render_detail(row, kind="request") self.render_detail(row, kind="request")
+78
View File
@@ -210,6 +210,84 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"Selected model is at capacity. Please try a different model.", "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: def test_request_rows_read_usage_from_nested_usage_object(self) -> None:
mod = load_module() mod = load_module()
payload = { payload = {