feat: simplify request column editor
This commit is contained in:
+258
-140
@@ -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
|
|
||||||
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
|
return
|
||||||
visible_columns = [key for key in visible_columns if key != column_key]
|
self.request_table_preferences = updated
|
||||||
action = "hidden"
|
if not self._persist_request_table_preferences():
|
||||||
else:
|
return
|
||||||
visible_columns.append(column_key)
|
self._rebuild_request_table_columns()
|
||||||
action = "shown"
|
self._rerender_requests_after_layout_change()
|
||||||
self.request_table_preferences["visible_columns"] = request_table_visible_columns(visible_columns)
|
self._set_status(
|
||||||
if not self._persist_request_table_preferences():
|
f"request columns saved -> {request_table_preferences_file_path()} | "
|
||||||
return
|
f"{len(self._visible_request_column_keys())}/{len(REQUEST_TABLE_COLUMNS)} shown"
|
||||||
self._rebuild_request_table_columns()
|
)
|
||||||
self._rerender_requests_after_layout_change()
|
except Exception as exc:
|
||||||
self._set_status(f"request column {request_table_column_label(column_key)} {action}")
|
self._set_status(f"request columns error: {exc}")
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user