ai-cli-toolkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_cli/__init__.py +3 -0
- ai_cli/__main__.py +6 -0
- ai_cli/bin/ai-mux-linux-x86_64 +0 -0
- ai_cli/bin/remote-tty-wrapper +153 -0
- ai_cli/ca.py +175 -0
- ai_cli/completion_gen.py +680 -0
- ai_cli/config.py +185 -0
- ai_cli/credentials.py +341 -0
- ai_cli/detached_cleanup.py +135 -0
- ai_cli/housekeeping.py +50 -0
- ai_cli/instructions.py +308 -0
- ai_cli/log.py +53 -0
- ai_cli/main.py +1516 -0
- ai_cli/main_helpers.py +553 -0
- ai_cli/prompt_editor_launcher.py +324 -0
- ai_cli/proxy.py +627 -0
- ai_cli/remote.py +669 -0
- ai_cli/remote_package.py +1111 -0
- ai_cli/session.py +1344 -0
- ai_cli/session_store.py +236 -0
- ai_cli/traffic.py +1510 -0
- ai_cli/traffic_db.py +118 -0
- ai_cli/tui.py +525 -0
- ai_cli/update.py +200 -0
- ai_cli_toolkit-0.2.0.dist-info/METADATA +17 -0
- ai_cli_toolkit-0.2.0.dist-info/RECORD +30 -0
- ai_cli_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ai_cli_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- ai_cli_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ai_cli_toolkit-0.2.0.dist-info/top_level.txt +1 -0
ai_cli/traffic.py
ADDED
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
"""Interactive traffic request/response viewer.
|
|
2
|
+
|
|
3
|
+
Reads from ~/.ai-cli/traffic.db and provides:
|
|
4
|
+
- Tabular list of requests with timestamp, caller, provider, method, host, path, status
|
|
5
|
+
- Filter by caller tool (--caller copilot/claude/codex/gemini)
|
|
6
|
+
- Filter by provider (--provider)
|
|
7
|
+
- Search by host/address/body/path/provider/request id (--search)
|
|
8
|
+
- Sort by time (default newest-first), domain, request number, or provider
|
|
9
|
+
- Interactive detail view for request/response bodies
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
ai-cli traffic # list recent traffic
|
|
13
|
+
ai-cli traffic --caller claude # filter by caller
|
|
14
|
+
ai-cli traffic --host anthropic # filter by host substring
|
|
15
|
+
ai-cli traffic --search "function" # search body/path/host/provider/id
|
|
16
|
+
ai-cli traffic --sort domain # sort by host instead of time
|
|
17
|
+
ai-cli traffic --sort request # sort by request id
|
|
18
|
+
ai-cli traffic --sort provider # sort by provider name
|
|
19
|
+
ai-cli traffic --api # only show API calls (with bodies)
|
|
20
|
+
ai-cli traffic --limit 50 # show N rows (default 100)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import curses
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import sqlite3
|
|
31
|
+
import sys
|
|
32
|
+
import textwrap
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import urwid
|
|
39
|
+
except Exception: # pragma: no cover - optional runtime dependency
|
|
40
|
+
urwid = None
|
|
41
|
+
|
|
42
|
+
from ai_cli import traffic_db as _traffic_db
|
|
43
|
+
|
|
44
|
+
_DEFAULT_DB_PATH = _traffic_db.DEFAULT_DB_PATH
|
|
45
|
+
_SORT_MODES = _traffic_db.SORT_MODES
|
|
46
|
+
_COLOR_ENABLED = False
|
|
47
|
+
|
|
48
|
+
# Caller display colors (curses color pair indices)
|
|
49
|
+
_CALLER_COLORS = {
|
|
50
|
+
"claude": 1,
|
|
51
|
+
"copilot": 2,
|
|
52
|
+
"codex": 3,
|
|
53
|
+
"gemini": 4,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
58
|
+
return _traffic_db.connect(db_path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_query(
|
|
62
|
+
caller: str = "",
|
|
63
|
+
host: str = "",
|
|
64
|
+
search: str = "",
|
|
65
|
+
provider: str = "",
|
|
66
|
+
api_only: bool = False,
|
|
67
|
+
sort: str = "time",
|
|
68
|
+
limit: int = 100,
|
|
69
|
+
) -> tuple[str, list[Any]]:
|
|
70
|
+
return _traffic_db.build_query(
|
|
71
|
+
caller=caller,
|
|
72
|
+
host=host,
|
|
73
|
+
search=search,
|
|
74
|
+
provider=provider,
|
|
75
|
+
api_only=api_only,
|
|
76
|
+
sort=sort,
|
|
77
|
+
limit=limit,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _format_size(n: int | None) -> str:
|
|
82
|
+
if n is None:
|
|
83
|
+
return "-"
|
|
84
|
+
if n < 1024:
|
|
85
|
+
return f"{n}B"
|
|
86
|
+
if n < 1024 * 1024:
|
|
87
|
+
return f"{n / 1024:.1f}K"
|
|
88
|
+
return f"{n / (1024 * 1024):.1f}M"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _format_ts(ts: str) -> str:
|
|
92
|
+
"""Shorten ISO timestamp to HH:MM:SS or date+time."""
|
|
93
|
+
if not ts:
|
|
94
|
+
return ""
|
|
95
|
+
# ts is like 2026-03-01T23:55:10Z
|
|
96
|
+
if "T" in ts:
|
|
97
|
+
parts = ts.split("T")
|
|
98
|
+
time_part = parts[1].rstrip("Z")[:8]
|
|
99
|
+
date_part = parts[0][5:] # MM-DD
|
|
100
|
+
return f"{date_part} {time_part}"
|
|
101
|
+
return ts[:19]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _caller_label(caller: str) -> str:
|
|
105
|
+
return caller or "?"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scalar_text(value: Any) -> str:
|
|
109
|
+
if value is None:
|
|
110
|
+
return "null"
|
|
111
|
+
if isinstance(value, bool):
|
|
112
|
+
return "true" if value else "false"
|
|
113
|
+
if isinstance(value, (int, float)):
|
|
114
|
+
return str(value)
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
return value
|
|
117
|
+
return json.dumps(value, ensure_ascii=False)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _flatten_json_pairs(value: Any, prefix: str = "") -> list[tuple[str, str]]:
|
|
121
|
+
pairs: list[tuple[str, str]] = []
|
|
122
|
+
if isinstance(value, dict):
|
|
123
|
+
if not value:
|
|
124
|
+
pairs.append((prefix or "value", "{}"))
|
|
125
|
+
return pairs
|
|
126
|
+
for key, item in value.items():
|
|
127
|
+
next_key = f"{prefix}.{key}" if prefix else str(key)
|
|
128
|
+
pairs.extend(_flatten_json_pairs(item, next_key))
|
|
129
|
+
return pairs
|
|
130
|
+
if isinstance(value, list):
|
|
131
|
+
if not value:
|
|
132
|
+
pairs.append((prefix or "value", "[]"))
|
|
133
|
+
return pairs
|
|
134
|
+
for idx, item in enumerate(value):
|
|
135
|
+
next_key = f"{prefix}[{idx}]" if prefix else f"[{idx}]"
|
|
136
|
+
pairs.extend(_flatten_json_pairs(item, next_key))
|
|
137
|
+
return pairs
|
|
138
|
+
pairs.append((prefix or "value", _scalar_text(value)))
|
|
139
|
+
return pairs
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_sse_to_pairs(body: str) -> list[tuple[str, str]]:
|
|
143
|
+
pairs: list[tuple[str, str]] = []
|
|
144
|
+
event_name = ""
|
|
145
|
+
event_index = 0
|
|
146
|
+
for raw in body.splitlines():
|
|
147
|
+
line = raw.strip()
|
|
148
|
+
if not line:
|
|
149
|
+
continue
|
|
150
|
+
if line.startswith("event:"):
|
|
151
|
+
event_name = line[6:].strip()
|
|
152
|
+
continue
|
|
153
|
+
if not line.startswith("data:"):
|
|
154
|
+
continue
|
|
155
|
+
payload = line[5:].strip()
|
|
156
|
+
if not payload or payload == "[DONE]":
|
|
157
|
+
continue
|
|
158
|
+
name = event_name or f"event_{event_index}"
|
|
159
|
+
event_index += 1
|
|
160
|
+
event_name = ""
|
|
161
|
+
try:
|
|
162
|
+
obj = json.loads(payload)
|
|
163
|
+
pairs.extend(_flatten_json_pairs(obj, f"sse.{name}"))
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
pairs.append((f"sse.{name}.data", payload))
|
|
166
|
+
return pairs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _body_pairs(body: str | None) -> list[tuple[str, str]]:
|
|
170
|
+
if not body:
|
|
171
|
+
return [("value", "(empty)")]
|
|
172
|
+
try:
|
|
173
|
+
obj = json.loads(body)
|
|
174
|
+
return _flatten_json_pairs(obj)
|
|
175
|
+
except (json.JSONDecodeError, TypeError):
|
|
176
|
+
sse_pairs = _parse_sse_to_pairs(body)
|
|
177
|
+
if sse_pairs:
|
|
178
|
+
return sse_pairs
|
|
179
|
+
return [("raw", body)]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _format_body_pairs_lines(body: str | None, width: int, max_lines: int) -> list[str]:
|
|
183
|
+
max_width = max(24, width)
|
|
184
|
+
pairs = _body_pairs(body)
|
|
185
|
+
lines: list[str] = []
|
|
186
|
+
truncated = False
|
|
187
|
+
for key, value in pairs:
|
|
188
|
+
if len(lines) >= max_lines:
|
|
189
|
+
truncated = True
|
|
190
|
+
break
|
|
191
|
+
lines.append(f"**{key}**")
|
|
192
|
+
for raw in str(value).splitlines() or [""]:
|
|
193
|
+
if len(lines) >= max_lines:
|
|
194
|
+
truncated = True
|
|
195
|
+
break
|
|
196
|
+
if not raw:
|
|
197
|
+
lines.append(" ")
|
|
198
|
+
continue
|
|
199
|
+
wrapped = textwrap.fill(
|
|
200
|
+
raw,
|
|
201
|
+
width=max_width - 2,
|
|
202
|
+
initial_indent=" ",
|
|
203
|
+
subsequent_indent=" ",
|
|
204
|
+
break_long_words=False,
|
|
205
|
+
break_on_hyphens=False,
|
|
206
|
+
)
|
|
207
|
+
for part in wrapped.splitlines():
|
|
208
|
+
if len(lines) >= max_lines:
|
|
209
|
+
truncated = True
|
|
210
|
+
break
|
|
211
|
+
lines.append(part)
|
|
212
|
+
if truncated:
|
|
213
|
+
break
|
|
214
|
+
if len(lines) >= max_lines:
|
|
215
|
+
truncated = True
|
|
216
|
+
break
|
|
217
|
+
lines.append("")
|
|
218
|
+
if truncated and lines:
|
|
219
|
+
if len(lines) >= max_lines:
|
|
220
|
+
lines[-1] = "... (truncated)"
|
|
221
|
+
else:
|
|
222
|
+
lines.append("... (truncated)")
|
|
223
|
+
return lines or ["(empty)"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _markdown_line_style(line: str) -> tuple[str, bool]:
|
|
227
|
+
"""Return (display_text, bold) for simple markdown-ish lines."""
|
|
228
|
+
stripped = line.strip()
|
|
229
|
+
if stripped.startswith("**") and stripped.endswith("**") and len(stripped) > 4:
|
|
230
|
+
return stripped[2:-2], True
|
|
231
|
+
return line, False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _render_terminal_line(line: str) -> str:
|
|
235
|
+
"""Render markdown-ish line for plain terminal output."""
|
|
236
|
+
text, bold = _markdown_line_style(line)
|
|
237
|
+
if bold and sys.stdout.isatty():
|
|
238
|
+
return f"\033[1m{text}\033[0m"
|
|
239
|
+
return text
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ── Plain text output (non-interactive) ───────────────────────────────
|
|
243
|
+
|
|
244
|
+
def _print_table(rows: list[sqlite3.Row]) -> None:
|
|
245
|
+
"""Print traffic rows as a formatted table."""
|
|
246
|
+
if not rows:
|
|
247
|
+
print("No traffic found matching filters.")
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
print(
|
|
251
|
+
f"{'#':<5} {'Time':<15} {'Caller':<8} {'Prov':<10} {'Method':<7} "
|
|
252
|
+
f"{'Status':>6} {'Host':<24} {'Path':<28} {'Req':>6} {'Resp':>6}"
|
|
253
|
+
)
|
|
254
|
+
print("─" * 130)
|
|
255
|
+
|
|
256
|
+
for r in rows:
|
|
257
|
+
status_str = str(r["status"]) if r["status"] else " - "
|
|
258
|
+
path_display = r["path"]
|
|
259
|
+
if len(path_display) > 27:
|
|
260
|
+
path_display = path_display[:24] + "..."
|
|
261
|
+
host_display = r["host"]
|
|
262
|
+
if len(host_display) > 23:
|
|
263
|
+
host_display = host_display[:20] + "..."
|
|
264
|
+
prov_display = (r["provider"] or "-")
|
|
265
|
+
if len(prov_display) > 9:
|
|
266
|
+
prov_display = prov_display[:8] + "…"
|
|
267
|
+
|
|
268
|
+
api_marker = "●" if r["is_api"] else " "
|
|
269
|
+
print(
|
|
270
|
+
f"{r['id']:<5} {_format_ts(r['ts']):<15} {_caller_label(r['caller']):<8} "
|
|
271
|
+
f"{prov_display:<10} "
|
|
272
|
+
f"{r['method']:<7} {status_str:>6} "
|
|
273
|
+
f"{host_display:<24} {path_display:<28} "
|
|
274
|
+
f"{_format_size(r['req_bytes']):>6} {_format_size(r['resp_bytes']):>6} {api_marker}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _print_detail(row: sqlite3.Row) -> None:
|
|
279
|
+
"""Print full detail for a single traffic row."""
|
|
280
|
+
width = max(24, shutil.get_terminal_size((120, 40)).columns - 2)
|
|
281
|
+
lines = _detail_content_lines(row, width=width, max_body_lines=300)
|
|
282
|
+
|
|
283
|
+
print()
|
|
284
|
+
print("═" * 80)
|
|
285
|
+
for line in lines:
|
|
286
|
+
print(_render_terminal_line(line))
|
|
287
|
+
print("═" * 80)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _request_params(row: sqlite3.Row) -> list[tuple[str, str]]:
|
|
291
|
+
"""Extract human-readable request parameters from URL and JSON request body."""
|
|
292
|
+
params: list[tuple[str, str]] = []
|
|
293
|
+
|
|
294
|
+
path = row["path"] or ""
|
|
295
|
+
query_pairs = parse_qsl(urlsplit(path).query, keep_blank_values=True)
|
|
296
|
+
for key, value in query_pairs:
|
|
297
|
+
params.append((f"url.{key}", value))
|
|
298
|
+
|
|
299
|
+
body_text = row["req_body"] or ""
|
|
300
|
+
if not body_text:
|
|
301
|
+
return params
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
body = json.loads(body_text)
|
|
305
|
+
except (json.JSONDecodeError, TypeError):
|
|
306
|
+
return params
|
|
307
|
+
if not isinstance(body, dict):
|
|
308
|
+
return params
|
|
309
|
+
|
|
310
|
+
# Common request-shaping keys that are useful at a glance.
|
|
311
|
+
for key in ("model", "stream", "temperature", "top_p", "max_tokens", "tool_choice"):
|
|
312
|
+
if key in body:
|
|
313
|
+
params.append((f"body.{key}", str(body.get(key))))
|
|
314
|
+
|
|
315
|
+
# Length-oriented parameters for chat-style inputs.
|
|
316
|
+
if isinstance(body.get("messages"), list):
|
|
317
|
+
params.append(("body.messages", str(len(body["messages"]))))
|
|
318
|
+
if isinstance(body.get("input"), list):
|
|
319
|
+
params.append(("body.input", str(len(body["input"]))))
|
|
320
|
+
if isinstance(body.get("contents"), list):
|
|
321
|
+
params.append(("body.contents", str(len(body["contents"]))))
|
|
322
|
+
if isinstance(body.get("tools"), list):
|
|
323
|
+
params.append(("body.tools", str(len(body["tools"]))))
|
|
324
|
+
|
|
325
|
+
# Include a few additional scalar keys for debugging unknown providers.
|
|
326
|
+
extra_count = 0
|
|
327
|
+
for key in sorted(body.keys()):
|
|
328
|
+
if key in {"model", "stream", "temperature", "top_p", "max_tokens", "tool_choice",
|
|
329
|
+
"messages", "input", "contents", "tools"}:
|
|
330
|
+
continue
|
|
331
|
+
body_value = body.get(key)
|
|
332
|
+
if isinstance(body_value, (str, int, float, bool)) or body_value is None:
|
|
333
|
+
params.append((f"body.{key}", str(body_value)))
|
|
334
|
+
extra_count += 1
|
|
335
|
+
if extra_count >= 8:
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
return params
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _message_content_to_text(content: Any) -> str:
|
|
342
|
+
if content is None:
|
|
343
|
+
return ""
|
|
344
|
+
if isinstance(content, str):
|
|
345
|
+
return content
|
|
346
|
+
if isinstance(content, list):
|
|
347
|
+
parts: list[str] = []
|
|
348
|
+
for part in content:
|
|
349
|
+
part_text = _message_content_to_text(part)
|
|
350
|
+
if part_text:
|
|
351
|
+
parts.append(part_text)
|
|
352
|
+
return "\n\n".join(parts)
|
|
353
|
+
if isinstance(content, dict):
|
|
354
|
+
ptype = str(content.get("type", "")).lower()
|
|
355
|
+
if ptype in {"text", "input_text", "output_text"}:
|
|
356
|
+
text = content.get("text") or content.get("content") or ""
|
|
357
|
+
return str(text)
|
|
358
|
+
if "text" in content and isinstance(content.get("text"), str):
|
|
359
|
+
return str(content["text"])
|
|
360
|
+
if "parts" in content:
|
|
361
|
+
return _message_content_to_text(content.get("parts"))
|
|
362
|
+
if "content" in content:
|
|
363
|
+
return _message_content_to_text(content.get("content"))
|
|
364
|
+
if ptype in {"image", "image_url", "input_image"}:
|
|
365
|
+
return "[image]"
|
|
366
|
+
values = _json_leaf_values(content, limit=80)
|
|
367
|
+
if values:
|
|
368
|
+
return "\n".join(values)
|
|
369
|
+
return json.dumps(content, indent=2, ensure_ascii=False)
|
|
370
|
+
return str(content)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _json_leaf_values(value: Any, limit: int = 80) -> list[str]:
|
|
374
|
+
"""Extract scalar leaf values from JSON-like data."""
|
|
375
|
+
out: list[str] = []
|
|
376
|
+
|
|
377
|
+
def _walk(node: Any) -> None:
|
|
378
|
+
if len(out) >= limit:
|
|
379
|
+
return
|
|
380
|
+
if node is None:
|
|
381
|
+
return
|
|
382
|
+
if isinstance(node, str):
|
|
383
|
+
text = node.strip()
|
|
384
|
+
if text:
|
|
385
|
+
out.append(text)
|
|
386
|
+
return
|
|
387
|
+
if isinstance(node, (int, float, bool)):
|
|
388
|
+
out.append(str(node))
|
|
389
|
+
return
|
|
390
|
+
if isinstance(node, list):
|
|
391
|
+
for item in node:
|
|
392
|
+
_walk(item)
|
|
393
|
+
if len(out) >= limit:
|
|
394
|
+
break
|
|
395
|
+
return
|
|
396
|
+
if isinstance(node, dict):
|
|
397
|
+
for item in node.values():
|
|
398
|
+
_walk(item)
|
|
399
|
+
if len(out) >= limit:
|
|
400
|
+
break
|
|
401
|
+
|
|
402
|
+
_walk(value)
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _json_text_values(value: Any, limit: int = 80) -> list[str]:
|
|
407
|
+
"""Extract likely human-readable text values from JSON-like data."""
|
|
408
|
+
out: list[str] = []
|
|
409
|
+
text_keys = {
|
|
410
|
+
"text",
|
|
411
|
+
"content",
|
|
412
|
+
"message",
|
|
413
|
+
"output_text",
|
|
414
|
+
"input_text",
|
|
415
|
+
"prompt",
|
|
416
|
+
"completion",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
def _walk(node: Any, key_hint: str = "") -> None:
|
|
420
|
+
if len(out) >= limit:
|
|
421
|
+
return
|
|
422
|
+
if node is None:
|
|
423
|
+
return
|
|
424
|
+
if isinstance(node, str):
|
|
425
|
+
text = node.strip()
|
|
426
|
+
if not text:
|
|
427
|
+
return
|
|
428
|
+
if key_hint in text_keys or "\n" in text or len(text.split()) > 2:
|
|
429
|
+
out.append(text)
|
|
430
|
+
return
|
|
431
|
+
if isinstance(node, list):
|
|
432
|
+
for item in node:
|
|
433
|
+
_walk(item, key_hint)
|
|
434
|
+
if len(out) >= limit:
|
|
435
|
+
break
|
|
436
|
+
return
|
|
437
|
+
if isinstance(node, dict):
|
|
438
|
+
for k, v in node.items():
|
|
439
|
+
_walk(v, str(k).lower())
|
|
440
|
+
if len(out) >= limit:
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
_walk(value)
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _extract_request_steps(body_text: str | None) -> list[tuple[str, str]]:
|
|
448
|
+
"""Extract role/content steps from request payload formats."""
|
|
449
|
+
if not body_text:
|
|
450
|
+
return []
|
|
451
|
+
try:
|
|
452
|
+
payload = json.loads(body_text)
|
|
453
|
+
except (json.JSONDecodeError, TypeError):
|
|
454
|
+
return []
|
|
455
|
+
if not isinstance(payload, dict):
|
|
456
|
+
return []
|
|
457
|
+
|
|
458
|
+
steps: list[tuple[str, str]] = []
|
|
459
|
+
|
|
460
|
+
def _append(role: str, content: Any) -> None:
|
|
461
|
+
text = _message_content_to_text(content).strip()
|
|
462
|
+
if text:
|
|
463
|
+
steps.append((role or "unknown", text))
|
|
464
|
+
|
|
465
|
+
messages = payload.get("messages")
|
|
466
|
+
if isinstance(messages, list):
|
|
467
|
+
for item in messages:
|
|
468
|
+
if not isinstance(item, dict):
|
|
469
|
+
continue
|
|
470
|
+
_append(str(item.get("role") or "message"), item.get("content"))
|
|
471
|
+
|
|
472
|
+
input_items = payload.get("input")
|
|
473
|
+
if isinstance(input_items, list):
|
|
474
|
+
for item in input_items:
|
|
475
|
+
if not isinstance(item, dict):
|
|
476
|
+
continue
|
|
477
|
+
role = str(item.get("role") or item.get("type") or "input")
|
|
478
|
+
content: Any = item.get("content")
|
|
479
|
+
if content is None:
|
|
480
|
+
if "input_text" in item:
|
|
481
|
+
content = item.get("input_text")
|
|
482
|
+
elif "text" in item:
|
|
483
|
+
content = item.get("text")
|
|
484
|
+
else:
|
|
485
|
+
content = item
|
|
486
|
+
_append(role, content)
|
|
487
|
+
|
|
488
|
+
contents = payload.get("contents")
|
|
489
|
+
if isinstance(contents, list):
|
|
490
|
+
for item in contents:
|
|
491
|
+
if not isinstance(item, dict):
|
|
492
|
+
continue
|
|
493
|
+
role = str(item.get("role") or "content")
|
|
494
|
+
_append(role, item.get("parts") or item.get("content") or item)
|
|
495
|
+
|
|
496
|
+
system_val = payload.get("system")
|
|
497
|
+
if system_val is not None:
|
|
498
|
+
_append("system", system_val)
|
|
499
|
+
|
|
500
|
+
instruction_val = payload.get("instructions")
|
|
501
|
+
if instruction_val is not None:
|
|
502
|
+
_append("system", instruction_val)
|
|
503
|
+
|
|
504
|
+
return steps
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _extract_response_steps(body_text: str | None) -> list[tuple[str, str]]:
|
|
508
|
+
"""Extract assistant text from response JSON / SSE JSON events."""
|
|
509
|
+
if not body_text:
|
|
510
|
+
return []
|
|
511
|
+
|
|
512
|
+
def _from_obj(obj: Any) -> str:
|
|
513
|
+
if isinstance(obj, dict):
|
|
514
|
+
chunks: list[str] = []
|
|
515
|
+
|
|
516
|
+
# Anthropic streaming shapes
|
|
517
|
+
if obj.get("type") == "content_block_delta":
|
|
518
|
+
delta = obj.get("delta")
|
|
519
|
+
if isinstance(delta, dict):
|
|
520
|
+
txt = delta.get("text") or delta.get("partial_json")
|
|
521
|
+
if isinstance(txt, str) and txt.strip():
|
|
522
|
+
chunks.append(txt.strip())
|
|
523
|
+
if obj.get("type") == "content_block_start":
|
|
524
|
+
block = obj.get("content_block")
|
|
525
|
+
if isinstance(block, dict):
|
|
526
|
+
txt = block.get("text")
|
|
527
|
+
if isinstance(txt, str) and txt.strip():
|
|
528
|
+
chunks.append(txt.strip())
|
|
529
|
+
|
|
530
|
+
# OpenAI-style chunks
|
|
531
|
+
choices = obj.get("choices")
|
|
532
|
+
if isinstance(choices, list):
|
|
533
|
+
for ch in choices:
|
|
534
|
+
if not isinstance(ch, dict):
|
|
535
|
+
continue
|
|
536
|
+
delta = ch.get("delta")
|
|
537
|
+
if isinstance(delta, dict):
|
|
538
|
+
txt = delta.get("content")
|
|
539
|
+
if isinstance(txt, str) and txt.strip():
|
|
540
|
+
chunks.append(txt.strip())
|
|
541
|
+
msg = ch.get("message")
|
|
542
|
+
if isinstance(msg, dict):
|
|
543
|
+
txt = msg.get("content")
|
|
544
|
+
if isinstance(txt, str) and txt.strip():
|
|
545
|
+
chunks.append(txt.strip())
|
|
546
|
+
|
|
547
|
+
# Generic content/output
|
|
548
|
+
for key in ("output_text", "text"):
|
|
549
|
+
txt = obj.get(key)
|
|
550
|
+
if isinstance(txt, str) and txt.strip():
|
|
551
|
+
chunks.append(txt.strip())
|
|
552
|
+
for key in ("output", "content", "message", "response"):
|
|
553
|
+
val = obj.get(key)
|
|
554
|
+
txt = _message_content_to_text(val).strip() if val is not None else ""
|
|
555
|
+
if txt:
|
|
556
|
+
chunks.append(txt)
|
|
557
|
+
|
|
558
|
+
if chunks:
|
|
559
|
+
return "\n".join(chunks)
|
|
560
|
+
|
|
561
|
+
vals = _json_text_values(obj, limit=60)
|
|
562
|
+
return "\n".join(vals)
|
|
563
|
+
|
|
564
|
+
if isinstance(obj, list):
|
|
565
|
+
vals = _json_text_values(obj, limit=60)
|
|
566
|
+
return "\n".join(vals)
|
|
567
|
+
|
|
568
|
+
if isinstance(obj, str):
|
|
569
|
+
return obj.strip()
|
|
570
|
+
return ""
|
|
571
|
+
|
|
572
|
+
chunks: list[str] = []
|
|
573
|
+
saw_sse = False
|
|
574
|
+
for raw in body_text.splitlines():
|
|
575
|
+
line = raw.strip()
|
|
576
|
+
if not line.startswith("data:"):
|
|
577
|
+
continue
|
|
578
|
+
saw_sse = True
|
|
579
|
+
payload = line[5:].strip()
|
|
580
|
+
if not payload or payload == "[DONE]":
|
|
581
|
+
continue
|
|
582
|
+
try:
|
|
583
|
+
obj = json.loads(payload)
|
|
584
|
+
except json.JSONDecodeError:
|
|
585
|
+
continue
|
|
586
|
+
txt = _from_obj(obj)
|
|
587
|
+
if txt:
|
|
588
|
+
chunks.append(txt)
|
|
589
|
+
|
|
590
|
+
if not saw_sse:
|
|
591
|
+
try:
|
|
592
|
+
obj = json.loads(body_text)
|
|
593
|
+
except (json.JSONDecodeError, TypeError):
|
|
594
|
+
return []
|
|
595
|
+
txt = _from_obj(obj)
|
|
596
|
+
return [("assistant", txt)] if txt else []
|
|
597
|
+
|
|
598
|
+
if not chunks:
|
|
599
|
+
return []
|
|
600
|
+
merged = "\n".join(chunks).strip()
|
|
601
|
+
return [("assistant", merged)] if merged else []
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _extract_conversation_steps(row: sqlite3.Row) -> list[tuple[str, str]]:
|
|
605
|
+
steps = _extract_request_steps(row["req_body"])
|
|
606
|
+
steps.extend(_extract_response_steps(row["resp_body"]))
|
|
607
|
+
return steps
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _format_markdown_lines(text: str, width: int, indent: str = " ") -> list[str]:
|
|
611
|
+
"""Render markdown-ish text with terminal-friendly word wrapping."""
|
|
612
|
+
max_width = max(24, width)
|
|
613
|
+
out: list[str] = []
|
|
614
|
+
in_code = False
|
|
615
|
+
|
|
616
|
+
for raw_line in text.replace("\r\n", "\n").split("\n"):
|
|
617
|
+
line = raw_line.rstrip()
|
|
618
|
+
stripped = line.strip()
|
|
619
|
+
|
|
620
|
+
if stripped.startswith("```"):
|
|
621
|
+
out.append(f"{indent}{line}")
|
|
622
|
+
in_code = not in_code
|
|
623
|
+
continue
|
|
624
|
+
|
|
625
|
+
if in_code:
|
|
626
|
+
out.append(f"{indent}{line}")
|
|
627
|
+
continue
|
|
628
|
+
|
|
629
|
+
if not stripped:
|
|
630
|
+
out.append("")
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
if stripped.startswith("#"):
|
|
634
|
+
heading = stripped.lstrip("#").strip()
|
|
635
|
+
out.append(f"{indent}{heading.upper()}" if heading else "")
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
bullet = ""
|
|
639
|
+
content = stripped
|
|
640
|
+
if stripped.startswith(("- ", "* ", "+ ")):
|
|
641
|
+
bullet = "• "
|
|
642
|
+
content = stripped[2:].strip()
|
|
643
|
+
else:
|
|
644
|
+
match = re.match(r"^(\d+)\.\s+(.*)$", stripped)
|
|
645
|
+
if match:
|
|
646
|
+
bullet = f"{match.group(1)}. "
|
|
647
|
+
content = match.group(2).strip()
|
|
648
|
+
|
|
649
|
+
if bullet:
|
|
650
|
+
wrapped = textwrap.fill(
|
|
651
|
+
content,
|
|
652
|
+
width=max_width - len(indent) - len(bullet),
|
|
653
|
+
initial_indent=f"{indent}{bullet}",
|
|
654
|
+
subsequent_indent=f"{indent}{' ' * len(bullet)}",
|
|
655
|
+
break_long_words=False,
|
|
656
|
+
break_on_hyphens=False,
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
wrapped = textwrap.fill(
|
|
660
|
+
stripped,
|
|
661
|
+
width=max_width - len(indent),
|
|
662
|
+
initial_indent=indent,
|
|
663
|
+
subsequent_indent=indent,
|
|
664
|
+
break_long_words=False,
|
|
665
|
+
break_on_hyphens=False,
|
|
666
|
+
)
|
|
667
|
+
out.extend(wrapped.splitlines())
|
|
668
|
+
|
|
669
|
+
return out
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _conversation_lines(row: sqlite3.Row, width: int) -> list[str]:
|
|
673
|
+
steps = _extract_conversation_steps(row)
|
|
674
|
+
if not steps:
|
|
675
|
+
return []
|
|
676
|
+
|
|
677
|
+
lines = ["── Conversation Steps (Markdown) ──", ""]
|
|
678
|
+
for idx, (role, content) in enumerate(steps, 1):
|
|
679
|
+
lines.append(f"[{idx}] {role.upper()}")
|
|
680
|
+
lines.extend(_format_markdown_lines(content, width=max(24, width - 2), indent=" "))
|
|
681
|
+
lines.append("")
|
|
682
|
+
return lines
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _detail_content_lines(
|
|
686
|
+
row: sqlite3.Row,
|
|
687
|
+
*,
|
|
688
|
+
width: int,
|
|
689
|
+
max_body_lines: int,
|
|
690
|
+
) -> list[str]:
|
|
691
|
+
"""Canonical detail content shared by all viewer routes."""
|
|
692
|
+
lines: list[str] = [
|
|
693
|
+
f" ID: {row['id']}",
|
|
694
|
+
f" Time: {row['ts']}",
|
|
695
|
+
f" Caller: {_caller_label(row['caller'])}",
|
|
696
|
+
f" Method: {row['method']}",
|
|
697
|
+
f" URL: {row['scheme']}://{row['host']}:{row['port'] or '?'}{row['path']}",
|
|
698
|
+
f" Provider: {row['provider'] or '-'}",
|
|
699
|
+
f" Status: {row['status'] or '-'}",
|
|
700
|
+
f" Req size: {_format_size(row['req_bytes'])}",
|
|
701
|
+
f" Resp size:{_format_size(row['resp_bytes'])}",
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
params = _request_params(row)
|
|
705
|
+
if params:
|
|
706
|
+
lines.append("")
|
|
707
|
+
lines.append("── Request Params ──")
|
|
708
|
+
for key, value in params:
|
|
709
|
+
lines.append(f" {key}: {value}")
|
|
710
|
+
|
|
711
|
+
convo = _conversation_lines(row, width=max(24, width - 2))
|
|
712
|
+
if convo:
|
|
713
|
+
lines.append("")
|
|
714
|
+
lines.extend(convo)
|
|
715
|
+
|
|
716
|
+
lines.append("")
|
|
717
|
+
|
|
718
|
+
if row["req_body"]:
|
|
719
|
+
lines.append("── Request Body ──")
|
|
720
|
+
lines.extend(_format_body_pairs_lines(row["req_body"], width=width, max_lines=max_body_lines))
|
|
721
|
+
lines.append("")
|
|
722
|
+
|
|
723
|
+
if row["resp_body"]:
|
|
724
|
+
lines.append("── Response Body ──")
|
|
725
|
+
lines.extend(_format_body_pairs_lines(row["resp_body"], width=width, max_lines=max_body_lines))
|
|
726
|
+
lines.append("")
|
|
727
|
+
|
|
728
|
+
if not row["req_body"] and not row["resp_body"]:
|
|
729
|
+
lines.append(" (no body content recorded — address-only log entry)")
|
|
730
|
+
lines.append("")
|
|
731
|
+
|
|
732
|
+
return lines
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ── Interactive urwid viewer ─────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
def _detail_lines(row: sqlite3.Row, index: int, total: int, width: int) -> list[str]:
|
|
738
|
+
"""Build detail-view lines for interactive UIs."""
|
|
739
|
+
lines: list[str] = [f"Detail {index + 1}/{total} ID={row['id']}", ""]
|
|
740
|
+
lines.extend(_detail_content_lines(row, width=width, max_body_lines=300))
|
|
741
|
+
return lines
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _interactive_viewer_urwid(rows: list[sqlite3.Row], conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
|
745
|
+
"""urwid-based interactive traffic viewer."""
|
|
746
|
+
if urwid is None:
|
|
747
|
+
raise RuntimeError("urwid not available")
|
|
748
|
+
|
|
749
|
+
state: dict[str, Any] = {
|
|
750
|
+
"caller": args.caller or "",
|
|
751
|
+
"provider": args.provider or "",
|
|
752
|
+
"host": args.host or "",
|
|
753
|
+
"search": args.search or "",
|
|
754
|
+
"api_only": args.api,
|
|
755
|
+
"sort": {"date": "time", "address": "domain"}.get(args.sort or "time", args.sort or "time"),
|
|
756
|
+
"limit": args.limit,
|
|
757
|
+
"rows": rows,
|
|
758
|
+
"mode": "list", # list | detail | prompt_search | prompt_host
|
|
759
|
+
"detail_idx": 0,
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
palette = [
|
|
763
|
+
("title", "black", "light gray", "bold"),
|
|
764
|
+
("subtitle", "light gray", "default"),
|
|
765
|
+
("focus", "black", "light cyan"),
|
|
766
|
+
("has_data", "dark green", "default", "bold"),
|
|
767
|
+
("has_data_focus", "black", "dark green", "bold"),
|
|
768
|
+
("footer", "dark gray", "default"),
|
|
769
|
+
("md_bold", "default,bold", "default"),
|
|
770
|
+
]
|
|
771
|
+
|
|
772
|
+
title = urwid.Text("")
|
|
773
|
+
subtitle = urwid.Text("")
|
|
774
|
+
footer = urwid.Text("", align="left")
|
|
775
|
+
rows_walker = urwid.SimpleFocusListWalker([])
|
|
776
|
+
listbox = urwid.ListBox(rows_walker)
|
|
777
|
+
header = urwid.Pile([urwid.AttrMap(title, "title"), urwid.AttrMap(subtitle, "subtitle")])
|
|
778
|
+
frame = urwid.Frame(listbox, header=header, footer=urwid.AttrMap(footer, "footer"))
|
|
779
|
+
|
|
780
|
+
prompt_edit: dict[str, Any] = {"widget": None}
|
|
781
|
+
detail_listbox: dict[str, Any] = {"widget": None}
|
|
782
|
+
|
|
783
|
+
def _providers() -> list[str]:
|
|
784
|
+
vals = conn.execute(
|
|
785
|
+
"SELECT DISTINCT provider FROM traffic WHERE provider IS NOT NULL AND provider <> '' "
|
|
786
|
+
"ORDER BY provider ASC"
|
|
787
|
+
).fetchall()
|
|
788
|
+
return [""] + [str(v[0]) for v in vals if v and v[0]]
|
|
789
|
+
|
|
790
|
+
def _filters_text() -> str:
|
|
791
|
+
parts: list[str] = []
|
|
792
|
+
if state["caller"]:
|
|
793
|
+
parts.append(f"caller={state['caller']}")
|
|
794
|
+
if state["provider"]:
|
|
795
|
+
parts.append(f"provider={state['provider']}")
|
|
796
|
+
if state["host"]:
|
|
797
|
+
parts.append(f"host={state['host']}")
|
|
798
|
+
if state["search"]:
|
|
799
|
+
parts.append(f"search={state['search']}")
|
|
800
|
+
if state["api_only"]:
|
|
801
|
+
parts.append("api-only")
|
|
802
|
+
if state["sort"] != "time":
|
|
803
|
+
parts.append(f"sort={state['sort']}")
|
|
804
|
+
return " ".join(parts) if parts else "no filters"
|
|
805
|
+
|
|
806
|
+
def _row_text(row: sqlite3.Row) -> str:
|
|
807
|
+
status_str = str(row["status"]) if row["status"] else " -"
|
|
808
|
+
host_display = row["host"][:21] if len(row["host"]) > 21 else row["host"]
|
|
809
|
+
path_display = row["path"][:23] if len(row["path"]) > 23 else row["path"]
|
|
810
|
+
provider_display = row["provider"] or "-"
|
|
811
|
+
if len(provider_display) > 9:
|
|
812
|
+
provider_display = provider_display[:8] + "…"
|
|
813
|
+
api_marker = "*" if row["is_api"] else " "
|
|
814
|
+
return (
|
|
815
|
+
f"{_format_ts(row['ts']):<15} "
|
|
816
|
+
f"{_caller_label(row['caller']):<8} "
|
|
817
|
+
f"{provider_display:<10} "
|
|
818
|
+
f"{row['method']:<7} "
|
|
819
|
+
f"{status_str:>4} "
|
|
820
|
+
f"{host_display:<22} "
|
|
821
|
+
f"{path_display:<24} "
|
|
822
|
+
f"{_format_size(row['req_bytes']):>6} "
|
|
823
|
+
f"{_format_size(row['resp_bytes']):>6} {api_marker}"
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
def _refresh_rows() -> None:
|
|
827
|
+
try:
|
|
828
|
+
selected = rows_walker.focus
|
|
829
|
+
except Exception:
|
|
830
|
+
selected = 0
|
|
831
|
+
query, params = _build_query(
|
|
832
|
+
caller=state["caller"],
|
|
833
|
+
provider=state["provider"],
|
|
834
|
+
host=state["host"],
|
|
835
|
+
search=state["search"],
|
|
836
|
+
api_only=state["api_only"],
|
|
837
|
+
sort=state["sort"],
|
|
838
|
+
limit=state["limit"],
|
|
839
|
+
)
|
|
840
|
+
state["rows"] = conn.execute(query, params).fetchall()
|
|
841
|
+
del rows_walker[:]
|
|
842
|
+
for idx, row in enumerate(state["rows"]):
|
|
843
|
+
widget = urwid.SelectableIcon(_row_text(row), cursor_position=0)
|
|
844
|
+
row_attr = "has_data" if (row["req_body"] or row["resp_body"]) else None
|
|
845
|
+
row_focus = "has_data_focus" if row_attr else "focus"
|
|
846
|
+
wrapped = urwid.AttrMap(widget, row_attr, focus_map=row_focus)
|
|
847
|
+
wrapped._row_index = idx
|
|
848
|
+
rows_walker.append(wrapped)
|
|
849
|
+
if state["rows"]:
|
|
850
|
+
rows_walker.set_focus(min(selected, len(state["rows"]) - 1))
|
|
851
|
+
title.set_text(f" ai-cli Traffic Viewer ({len(state['rows'])} rows)")
|
|
852
|
+
subtitle.set_text(
|
|
853
|
+
"Time Caller Prov Method St Host "
|
|
854
|
+
"Path Req Resp"
|
|
855
|
+
)
|
|
856
|
+
footer.set_text(
|
|
857
|
+
f"↑↓ nav Enter detail / search h host c caller p provider a api s sort q quit"
|
|
858
|
+
f" [{_filters_text()}]"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
def _show_list() -> None:
|
|
862
|
+
state["mode"] = "list"
|
|
863
|
+
frame.body = listbox
|
|
864
|
+
frame.footer = urwid.AttrMap(footer, "footer")
|
|
865
|
+
_refresh_rows()
|
|
866
|
+
|
|
867
|
+
def _show_detail(index: int) -> None:
|
|
868
|
+
if not state["rows"]:
|
|
869
|
+
return
|
|
870
|
+
index = max(0, min(index, len(state["rows"]) - 1))
|
|
871
|
+
state["mode"] = "detail"
|
|
872
|
+
state["detail_idx"] = index
|
|
873
|
+
row = state["rows"][index]
|
|
874
|
+
width = max(24, shutil.get_terminal_size((120, 40)).columns - 2)
|
|
875
|
+
lines = _detail_lines(row, index, len(state["rows"]), width=width)
|
|
876
|
+
widgets: list[urwid.Widget] = []
|
|
877
|
+
for line in lines:
|
|
878
|
+
text, is_bold = _markdown_line_style(line)
|
|
879
|
+
w: urwid.Widget = urwid.Text(text, wrap="space")
|
|
880
|
+
if is_bold:
|
|
881
|
+
w = urwid.AttrMap(w, "md_bold")
|
|
882
|
+
widgets.append(w)
|
|
883
|
+
walker = urwid.SimpleFocusListWalker(widgets)
|
|
884
|
+
detail_listbox["widget"] = urwid.ListBox(walker)
|
|
885
|
+
frame.body = detail_listbox["widget"]
|
|
886
|
+
title.set_text(f" Traffic Detail {index + 1}/{len(state['rows'])}")
|
|
887
|
+
subtitle.set_text(f"#{row['id']} {row['method']} {row['host']}{row['path'][:40]}")
|
|
888
|
+
footer.set_text("↑↓ scroll ←/→ prev-next request q/Esc back")
|
|
889
|
+
|
|
890
|
+
def _start_prompt(kind: str, caption: str, initial: str) -> None:
|
|
891
|
+
state["mode"] = kind
|
|
892
|
+
edit = urwid.Edit(caption, edit_text=initial)
|
|
893
|
+
prompt_edit["widget"] = edit
|
|
894
|
+
frame.footer = urwid.AttrMap(edit, "focus")
|
|
895
|
+
|
|
896
|
+
def _apply_prompt_value(value: str) -> None:
|
|
897
|
+
if state["mode"] == "prompt_search":
|
|
898
|
+
state["search"] = value
|
|
899
|
+
elif state["mode"] == "prompt_host":
|
|
900
|
+
state["host"] = value
|
|
901
|
+
_show_list()
|
|
902
|
+
|
|
903
|
+
def _unhandled_input(key: str) -> None:
|
|
904
|
+
if state["mode"].startswith("prompt_"):
|
|
905
|
+
if key == "enter":
|
|
906
|
+
edit = prompt_edit["widget"]
|
|
907
|
+
value = edit.edit_text.strip() if edit is not None else ""
|
|
908
|
+
_apply_prompt_value(value)
|
|
909
|
+
return
|
|
910
|
+
if key in ("esc",):
|
|
911
|
+
_show_list()
|
|
912
|
+
return
|
|
913
|
+
return
|
|
914
|
+
|
|
915
|
+
if state["mode"] == "detail":
|
|
916
|
+
if key in ("q", "Q", "esc"):
|
|
917
|
+
_show_list()
|
|
918
|
+
return
|
|
919
|
+
if key in ("left", "h"):
|
|
920
|
+
_show_detail(state["detail_idx"] - 1)
|
|
921
|
+
return
|
|
922
|
+
if key in ("right", "l"):
|
|
923
|
+
_show_detail(state["detail_idx"] + 1)
|
|
924
|
+
return
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
# List mode
|
|
928
|
+
if key in ("q", "Q"):
|
|
929
|
+
raise urwid.ExitMainLoop()
|
|
930
|
+
if key == "enter":
|
|
931
|
+
if state["rows"]:
|
|
932
|
+
_show_detail(rows_walker.focus)
|
|
933
|
+
return
|
|
934
|
+
if key == "/":
|
|
935
|
+
_start_prompt("prompt_search", "Search body/path/host/provider/id: ", state["search"])
|
|
936
|
+
return
|
|
937
|
+
if key == "h":
|
|
938
|
+
_start_prompt("prompt_host", "Filter host: ", state["host"])
|
|
939
|
+
return
|
|
940
|
+
if key == "c":
|
|
941
|
+
callers = ["", "claude", "copilot", "codex", "gemini"]
|
|
942
|
+
try:
|
|
943
|
+
idx = callers.index(state["caller"])
|
|
944
|
+
except ValueError:
|
|
945
|
+
idx = 0
|
|
946
|
+
state["caller"] = callers[(idx + 1) % len(callers)]
|
|
947
|
+
_refresh_rows()
|
|
948
|
+
return
|
|
949
|
+
if key == "p":
|
|
950
|
+
providers = _providers()
|
|
951
|
+
try:
|
|
952
|
+
idx = providers.index(state["provider"])
|
|
953
|
+
except ValueError:
|
|
954
|
+
idx = 0
|
|
955
|
+
state["provider"] = providers[(idx + 1) % len(providers)]
|
|
956
|
+
_refresh_rows()
|
|
957
|
+
return
|
|
958
|
+
if key == "a":
|
|
959
|
+
state["api_only"] = not state["api_only"]
|
|
960
|
+
_refresh_rows()
|
|
961
|
+
return
|
|
962
|
+
if key == "s":
|
|
963
|
+
try:
|
|
964
|
+
idx = _SORT_MODES.index(state["sort"])
|
|
965
|
+
except ValueError:
|
|
966
|
+
idx = 0
|
|
967
|
+
state["sort"] = _SORT_MODES[(idx + 1) % len(_SORT_MODES)]
|
|
968
|
+
_refresh_rows()
|
|
969
|
+
return
|
|
970
|
+
if key == "r":
|
|
971
|
+
_refresh_rows()
|
|
972
|
+
return
|
|
973
|
+
|
|
974
|
+
_refresh_rows()
|
|
975
|
+
loop = urwid.MainLoop(frame, palette=palette, unhandled_input=_unhandled_input)
|
|
976
|
+
loop.run()
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
# ── Interactive curses viewer ─────────────────────────────────────────
|
|
980
|
+
|
|
981
|
+
def _init_colors() -> None:
|
|
982
|
+
global _COLOR_ENABLED
|
|
983
|
+
_COLOR_ENABLED = False
|
|
984
|
+
try:
|
|
985
|
+
if not curses.has_colors():
|
|
986
|
+
return
|
|
987
|
+
curses.start_color()
|
|
988
|
+
curses.use_default_colors()
|
|
989
|
+
curses.init_pair(1, curses.COLOR_MAGENTA, -1) # claude
|
|
990
|
+
curses.init_pair(2, curses.COLOR_CYAN, -1) # copilot
|
|
991
|
+
curses.init_pair(3, curses.COLOR_GREEN, -1) # codex
|
|
992
|
+
curses.init_pair(4, curses.COLOR_YELLOW, -1) # gemini
|
|
993
|
+
curses.init_pair(5, curses.COLOR_RED, -1) # error status
|
|
994
|
+
curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLUE) # selected row
|
|
995
|
+
curses.init_pair(7, curses.COLOR_WHITE, -1) # header
|
|
996
|
+
curses.init_pair(8, curses.COLOR_GREEN, -1) # rows with body data
|
|
997
|
+
_COLOR_ENABLED = True
|
|
998
|
+
except curses.error:
|
|
999
|
+
_COLOR_ENABLED = False
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _cp(pair: int) -> int:
|
|
1003
|
+
if not _COLOR_ENABLED:
|
|
1004
|
+
return 0
|
|
1005
|
+
try:
|
|
1006
|
+
return curses.color_pair(pair)
|
|
1007
|
+
except curses.error:
|
|
1008
|
+
return 0
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _caller_attr(caller: str) -> int:
|
|
1012
|
+
pair = _CALLER_COLORS.get(caller, 0)
|
|
1013
|
+
return _cp(pair) | curses.A_BOLD if pair else curses.A_DIM
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _addnstr_safe(
|
|
1017
|
+
stdscr: curses.window,
|
|
1018
|
+
y: int,
|
|
1019
|
+
x: int,
|
|
1020
|
+
text: str,
|
|
1021
|
+
attr: int = 0,
|
|
1022
|
+
) -> None:
|
|
1023
|
+
"""Best-effort addnstr that avoids lower-right corner curses errors."""
|
|
1024
|
+
height, width = stdscr.getmaxyx()
|
|
1025
|
+
if y < 0 or y >= height or x < 0 or x >= width:
|
|
1026
|
+
return
|
|
1027
|
+
|
|
1028
|
+
max_chars = width - x
|
|
1029
|
+
# Writing to the lower-right corner can raise curses.error even when valid.
|
|
1030
|
+
if y == height - 1:
|
|
1031
|
+
max_chars -= 1
|
|
1032
|
+
if max_chars <= 0:
|
|
1033
|
+
return
|
|
1034
|
+
|
|
1035
|
+
try:
|
|
1036
|
+
stdscr.addnstr(y, x, text, max_chars, attr)
|
|
1037
|
+
except curses.error:
|
|
1038
|
+
pass
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _draw_list(
|
|
1042
|
+
stdscr: curses.window,
|
|
1043
|
+
rows: list[sqlite3.Row],
|
|
1044
|
+
selected: int,
|
|
1045
|
+
scroll_offset: int,
|
|
1046
|
+
filter_text: str,
|
|
1047
|
+
) -> None:
|
|
1048
|
+
stdscr.erase()
|
|
1049
|
+
height, width = stdscr.getmaxyx()
|
|
1050
|
+
|
|
1051
|
+
# Title bar
|
|
1052
|
+
title = " ai-cli Traffic Viewer"
|
|
1053
|
+
if filter_text:
|
|
1054
|
+
title += f" [filter: {filter_text}]"
|
|
1055
|
+
title += f" ({len(rows)} rows)"
|
|
1056
|
+
_addnstr_safe(stdscr, 0, 0, title.ljust(width), curses.A_BOLD | _cp(7))
|
|
1057
|
+
|
|
1058
|
+
# Help line
|
|
1059
|
+
help_text = " ↑↓ nav Enter detail / search c caller p provider a api s sort q quit"
|
|
1060
|
+
_addnstr_safe(stdscr, 1, 0, help_text.ljust(width), curses.A_DIM)
|
|
1061
|
+
|
|
1062
|
+
# Column header
|
|
1063
|
+
hdr = (
|
|
1064
|
+
f"{'Time':<15} {'Caller':<8} {'Prov':<10} {'Method':<7} {'St':>4} "
|
|
1065
|
+
f"{'Host':<22} {'Path':<24} {'Req':>6} {'Resp':>6}"
|
|
1066
|
+
)
|
|
1067
|
+
_addnstr_safe(stdscr, 2, 0, hdr[:width], curses.A_UNDERLINE)
|
|
1068
|
+
|
|
1069
|
+
# Rows
|
|
1070
|
+
list_height = height - 4
|
|
1071
|
+
for i in range(list_height):
|
|
1072
|
+
row_idx = scroll_offset + i
|
|
1073
|
+
if row_idx >= len(rows):
|
|
1074
|
+
break
|
|
1075
|
+
r = rows[row_idx]
|
|
1076
|
+
y = 3 + i
|
|
1077
|
+
|
|
1078
|
+
is_selected = row_idx == selected
|
|
1079
|
+
has_data = bool(r["req_body"] or r["resp_body"])
|
|
1080
|
+
base_attr = _cp(6) if is_selected else curses.A_NORMAL
|
|
1081
|
+
if has_data and not is_selected:
|
|
1082
|
+
base_attr = _cp(8) | curses.A_BOLD
|
|
1083
|
+
|
|
1084
|
+
status_str = str(r["status"]) if r["status"] else " -"
|
|
1085
|
+
host_display = r["host"][:21] if len(r["host"]) > 21 else r["host"]
|
|
1086
|
+
path_display = r["path"][:23] if len(r["path"]) > 23 else r["path"]
|
|
1087
|
+
provider_display = (r["provider"] or "-")
|
|
1088
|
+
if len(provider_display) > 9:
|
|
1089
|
+
provider_display = provider_display[:8] + "…"
|
|
1090
|
+
api_marker = "●" if r["is_api"] else " "
|
|
1091
|
+
|
|
1092
|
+
line = (
|
|
1093
|
+
f"{_format_ts(r['ts']):<15} "
|
|
1094
|
+
f"{_caller_label(r['caller']):<8} "
|
|
1095
|
+
f"{provider_display:<10} "
|
|
1096
|
+
f"{r['method']:<7} "
|
|
1097
|
+
f"{status_str:>4} "
|
|
1098
|
+
f"{host_display:<22} "
|
|
1099
|
+
f"{path_display:<24} "
|
|
1100
|
+
f"{_format_size(r['req_bytes']):>6} "
|
|
1101
|
+
f"{_format_size(r['resp_bytes']):>6} {api_marker}"
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
if is_selected:
|
|
1105
|
+
_addnstr_safe(stdscr, y, 0, line.ljust(width)[:width], base_attr)
|
|
1106
|
+
else:
|
|
1107
|
+
_addnstr_safe(stdscr, y, 0, line[:width], base_attr)
|
|
1108
|
+
|
|
1109
|
+
# Status bar
|
|
1110
|
+
if rows:
|
|
1111
|
+
pos_text = f" Row {selected + 1}/{len(rows)}"
|
|
1112
|
+
_addnstr_safe(stdscr, height - 1, 0, pos_text.ljust(width), curses.A_REVERSE)
|
|
1113
|
+
|
|
1114
|
+
stdscr.refresh()
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _draw_detail(
|
|
1118
|
+
stdscr: curses.window,
|
|
1119
|
+
row: sqlite3.Row,
|
|
1120
|
+
scroll: int,
|
|
1121
|
+
index: int,
|
|
1122
|
+
total: int,
|
|
1123
|
+
) -> int:
|
|
1124
|
+
stdscr.erase()
|
|
1125
|
+
height, width = stdscr.getmaxyx()
|
|
1126
|
+
|
|
1127
|
+
lines = _detail_content_lines(row, width=max(24, width - 2), max_body_lines=200)
|
|
1128
|
+
|
|
1129
|
+
# Title
|
|
1130
|
+
title = f" Detail {index + 1}/{total}: #{row['id']} {row['method']} {row['host']}{row['path'][:28]}"
|
|
1131
|
+
_addnstr_safe(stdscr, 0, 0, title.ljust(width)[:width], curses.A_BOLD | curses.A_REVERSE)
|
|
1132
|
+
|
|
1133
|
+
# Content with scroll
|
|
1134
|
+
view_height = height - 2
|
|
1135
|
+
for i in range(view_height):
|
|
1136
|
+
line_idx = scroll + i
|
|
1137
|
+
if line_idx >= len(lines):
|
|
1138
|
+
break
|
|
1139
|
+
text, is_bold = _markdown_line_style(lines[line_idx])
|
|
1140
|
+
attr = curses.A_BOLD if is_bold else 0
|
|
1141
|
+
_addnstr_safe(stdscr, 1 + i, 0, text[:width], attr)
|
|
1142
|
+
|
|
1143
|
+
help_text = " ↑↓ scroll ←/→ prev-next request q/Esc back"
|
|
1144
|
+
_addnstr_safe(stdscr, height - 1, 0, help_text.ljust(width)[:width], curses.A_DIM)
|
|
1145
|
+
|
|
1146
|
+
stdscr.refresh()
|
|
1147
|
+
return len(lines)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _curses_prompt(stdscr: curses.window, prompt: str) -> str:
|
|
1151
|
+
"""Show a prompt on the bottom line and read text input."""
|
|
1152
|
+
height, width = stdscr.getmaxyx()
|
|
1153
|
+
_addnstr_safe(stdscr, height - 1, 0, prompt.ljust(width)[:width], curses.A_REVERSE)
|
|
1154
|
+
stdscr.refresh()
|
|
1155
|
+
curses.echo()
|
|
1156
|
+
try:
|
|
1157
|
+
curses.curs_set(1)
|
|
1158
|
+
except curses.error:
|
|
1159
|
+
pass
|
|
1160
|
+
try:
|
|
1161
|
+
buf = stdscr.getstr(height - 1, len(prompt), width - len(prompt) - 1)
|
|
1162
|
+
return buf.decode("utf-8", errors="replace").strip()
|
|
1163
|
+
except Exception:
|
|
1164
|
+
return ""
|
|
1165
|
+
finally:
|
|
1166
|
+
curses.noecho()
|
|
1167
|
+
try:
|
|
1168
|
+
curses.curs_set(0)
|
|
1169
|
+
except curses.error:
|
|
1170
|
+
pass
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _read_key(stdscr: curses.window) -> int:
|
|
1174
|
+
"""Read one key, normalizing common ESC arrow sequences."""
|
|
1175
|
+
key = stdscr.getch()
|
|
1176
|
+
if key != 27:
|
|
1177
|
+
return key
|
|
1178
|
+
|
|
1179
|
+
stdscr.nodelay(True)
|
|
1180
|
+
try:
|
|
1181
|
+
nxt = stdscr.getch()
|
|
1182
|
+
if nxt == -1:
|
|
1183
|
+
return 27
|
|
1184
|
+
if nxt == 91: # '['
|
|
1185
|
+
final = stdscr.getch()
|
|
1186
|
+
if final == 65:
|
|
1187
|
+
return curses.KEY_UP
|
|
1188
|
+
if final == 66:
|
|
1189
|
+
return curses.KEY_DOWN
|
|
1190
|
+
if final == 67:
|
|
1191
|
+
return curses.KEY_RIGHT
|
|
1192
|
+
if final == 68:
|
|
1193
|
+
return curses.KEY_LEFT
|
|
1194
|
+
return 27
|
|
1195
|
+
finally:
|
|
1196
|
+
stdscr.nodelay(False)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _interactive_viewer(rows: list[sqlite3.Row], conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
|
1200
|
+
"""Curses-based interactive traffic viewer."""
|
|
1201
|
+
|
|
1202
|
+
# Mutable state for re-querying
|
|
1203
|
+
state = {
|
|
1204
|
+
"caller": args.caller or "",
|
|
1205
|
+
"provider": args.provider or "",
|
|
1206
|
+
"host": args.host or "",
|
|
1207
|
+
"search": args.search or "",
|
|
1208
|
+
"api_only": args.api,
|
|
1209
|
+
"sort": {"date": "time", "address": "domain"}.get(args.sort or "time", args.sort or "time"),
|
|
1210
|
+
"limit": args.limit,
|
|
1211
|
+
"rows": rows,
|
|
1212
|
+
"selected": 0,
|
|
1213
|
+
"scroll_offset": 0,
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
def _requery() -> None:
|
|
1217
|
+
query, params = _build_query(
|
|
1218
|
+
caller=state["caller"],
|
|
1219
|
+
provider=state["provider"],
|
|
1220
|
+
host=state["host"],
|
|
1221
|
+
search=state["search"],
|
|
1222
|
+
api_only=state["api_only"],
|
|
1223
|
+
sort=state["sort"],
|
|
1224
|
+
limit=state["limit"],
|
|
1225
|
+
)
|
|
1226
|
+
state["rows"] = conn.execute(query, params).fetchall()
|
|
1227
|
+
state["selected"] = 0
|
|
1228
|
+
state["scroll_offset"] = 0
|
|
1229
|
+
|
|
1230
|
+
def _filter_label() -> str:
|
|
1231
|
+
parts = []
|
|
1232
|
+
if state["caller"]:
|
|
1233
|
+
parts.append(f"caller={state['caller']}")
|
|
1234
|
+
if state["host"]:
|
|
1235
|
+
parts.append(f"host={state['host']}")
|
|
1236
|
+
if state["search"]:
|
|
1237
|
+
parts.append(f"search={state['search']}")
|
|
1238
|
+
if state["provider"]:
|
|
1239
|
+
parts.append(f"provider={state['provider']}")
|
|
1240
|
+
if state["api_only"]:
|
|
1241
|
+
parts.append("api-only")
|
|
1242
|
+
if state["sort"] != "time":
|
|
1243
|
+
parts.append(f"sort={state['sort']}")
|
|
1244
|
+
return " ".join(parts)
|
|
1245
|
+
|
|
1246
|
+
def _providers() -> list[str]:
|
|
1247
|
+
rows = conn.execute(
|
|
1248
|
+
"SELECT DISTINCT provider FROM traffic WHERE provider IS NOT NULL AND provider <> '' "
|
|
1249
|
+
"ORDER BY provider ASC"
|
|
1250
|
+
).fetchall()
|
|
1251
|
+
vals = [str(r[0]) for r in rows if r and r[0]]
|
|
1252
|
+
return ["", *vals]
|
|
1253
|
+
|
|
1254
|
+
def _inner(stdscr: curses.window) -> None:
|
|
1255
|
+
_init_colors()
|
|
1256
|
+
try:
|
|
1257
|
+
curses.curs_set(0)
|
|
1258
|
+
except curses.error:
|
|
1259
|
+
pass
|
|
1260
|
+
stdscr.keypad(True)
|
|
1261
|
+
stdscr.timeout(-1)
|
|
1262
|
+
|
|
1263
|
+
while True:
|
|
1264
|
+
current_rows = state["rows"]
|
|
1265
|
+
height, width = stdscr.getmaxyx()
|
|
1266
|
+
list_height = height - 4
|
|
1267
|
+
|
|
1268
|
+
# Ensure selected is in bounds
|
|
1269
|
+
if current_rows:
|
|
1270
|
+
state["selected"] = max(0, min(state["selected"], len(current_rows) - 1))
|
|
1271
|
+
else:
|
|
1272
|
+
state["selected"] = 0
|
|
1273
|
+
|
|
1274
|
+
# Auto-scroll to keep selected visible
|
|
1275
|
+
if state["selected"] < state["scroll_offset"]:
|
|
1276
|
+
state["scroll_offset"] = state["selected"]
|
|
1277
|
+
elif state["selected"] >= state["scroll_offset"] + list_height:
|
|
1278
|
+
state["scroll_offset"] = state["selected"] - list_height + 1
|
|
1279
|
+
|
|
1280
|
+
_draw_list(stdscr, current_rows, state["selected"], state["scroll_offset"], _filter_label())
|
|
1281
|
+
|
|
1282
|
+
key = _read_key(stdscr)
|
|
1283
|
+
|
|
1284
|
+
if key in (ord("q"), 27):
|
|
1285
|
+
return
|
|
1286
|
+
elif key in (curses.KEY_UP, ord("k")):
|
|
1287
|
+
state["selected"] = max(0, state["selected"] - 1)
|
|
1288
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1289
|
+
if current_rows:
|
|
1290
|
+
state["selected"] = min(len(current_rows) - 1, state["selected"] + 1)
|
|
1291
|
+
elif key == curses.KEY_PPAGE:
|
|
1292
|
+
state["selected"] = max(0, state["selected"] - list_height)
|
|
1293
|
+
elif key == curses.KEY_NPAGE:
|
|
1294
|
+
if current_rows:
|
|
1295
|
+
state["selected"] = min(len(current_rows) - 1, state["selected"] + list_height)
|
|
1296
|
+
elif key == curses.KEY_HOME:
|
|
1297
|
+
state["selected"] = 0
|
|
1298
|
+
elif key == curses.KEY_END:
|
|
1299
|
+
if current_rows:
|
|
1300
|
+
state["selected"] = len(current_rows) - 1
|
|
1301
|
+
elif key in (10, 13, curses.KEY_ENTER):
|
|
1302
|
+
# Detail view
|
|
1303
|
+
if current_rows:
|
|
1304
|
+
detail_idx = state["selected"]
|
|
1305
|
+
row = current_rows[detail_idx]
|
|
1306
|
+
detail_scroll = 0
|
|
1307
|
+
while True:
|
|
1308
|
+
total_lines = _draw_detail(
|
|
1309
|
+
stdscr, row, detail_scroll, detail_idx, len(current_rows)
|
|
1310
|
+
)
|
|
1311
|
+
dk = _read_key(stdscr)
|
|
1312
|
+
if dk in (ord("q"), 27):
|
|
1313
|
+
break
|
|
1314
|
+
elif dk in (curses.KEY_UP, ord("k")):
|
|
1315
|
+
detail_scroll = max(0, detail_scroll - 1)
|
|
1316
|
+
elif dk in (curses.KEY_DOWN, ord("j")):
|
|
1317
|
+
detail_scroll = min(max(0, total_lines - (height - 2)), detail_scroll + 1)
|
|
1318
|
+
elif dk == curses.KEY_PPAGE:
|
|
1319
|
+
detail_scroll = max(0, detail_scroll - (height - 3))
|
|
1320
|
+
elif dk == curses.KEY_NPAGE:
|
|
1321
|
+
detail_scroll = min(max(0, total_lines - (height - 2)), detail_scroll + (height - 3))
|
|
1322
|
+
elif dk in (curses.KEY_LEFT, ord("h")):
|
|
1323
|
+
if detail_idx > 0:
|
|
1324
|
+
detail_idx -= 1
|
|
1325
|
+
row = current_rows[detail_idx]
|
|
1326
|
+
detail_scroll = 0
|
|
1327
|
+
state["selected"] = detail_idx
|
|
1328
|
+
elif dk in (curses.KEY_RIGHT, ord("l")):
|
|
1329
|
+
if detail_idx < len(current_rows) - 1:
|
|
1330
|
+
detail_idx += 1
|
|
1331
|
+
row = current_rows[detail_idx]
|
|
1332
|
+
detail_scroll = 0
|
|
1333
|
+
state["selected"] = detail_idx
|
|
1334
|
+
elif key == ord("/"):
|
|
1335
|
+
# Search prompt
|
|
1336
|
+
text = _curses_prompt(stdscr, "Search body/path: ")
|
|
1337
|
+
state["search"] = text
|
|
1338
|
+
_requery()
|
|
1339
|
+
elif key == ord("c"):
|
|
1340
|
+
# Cycle caller filter
|
|
1341
|
+
callers = ["", "claude", "copilot", "codex", "gemini"]
|
|
1342
|
+
try:
|
|
1343
|
+
idx = callers.index(state["caller"])
|
|
1344
|
+
except ValueError:
|
|
1345
|
+
idx = 0
|
|
1346
|
+
state["caller"] = callers[(idx + 1) % len(callers)]
|
|
1347
|
+
_requery()
|
|
1348
|
+
elif key == ord("p"):
|
|
1349
|
+
# Cycle provider filter
|
|
1350
|
+
providers = _providers()
|
|
1351
|
+
try:
|
|
1352
|
+
idx = providers.index(state["provider"])
|
|
1353
|
+
except ValueError:
|
|
1354
|
+
idx = 0
|
|
1355
|
+
state["provider"] = providers[(idx + 1) % len(providers)]
|
|
1356
|
+
_requery()
|
|
1357
|
+
elif key == ord("h"):
|
|
1358
|
+
# Host filter prompt
|
|
1359
|
+
text = _curses_prompt(stdscr, "Filter host: ")
|
|
1360
|
+
state["host"] = text
|
|
1361
|
+
_requery()
|
|
1362
|
+
elif key == ord("a"):
|
|
1363
|
+
# Toggle API-only
|
|
1364
|
+
state["api_only"] = not state["api_only"]
|
|
1365
|
+
_requery()
|
|
1366
|
+
elif key == ord("s"):
|
|
1367
|
+
# Cycle sort mode
|
|
1368
|
+
try:
|
|
1369
|
+
idx = _SORT_MODES.index(state["sort"])
|
|
1370
|
+
except ValueError:
|
|
1371
|
+
idx = 0
|
|
1372
|
+
state["sort"] = _SORT_MODES[(idx + 1) % len(_SORT_MODES)]
|
|
1373
|
+
_requery()
|
|
1374
|
+
elif key == ord("r"):
|
|
1375
|
+
# Refresh
|
|
1376
|
+
_requery()
|
|
1377
|
+
|
|
1378
|
+
curses.wrapper(_inner)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
# ── CLI entry point ───────────────────────────────────────────────────
|
|
1382
|
+
|
|
1383
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1384
|
+
parser = argparse.ArgumentParser(
|
|
1385
|
+
prog="ai-cli traffic",
|
|
1386
|
+
description="Browse and search proxied API traffic.",
|
|
1387
|
+
)
|
|
1388
|
+
parser.add_argument(
|
|
1389
|
+
"--caller", "-c",
|
|
1390
|
+
choices=["claude", "copilot", "codex", "gemini"],
|
|
1391
|
+
help="Filter by caller tool",
|
|
1392
|
+
)
|
|
1393
|
+
parser.add_argument(
|
|
1394
|
+
"--host",
|
|
1395
|
+
help="Filter by host substring",
|
|
1396
|
+
)
|
|
1397
|
+
parser.add_argument(
|
|
1398
|
+
"--provider",
|
|
1399
|
+
help="Filter by provider (e.g. anthropic/openai/copilot/google)",
|
|
1400
|
+
)
|
|
1401
|
+
parser.add_argument(
|
|
1402
|
+
"--search", "-s",
|
|
1403
|
+
help="Search in request/response bodies and paths",
|
|
1404
|
+
)
|
|
1405
|
+
parser.add_argument(
|
|
1406
|
+
"--api", "-a",
|
|
1407
|
+
action="store_true",
|
|
1408
|
+
help="Show only confirmed API calls (with body content)",
|
|
1409
|
+
)
|
|
1410
|
+
parser.add_argument(
|
|
1411
|
+
"--sort",
|
|
1412
|
+
choices=["time", "domain", "request", "provider", "date", "address"],
|
|
1413
|
+
default="time",
|
|
1414
|
+
help="Sort order (default: time)",
|
|
1415
|
+
)
|
|
1416
|
+
parser.add_argument(
|
|
1417
|
+
"--limit", "-n",
|
|
1418
|
+
type=int,
|
|
1419
|
+
default=100,
|
|
1420
|
+
help="Maximum rows to show (default: 100)",
|
|
1421
|
+
)
|
|
1422
|
+
parser.add_argument(
|
|
1423
|
+
"--db",
|
|
1424
|
+
type=Path,
|
|
1425
|
+
default=_DEFAULT_DB_PATH,
|
|
1426
|
+
help="Path to traffic database",
|
|
1427
|
+
)
|
|
1428
|
+
parser.add_argument(
|
|
1429
|
+
"--no-interactive", "--plain",
|
|
1430
|
+
action="store_true",
|
|
1431
|
+
dest="plain",
|
|
1432
|
+
help="Plain text output (no curses)",
|
|
1433
|
+
)
|
|
1434
|
+
parser.add_argument(
|
|
1435
|
+
"--detail", "-d",
|
|
1436
|
+
type=int,
|
|
1437
|
+
metavar="ID",
|
|
1438
|
+
help="Show detail for a specific row ID",
|
|
1439
|
+
)
|
|
1440
|
+
return parser
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1444
|
+
parser = _build_parser()
|
|
1445
|
+
args = parser.parse_args(argv)
|
|
1446
|
+
|
|
1447
|
+
conn = _connect(args.db)
|
|
1448
|
+
|
|
1449
|
+
# Single row detail mode
|
|
1450
|
+
if args.detail:
|
|
1451
|
+
caller_sel = "caller" if _traffic_db.HAS_CALLER_COL else "'' AS caller"
|
|
1452
|
+
port_sel = "port" if _traffic_db.HAS_PORT_COL else "NULL AS port"
|
|
1453
|
+
row = conn.execute(
|
|
1454
|
+
f"SELECT id, ts, {caller_sel}, method, scheme, host, {port_sel}, path, "
|
|
1455
|
+
"provider, is_api, status, req_bytes, resp_bytes, "
|
|
1456
|
+
"req_body, resp_body FROM traffic WHERE id = ?",
|
|
1457
|
+
(args.detail,),
|
|
1458
|
+
).fetchone()
|
|
1459
|
+
if not row:
|
|
1460
|
+
print(f"No traffic row with id={args.detail}", file=sys.stderr)
|
|
1461
|
+
conn.close()
|
|
1462
|
+
return 1
|
|
1463
|
+
_print_detail(row)
|
|
1464
|
+
conn.close()
|
|
1465
|
+
return 0
|
|
1466
|
+
|
|
1467
|
+
# Query
|
|
1468
|
+
query, params = _build_query(
|
|
1469
|
+
caller=args.caller or "",
|
|
1470
|
+
provider=args.provider or "",
|
|
1471
|
+
host=args.host or "",
|
|
1472
|
+
search=args.search or "",
|
|
1473
|
+
api_only=args.api,
|
|
1474
|
+
sort={"date": "time", "address": "domain"}.get(args.sort, args.sort),
|
|
1475
|
+
limit=args.limit,
|
|
1476
|
+
)
|
|
1477
|
+
rows = conn.execute(query, params).fetchall()
|
|
1478
|
+
|
|
1479
|
+
# Interactive or plain
|
|
1480
|
+
use_interactive = (
|
|
1481
|
+
not args.plain
|
|
1482
|
+
and sys.stdin.isatty()
|
|
1483
|
+
and sys.stdout.isatty()
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
if use_interactive:
|
|
1487
|
+
used_interactive = False
|
|
1488
|
+
if urwid is not None:
|
|
1489
|
+
try:
|
|
1490
|
+
_interactive_viewer_urwid(rows, conn, args)
|
|
1491
|
+
used_interactive = True
|
|
1492
|
+
except Exception:
|
|
1493
|
+
used_interactive = False
|
|
1494
|
+
if not used_interactive:
|
|
1495
|
+
try:
|
|
1496
|
+
_interactive_viewer(rows, conn, args)
|
|
1497
|
+
used_interactive = True
|
|
1498
|
+
except curses.error:
|
|
1499
|
+
used_interactive = False
|
|
1500
|
+
if not used_interactive:
|
|
1501
|
+
_print_table(rows)
|
|
1502
|
+
if rows:
|
|
1503
|
+
print(f"\nUse 'ai-cli traffic --detail ID' to view full request/response.")
|
|
1504
|
+
else:
|
|
1505
|
+
_print_table(rows)
|
|
1506
|
+
if rows:
|
|
1507
|
+
print(f"\nUse 'ai-cli traffic --detail ID' to view full request/response.")
|
|
1508
|
+
|
|
1509
|
+
conn.close()
|
|
1510
|
+
return 0
|