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`
- `--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.
+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_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")
+78
View File
@@ -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 = {