feat: simplify request column editor

This commit is contained in:
2026-07-07 11:43:31 +08:00
parent 16da64bc4c
commit f12f6de523
2 changed files with 296 additions and 157 deletions
+252 -134
View File
@@ -29,7 +29,8 @@ 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_REQUEST_TABLE_PREFERENCES_FILE = "~/.config/codex-retry-gateway-tui/request-table-columns.txt"
LEGACY_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"
@@ -62,7 +63,9 @@ REQUEST_TABLE_COLUMNS = (
{"key": "model", "label": "Model", "width_key": "model"}, {"key": "model", "label": "Model", "width_key": "model"},
{"key": "effort", "label": "Effort", "width_key": "effort"}, {"key": "effort", "label": "Effort", "width_key": "effort"},
{"key": "reasoning", "label": "R.Tok", "width_key": "reasoning"}, {"key": "reasoning", "label": "R.Tok", "width_key": "reasoning"},
{"key": "usage", "label": "Usage", "width_key": "usage"}, {"key": "in", "label": "In", "width_key": "token_col"},
{"key": "out", "label": "Out", "width_key": "token_col"},
{"key": "cache", "label": "Cache", "width_key": "token_col"},
{"key": "req_bytes", "label": "Req Size", "width_key": "req"}, {"key": "req_bytes", "label": "Req Size", "width_key": "req"},
{"key": "resp_bytes", "label": "Resp Size", "width_key": "resp"}, {"key": "resp_bytes", "label": "Resp Size", "width_key": "resp"},
{"key": "chunks", "label": "Chunks", "width_key": "chunks"}, {"key": "chunks", "label": "Chunks", "width_key": "chunks"},
@@ -78,6 +81,29 @@ 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)} 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_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS
DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq" DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq"
REQUEST_TABLE_COLUMN_ALIASES = {
"seq": ("seq",),
"req_id": ("req id", "request id", "request_id"),
"resp_id": ("resp id", "response id", "response_id"),
"thread": ("thread", "thread id", "thread_id"),
"started": ("started", "start", "started at"),
"status": ("status", "code", "status code"),
"path": ("path", "endpoint"),
"model": ("model",),
"effort": ("effort", "reasoning effort"),
"reasoning": ("reasoning", "r tok", "rtok", "reasoning tokens", "reasoning_tokens"),
"in": ("in", "input", "input tokens", "input_tokens"),
"out": ("out", "output", "output tokens", "output_tokens"),
"cache": ("cache", "cached", "cached tokens", "cached_tokens"),
"req_bytes": ("req", "req size", "request size", "request bytes", "request_bytes"),
"resp_bytes": ("resp", "resp size", "response size", "response bytes", "response_bytes"),
"chunks": ("chunks", "stream chunks", "chunk count"),
"first": ("first", "first response", "first delay"),
"duration": ("duration", "elapsed"),
"updated": ("updated", "last update"),
"note": ("note", "retry note"),
"round": ("round", "retry round"),
}
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",
@@ -185,6 +211,10 @@ def request_table_preferences_file_path() -> Path:
).expanduser() ).expanduser()
def legacy_request_table_preferences_file_path() -> Path:
return Path(LEGACY_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",),
@@ -201,18 +231,60 @@ def default_access_key() -> str:
) )
def request_table_visible_columns(value: Any) -> list[str]: def request_table_column_token(value: Any) -> str:
requested = [] return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())
if isinstance(value, (list, tuple, set)):
requested = [str(item or "").strip() for item in value]
visible = [] def build_request_table_column_token_map() -> dict[str, str]:
token_map: dict[str, str] = {}
for column in REQUEST_TABLE_COLUMNS:
key = column["key"]
aliases = set(REQUEST_TABLE_COLUMN_ALIASES.get(key, ()))
aliases.add(key)
aliases.add(column["label"])
for alias in aliases:
token = request_table_column_token(alias)
if token:
token_map[token] = key
return token_map
REQUEST_TABLE_COLUMN_TOKEN_MAP = build_request_table_column_token_map()
def request_table_column_key(value: Any) -> str:
token = request_table_column_token(value)
return REQUEST_TABLE_COLUMN_TOKEN_MAP.get(token, "")
def request_table_column_order(value: Any) -> list[str]:
requested = value if isinstance(value, (list, tuple, set)) else []
columns = []
seen: set[str] = set() seen: set[str] = set()
for column_key in REQUEST_TABLE_COLUMN_KEYS: for item in requested:
if column_key not in requested or column_key in seen: column_key = request_table_column_key(item)
if not column_key or column_key in seen:
continue continue
visible.append(column_key) columns.append(column_key)
seen.add(column_key) seen.add(column_key)
return visible or list(DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS) for column_key in REQUEST_TABLE_COLUMN_KEYS:
if column_key not in seen:
columns.append(column_key)
return columns or list(DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS)
def request_table_hidden_columns(value: Any, columns: list[str]) -> list[str]:
requested = value if isinstance(value, (list, tuple, set)) else []
allowed = set(columns)
hidden = []
seen: set[str] = set()
for item in requested:
column_key = request_table_column_key(item)
if not column_key or column_key not in allowed or column_key in seen:
continue
hidden.append(column_key)
seen.add(column_key)
return hidden
def normalize_request_table_sort_column(value: Any) -> str: def normalize_request_table_sort_column(value: Any) -> str:
@@ -224,16 +296,112 @@ def normalize_request_table_sort_column(value: Any) -> str:
def normalize_request_table_preferences(data: Any) -> dict[str, Any]: def normalize_request_table_preferences(data: Any) -> dict[str, Any]:
source = data if isinstance(data, dict) else {} source = data if isinstance(data, dict) else {}
if isinstance(source.get("visible_columns"), (list, tuple, set)):
legacy_visible = []
seen: set[str] = set()
for item in source.get("visible_columns") or []:
column_key = request_table_column_key(item)
if not column_key or column_key in seen:
continue
legacy_visible.append(column_key)
seen.add(column_key)
columns = request_table_column_order(legacy_visible)
hidden_columns = [column_key for column_key in columns if column_key not in seen]
else:
columns = request_table_column_order(source.get("columns"))
hidden_columns = request_table_hidden_columns(source.get("hidden_columns"), columns)
if len(hidden_columns) >= len(columns) and columns:
hidden_columns = [column_key for column_key in hidden_columns if column_key != columns[0]]
return { return {
"visible_columns": request_table_visible_columns(source.get("visible_columns")), "columns": columns,
"sort_column": normalize_request_table_sort_column(source.get("sort_column")), "hidden_columns": hidden_columns,
"sort_reverse": parse_bool_value(source.get("sort_reverse"), True),
} }
def request_table_visible_columns(preferences: dict[str, Any]) -> list[str]:
normalized = normalize_request_table_preferences(preferences)
hidden = set(normalized["hidden_columns"])
visible = [column_key for column_key in normalized["columns"] if column_key not in hidden]
if visible:
return visible
return [normalized["columns"][0]]
def request_table_preferences_from_text(text: str) -> dict[str, Any]:
columns = []
hidden_columns = []
seen: set[str] = set()
for line_number, raw_line in enumerate(text.splitlines(), start=1):
stripped = raw_line.strip()
if not stripped:
continue
hidden = False
token = stripped
if token.startswith("#"):
hidden = True
token = token[1:].strip()
if not token:
continue
column_key = request_table_column_key(token)
if not column_key:
if hidden:
continue
raise ValueError(f"line {line_number}: unknown request column {token!r}")
if column_key in seen:
continue
seen.add(column_key)
columns.append(column_key)
if hidden:
hidden_columns.append(column_key)
if not columns:
raise ValueError("no request columns found in editor document")
missing = [column_key for column_key in REQUEST_TABLE_COLUMN_KEYS if column_key not in seen]
return normalize_request_table_preferences(
{
"columns": columns + missing,
"hidden_columns": hidden_columns + missing,
}
)
def request_table_preferences_document(preferences: dict[str, Any]) -> str:
normalized = normalize_request_table_preferences(preferences)
hidden = set(normalized["hidden_columns"])
lines = [
"# Request table columns.",
"# One column key per line.",
"# Prefix with # to hide a column.",
"# Move lines up or down to change display order.",
"# Common keys: in, out, cache, req_id, resp_id, req_bytes, resp_bytes, round.",
"",
]
for column_key in normalized["columns"]:
prefix = "# " if column_key in hidden else ""
lines.append(f"{prefix}{column_key}")
return "\n".join(lines).rstrip() + "\n"
def load_request_table_preferences() -> dict[str, Any]: def load_request_table_preferences() -> dict[str, Any]:
path = request_table_preferences_file_path() configured_path = request_table_preferences_file_path()
return normalize_request_table_preferences(read_json_object(str(path))) configured_env = os.environ.get("CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE", "").strip()
candidate_paths = [configured_path]
if not configured_env:
candidate_paths.append(legacy_request_table_preferences_file_path())
for path in candidate_paths:
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
stripped = text.strip()
if not stripped:
continue
try:
if stripped.startswith("{"):
return normalize_request_table_preferences(json.loads(text))
return request_table_preferences_from_text(text)
except Exception:
continue
return normalize_request_table_preferences({})
def write_request_table_preferences(preferences: dict[str, Any]) -> Path: def write_request_table_preferences(preferences: dict[str, Any]) -> Path:
@@ -244,7 +412,7 @@ def write_request_table_preferences(preferences: dict[str, Any]) -> Path:
except OSError: except OSError:
pass pass
normalized = normalize_request_table_preferences(preferences) normalized = normalize_request_table_preferences(preferences)
path.write_text(json.dumps(normalized, indent=2, sort_keys=True) + "\n", encoding="utf-8") path.write_text(request_table_preferences_document(normalized), encoding="utf-8")
try: try:
path.chmod(0o600) path.chmod(0o600)
except OSError: except OSError:
@@ -495,6 +663,21 @@ def request_usage_summary(row: dict[str, Any]) -> str:
return f"in {in_text} | out {out_text} | cached {cached_text}" return f"in {in_text} | out {out_text} | cached {cached_text}"
def request_input_tokens_text(row: dict[str, Any]) -> str:
effective_in = effective_input_tokens(row.get("input_tokens"), row.get("cached_tokens"))
return format_count(effective_in) if effective_in is not None else "-"
def request_output_tokens_text(row: dict[str, Any]) -> str:
output_tokens = row.get("output_tokens")
return format_count(output_tokens) if output_tokens is not None else "-"
def request_cached_tokens_text(row: dict[str, Any]) -> str:
cached_tokens = row.get("cached_tokens")
return format_count(cached_tokens) if cached_tokens is not None else "-"
def format_count(value: Any) -> str: def format_count(value: Any) -> str:
number = as_int(value) number = as_int(value)
if abs(number) >= 1_000_000: if abs(number) >= 1_000_000:
@@ -686,7 +869,9 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None =
short_text(request_model_text(row), 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)), request_input_tokens_text(row),
request_output_tokens_text(row),
request_cached_tokens_text(row),
format_bytes(row.get("request_body_bytes")), format_bytes(row.get("request_body_bytes")),
format_bytes(row.get("response_bytes_received")), format_bytes(row.get("response_bytes_received")),
request_chunk_progress(row), request_chunk_progress(row),
@@ -1350,6 +1535,33 @@ def edit_profile_payload_with_editor(row: dict[str, Any]) -> dict[str, Any] | No
pass pass
def edit_request_table_preferences_with_editor(preferences: dict[str, Any]) -> dict[str, Any] | None:
original_text = request_table_preferences_document(preferences)
editor_command = resolve_editor_command()
fd, raw_path = tempfile.mkstemp(prefix="codex-retry-columns-", suffix=".txt")
os.close(fd)
temp_path = Path(raw_path)
temp_path.write_text(original_text, encoding="utf-8")
try:
result = subprocess.run(editor_command + [str(temp_path)], check=False)
edited_text = temp_path.read_text(encoding="utf-8")
if edited_text == original_text:
return None
preferences = request_table_preferences_from_text(edited_text)
if result.returncode != 0:
raise RuntimeError(f"editor exited with status {result.returncode}")
return preferences
except Exception as exc:
raise RuntimeError(f"{exc}; kept draft at {temp_path}") from exc
finally:
if temp_path.exists():
try:
if temp_path.read_text(encoding="utf-8") == original_text:
temp_path.unlink()
except Exception:
pass
def action_url(api_url: str, suffix: str) -> str: def action_url(api_url: str, suffix: str) -> str:
base = api_url.rstrip("/") base = api_url.rstrip("/")
return f"{base}{suffix}" return f"{base}{suffix}"
@@ -1396,8 +1608,6 @@ 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 = {
@@ -1429,11 +1639,7 @@ def fetch_dashboard_snapshot(
"payload": payload, "payload": payload,
"status_payload": payload, "status_payload": payload,
"status_error": " | ".join(errors), "status_error": " | ".join(errors),
"requests": sort_request_rows( "requests": normalize_request_rows(requests_source, filter_text),
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,
@@ -1489,15 +1695,9 @@ 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"),
("comma", "select_previous_request_column", "Prev Column"), ("c", "edit_request_columns", "Columns"),
("full_stop", "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"),
("]", "widen_usage_column", "Widen Usage"),
("e", "edit_profile", "Edit Profile"), ("e", "edit_profile", "Edit Profile"),
("p", "probe_profile", "Probe Profile"), ("p", "probe_profile", "Probe Profile"),
("s", "switch_profile", "Switch Profile"), ("s", "switch_profile", "Switch Profile"),
@@ -1533,9 +1733,6 @@ def run_textual(
} }
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_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": {
@@ -1549,7 +1746,7 @@ def run_textual(
"model": 16, "model": 16,
"effort": 8, "effort": 8,
"reasoning": 8, "reasoning": 8,
"usage": 36, "token_col": 8,
"req": 10, "req": 10,
"resp": 10, "resp": 10,
"chunks": 14, "chunks": 14,
@@ -1570,7 +1767,7 @@ def run_textual(
"model": 24, "model": 24,
"effort": 8, "effort": 8,
"reasoning": 8, "reasoning": 8,
"usage": 72, "token_col": 8,
"req": 12, "req": 12,
"resp": 12, "resp": 12,
"chunks": 16, "chunks": 16,
@@ -1581,7 +1778,6 @@ def run_textual(
"round": 10, "round": 10,
}, },
} }
self.request_usage_width = self.request_table_width_profiles[self.request_table_density]["usage"]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
@@ -1627,7 +1823,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 | , . 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" "views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | m compact/wide | c columns | 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()
@@ -1642,14 +1838,10 @@ 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:
selected_label = request_table_column_label(self.request_table_selected_column) visible_columns = request_table_visible_columns(self.request_table_preferences)
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 = ( counts = (
f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)} | " 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"req cols {len(visible_columns)}/{len(REQUEST_TABLE_COLUMNS)} | c edit 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)
@@ -1657,7 +1849,7 @@ def run_textual(
return self.query_one("#filter", Input).value.strip() return self.query_one("#filter", Input).value.strip()
def _visible_request_column_keys(self) -> list[str]: def _visible_request_column_keys(self) -> list[str]:
return request_table_visible_columns(self.request_table_preferences.get("visible_columns")) return request_table_visible_columns(self.request_table_preferences)
def _request_table_actions_enabled(self) -> bool: def _request_table_actions_enabled(self) -> bool:
if self.visible_tables.get(self.current_view, "requests") == "requests": if self.visible_tables.get(self.current_view, "requests") == "requests":
@@ -1667,8 +1859,6 @@ def run_textual(
def _persist_request_table_preferences(self) -> bool: def _persist_request_table_preferences(self) -> bool:
self.request_table_preferences = normalize_request_table_preferences(self.request_table_preferences) 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: try:
write_request_table_preferences(self.request_table_preferences) write_request_table_preferences(self.request_table_preferences)
except Exception as exc: except Exception as exc:
@@ -1701,11 +1891,9 @@ def run_textual(
width_profile = self.request_table_width_profiles[self.request_table_density] width_profile = self.request_table_width_profiles[self.request_table_density]
table.clear(columns=True) table.clear(columns=True)
self.request_table_column_keys = {} self.request_table_column_keys = {}
for column in REQUEST_TABLE_COLUMNS: for column_key in self._visible_request_column_keys():
column_key = column["key"] column = REQUEST_TABLE_COLUMN_BY_KEY[column_key]
if column_key not in self._visible_request_column_keys(): width = width_profile[column["width_key"]]
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( self.request_table_column_keys[column_key] = table.add_column(
column["label"], column["label"],
width=width, width=width,
@@ -1788,8 +1976,6 @@ 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)
@@ -1936,18 +2122,6 @@ def run_textual(
def action_focus_profiles(self) -> None: def action_focus_profiles(self) -> None:
self._set_view("profiles") self._set_view("profiles")
def _set_request_usage_width(self, width: int) -> None:
table = self.query_one("#requests_table", DataTable)
key = self.request_table_column_keys.get("usage")
column = table.columns.get(key) if key is not None else None
if column is None:
self._set_status("usage column not ready")
return
self.request_usage_width = max(18, min(80, width))
column.width = self.request_usage_width
self._rerender_requests_after_layout_change()
self._set_status(f"usage column width {self.request_usage_width}")
def _request_text_limits(self, table: DataTable) -> dict[str, int]: def _request_text_limits(self, table: DataTable) -> dict[str, int]:
width_profile = self.request_table_width_profiles[self.request_table_density] width_profile = self.request_table_width_profiles[self.request_table_density]
limits = { limits = {
@@ -1957,11 +2131,10 @@ def run_textual(
"path": width_profile["path"], "path": width_profile["path"],
"model": width_profile["model"], "model": width_profile["model"],
"effort": width_profile["effort"], "effort": width_profile["effort"],
"usage": self.request_usage_width,
"note": width_profile["note"], "note": width_profile["note"],
"round": width_profile["round"], "round": width_profile["round"],
} }
for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "usage", "note", "round"): for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "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):
@@ -1990,7 +2163,6 @@ def run_textual(
continue continue
column.width = width_profile[width_key] column.width = width_profile[width_key]
self.request_table_density = density self.request_table_density = density
self.request_usage_width = width_profile["usage"]
self._rerender_requests_after_layout_change() self._rerender_requests_after_layout_change()
self._set_status(f"request table mode {density}") self._set_status(f"request table mode {density}")
@@ -1998,78 +2170,24 @@ 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: def action_edit_request_columns(self) -> None:
if not self._request_table_actions_enabled():
return
try: try:
index = REQUEST_TABLE_COLUMN_KEYS.index(self.request_table_selected_column) with self.suspend():
except ValueError: updated = edit_request_table_preferences_with_editor(self.request_table_preferences)
index = 0 if updated is None:
self.request_table_selected_column = REQUEST_TABLE_COLUMN_KEYS[(index + offset) % len(REQUEST_TABLE_COLUMN_KEYS)] self._set_status("request columns unchanged")
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 return
visible_columns = self._visible_request_column_keys() self.request_table_preferences = updated
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(): if not self._persist_request_table_preferences():
return return
self._rebuild_request_table_columns() self._rebuild_request_table_columns()
self._rerender_requests_after_layout_change() 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( self._set_status(
f"request sort {request_table_column_label(self.request_table_sort_column)} " f"request columns saved -> {request_table_preferences_file_path()} | "
f"{'desc' if self.request_table_sort_reverse else 'asc'}" f"{len(self._visible_request_column_keys())}/{len(REQUEST_TABLE_COLUMNS)} shown"
) )
except Exception as exc:
def action_sort_requests_by_selected_column(self) -> None: self._set_status(f"request columns error: {exc}")
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)
def action_narrow_usage_column(self) -> None:
self._set_request_usage_width(self.request_usage_width - 4)
def action_scroll_table_left(self) -> None: def action_scroll_table_left(self) -> None:
table = self._current_table_widget() table = self._current_table_widget()
+38 -17
View File
@@ -144,18 +144,20 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
"error": "Selected model is at capacity. Please try a different model.", "error": "Selected model is at capacity. Please try a different model.",
"finished_at": "2026-06-30T12:25:00Z", "finished_at": "2026-06-30T12:25:00Z",
} }
compact = mod.request_row_cells(row, {"req_id": 18, "resp_id": 18, "thread": 18, "path": 22, "model": 16, "usage": 24, "note": 22}) compact = mod.request_row_cells(row, {"req_id": 18, "resp_id": 18, "thread": 18, "path": 22, "model": 16, "note": 22})
wide = mod.request_row_cells(row, {"req_id": 36, "resp_id": 36, "thread": 36, "path": 40, "model": 24, "usage": 72, "note": 48}) wide = mod.request_row_cells(row, {"req_id": 36, "resp_id": 36, "thread": 36, "path": 40, "model": 24, "note": 48})
self.assertEqual(compact[11], "0B") self.assertEqual(compact[13], "0B")
self.assertEqual(wide[11], "0B") self.assertEqual(wide[13], "0B")
self.assertEqual(compact[8], "-") self.assertEqual(compact[8], "-")
self.assertEqual(compact[9], "516") self.assertEqual(compact[9], "516")
self.assertEqual(compact[10], "1.1K")
self.assertEqual(compact[11], "340")
self.assertEqual(compact[12], "128")
self.assertLess(len(compact[1]), len(wide[1])) self.assertLess(len(compact[1]), len(wide[1]))
self.assertLess(len(compact[2]), len(wide[2])) self.assertLess(len(compact[2]), len(wide[2]))
self.assertLess(len(compact[3]), len(wide[3])) self.assertLess(len(compact[3]), len(wide[3]))
self.assertLess(len(compact[6]), len(wide[6])) self.assertLess(len(compact[6]), len(wide[6]))
self.assertLess(len(compact[10]), len(wide[10])) self.assertLess(len(compact[19]), len(wide[19]))
self.assertLess(len(compact[17]), len(wide[17]))
def test_request_row_cells_show_request_bytes(self) -> None: def test_request_row_cells_show_request_bytes(self) -> None:
mod = load_module() mod = load_module()
@@ -190,9 +192,12 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
self.assertEqual(cells[2], "resp_1") self.assertEqual(cells[2], "resp_1")
self.assertEqual(cells[8], "xhigh") self.assertEqual(cells[8], "xhigh")
self.assertEqual(cells[9], "7") self.assertEqual(cells[9], "7")
self.assertEqual(cells[11], "1.5KB") self.assertEqual(cells[10], "15")
self.assertEqual(cells[12], "4.1KB") self.assertEqual(cells[11], "10")
self.assertEqual(cells[17], "-") self.assertEqual(cells[12], "5")
self.assertEqual(cells[13], "1.5KB")
self.assertEqual(cells[14], "4.1KB")
self.assertEqual(cells[19], "-")
def test_request_retry_note_only_shows_retry_reason(self) -> None: def test_request_retry_note_only_shows_retry_reason(self) -> None:
mod = load_module() mod = load_module()
@@ -253,12 +258,12 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
self.assertIn("slot 1", mod.request_retry_wave_summary(row)) self.assertIn("slot 1", mod.request_retry_wave_summary(row))
self.assertIn("retry round 3(2)", mod.render_request_detail(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.assertIn("current wave slot 1 first 1.2s", mod.render_request_detail(row))
self.assertEqual(mod.request_row_cells(row)[18], "3(2)") self.assertEqual(mod.request_row_cells(row)[20], "3(2)")
def test_request_table_preferences_round_trip(self) -> None: def test_request_table_preferences_round_trip(self) -> None:
mod = load_module() mod = load_module()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
prefs_path = Path(tmpdir) / "request-table.json" prefs_path = Path(tmpdir) / "request-table-columns.txt"
with mock.patch.dict( with mock.patch.dict(
os.environ, os.environ,
{"CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE": str(prefs_path)}, {"CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE": str(prefs_path)},
@@ -266,16 +271,32 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
): ):
saved_path = mod.write_request_table_preferences( saved_path = mod.write_request_table_preferences(
{ {
"visible_columns": ["round", "seq", "status", "unknown"], "columns": ["round", "seq", "status", "unknown"],
"sort_column": "round", "hidden_columns": ["status", "unknown"],
"sort_reverse": False,
} }
) )
self.assertEqual(saved_path, prefs_path) self.assertEqual(saved_path, prefs_path)
loaded = mod.load_request_table_preferences() loaded = mod.load_request_table_preferences()
self.assertEqual(loaded["visible_columns"], ["seq", "status", "round"]) self.assertEqual(loaded["columns"][:3], ["round", "seq", "status"])
self.assertEqual(loaded["sort_column"], "round") self.assertEqual(loaded["hidden_columns"], ["status"])
self.assertFalse(loaded["sort_reverse"])
def test_request_table_preferences_from_text_hides_and_reorders_columns(self) -> None:
mod = load_module()
prefs = mod.request_table_preferences_from_text(
"\n".join(
[
"# comment",
"thread",
"seq",
"# cache",
"in",
"out",
"",
]
)
)
self.assertEqual(prefs["columns"][:6], ["thread", "seq", "cache", "in", "out", "req_id"])
self.assertIn("cache", prefs["hidden_columns"])
def test_sort_request_rows_supports_custom_column_and_missing_values(self) -> None: def test_sort_request_rows_supports_custom_column_and_missing_values(self) -> None:
mod = load_module() mod = load_module()