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/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