clicodelog 0.1.0__py3-none-any.whl → 0.2.1__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.
clicodelog/__init__.py CHANGED
@@ -2,5 +2,5 @@
2
2
  CLI Code Log - Browse, inspect, and export logs from CLI-based AI coding agents.
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.1"
6
6
  __author__ = "monk1337"
clicodelog/app.py CHANGED
@@ -1,21 +1,29 @@
1
+ #!/usr/bin/env python3
1
2
  """
2
- cli code log
3
- A web app to browse, inspect, and export logs from CLI-based AI coding agents.
4
- Data is copied from source directories to ~/.clicodelog/data/ for backup and local use.
3
+ AI Conversation History Viewer
4
+ A web app to browse and view Claude Code, OpenAI Codex, and Google Gemini conversation history.
5
+ Data is copied from source directories to ./data/ for backup and local use.
5
6
  Background sync runs every hour to keep data updated.
6
7
  """
7
8
 
8
- import base64
9
9
  import json
10
10
  import os
11
11
  import shutil
12
+ import signal
13
+ import subprocess
12
14
  import threading
13
15
  import time
16
+ import webbrowser
14
17
  from datetime import datetime
15
18
  from pathlib import Path
19
+ from typing import Optional
16
20
 
17
- from flask import Flask, Response, jsonify, render_template, request
18
- from flask_cors import CORS
21
+ import uvicorn
22
+ from fastapi import FastAPI, Request
23
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
24
+ from fastapi.staticfiles import StaticFiles
25
+ from fastapi.templating import Jinja2Templates
26
+ from fastapi.middleware.cors import CORSMiddleware
19
27
 
20
28
  # Package directory for templates
21
29
  PACKAGE_DIR = Path(__file__).parent
@@ -51,13 +59,9 @@ sync_lock = threading.Lock()
51
59
  last_sync_time = {} # Track per-source sync times
52
60
  current_source = "claude-code" # Default source
53
61
 
54
- # Create Flask app with template folder from package
55
- app = Flask(__name__, template_folder=str(PACKAGE_DIR / "templates"))
56
- CORS(app)
57
-
58
62
 
59
63
  def sync_data(source_id=None, silent=False):
60
- """Copy data from source directory to data dir for backup."""
64
+ """Copy data from source directory to ./data/{source}/ for backup."""
61
65
  global last_sync_time
62
66
 
63
67
  if source_id is None:
@@ -79,7 +83,7 @@ def sync_data(source_id=None, silent=False):
79
83
  return False
80
84
 
81
85
  # Create data directory if it doesn't exist
82
- DATA_DIR.mkdir(parents=True, exist_ok=True)
86
+ DATA_DIR.mkdir(exist_ok=True)
83
87
 
84
88
  # Copy source directory
85
89
  if not silent:
@@ -156,11 +160,13 @@ def background_sync():
156
160
 
157
161
  def encode_path_id(path):
158
162
  """Encode a path as a safe ID using base64."""
163
+ import base64
159
164
  return base64.urlsafe_b64encode(path.encode()).decode().rstrip('=')
160
165
 
161
166
 
162
167
  def decode_path_id(encoded_id):
163
168
  """Decode a base64-encoded path ID."""
169
+ import base64
164
170
  # Add back padding if needed
165
171
  padding = 4 - len(encoded_id) % 4
166
172
  if padding != 4:
@@ -577,11 +583,18 @@ def parse_codex_conversation(session_file, session_id):
577
583
 
578
584
  elif payload_type == "reasoning":
579
585
  # Reasoning/thinking block
580
- summary_parts = payload.get("summary", [])
581
586
  thinking_text = ""
582
- for part in summary_parts:
583
- if isinstance(part, dict) and part.get("type") == "summary_text":
584
- thinking_text += part.get("text", "") + "\n"
587
+
588
+ # Check if content is encrypted
589
+ if payload.get("encrypted_content"):
590
+ thinking_text = "[Reasoning content is encrypted and cannot be displayed]\n\nOpenAI Codex encrypts extended thinking for privacy. The model used reasoning here, but the content is not accessible in the logs."
591
+ else:
592
+ # Try to extract from summary (unencrypted format)
593
+ summary_parts = payload.get("summary", [])
594
+ for part in summary_parts:
595
+ if isinstance(part, dict) and part.get("type") == "summary_text":
596
+ thinking_text += part.get("text", "") + "\n"
597
+
585
598
  if thinking_text:
586
599
  messages.append({
587
600
  "role": "assistant",
@@ -711,13 +724,29 @@ def parse_gemini_conversation(session_file, session_id):
711
724
  }
712
725
 
713
726
 
714
- @app.route('/')
715
- def index():
716
- return render_template('index.html')
727
+ # Create FastAPI app
728
+ app = FastAPI(title="CLI Code Log", version="0.2.0")
729
+
730
+ # Add CORS middleware
731
+ app.add_middleware(
732
+ CORSMiddleware,
733
+ allow_origins=["*"],
734
+ allow_credentials=True,
735
+ allow_methods=["*"],
736
+ allow_headers=["*"],
737
+ )
738
+
739
+ # Templates
740
+ templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
717
741
 
718
742
 
719
- @app.route('/api/sources')
720
- def api_sources():
743
+ @app.get("/", response_class=HTMLResponse)
744
+ async def index(request: Request):
745
+ return templates.TemplateResponse("index.html", {"request": request})
746
+
747
+
748
+ @app.get("/api/sources")
749
+ async def api_sources():
721
750
  """Get available sources."""
722
751
  sources = []
723
752
  for source_id, config in SOURCES.items():
@@ -726,55 +755,55 @@ def api_sources():
726
755
  "name": config["name"],
727
756
  "available": config["source_dir"].exists()
728
757
  })
729
- return jsonify({
758
+ return {
730
759
  "sources": sources,
731
760
  "current": current_source
732
- })
761
+ }
733
762
 
734
763
 
735
- @app.route('/api/sources/<source_id>', methods=['POST'])
736
- def api_set_source(source_id):
764
+ @app.post("/api/sources/{source_id}")
765
+ async def api_set_source(source_id: str):
737
766
  """Set the current source."""
738
767
  global current_source
739
768
  if source_id not in SOURCES:
740
- return jsonify({"error": "Unknown source"}), 400
769
+ return JSONResponse({"error": "Unknown source"}, status_code=400)
741
770
  current_source = source_id
742
- return jsonify({"status": "success", "current": current_source})
771
+ return {"status": "success", "current": current_source}
743
772
 
744
773
 
745
- @app.route('/api/projects')
746
- def api_projects():
747
- source_id = request.args.get('source', current_source)
748
- return jsonify(get_projects(source_id))
774
+ @app.get("/api/projects")
775
+ async def api_projects(source: Optional[str] = None):
776
+ source_id = source or current_source
777
+ return get_projects(source_id)
749
778
 
750
779
 
751
- @app.route('/api/projects/<project_id>/sessions')
752
- def api_sessions(project_id):
753
- source_id = request.args.get('source', current_source)
754
- return jsonify(get_sessions(project_id, source_id))
780
+ @app.get("/api/projects/{project_id}/sessions")
781
+ async def api_sessions(project_id: str, source: Optional[str] = None):
782
+ source_id = source or current_source
783
+ return get_sessions(project_id, source_id)
755
784
 
756
785
 
757
- @app.route('/api/projects/<project_id>/sessions/<session_id>')
758
- def api_conversation(project_id, session_id):
759
- source_id = request.args.get('source', current_source)
760
- return jsonify(get_conversation(project_id, session_id, source_id))
786
+ @app.get("/api/projects/{project_id}/sessions/{session_id}")
787
+ async def api_conversation(project_id: str, session_id: str, source: Optional[str] = None):
788
+ source_id = source or current_source
789
+ return get_conversation(project_id, session_id, source_id)
761
790
 
762
791
 
763
- @app.route('/api/search')
764
- def api_search():
792
+ @app.get("/api/search")
793
+ async def api_search(q: Optional[str] = None, source: Optional[str] = None):
765
794
  """Search across all conversations."""
766
- query = request.args.get('q', '').lower()
767
- source_id = request.args.get('source', current_source)
795
+ query = (q or '').lower()
796
+ source_id = source or current_source
768
797
 
769
798
  if not query:
770
- return jsonify([])
799
+ return []
771
800
 
772
801
  if source_id not in SOURCES:
773
- return jsonify([])
802
+ return []
774
803
 
775
804
  data_dir = DATA_DIR / SOURCES[source_id]["data_subdir"]
776
805
  if not data_dir.exists():
777
- return jsonify([])
806
+ return []
778
807
 
779
808
  results = []
780
809
 
@@ -827,17 +856,17 @@ def api_search():
827
856
  except Exception:
828
857
  continue
829
858
 
830
- return jsonify(results[:50]) # Limit results
859
+ return results[:50] # Limit results
831
860
 
832
861
 
833
- @app.route('/api/projects/<project_id>/sessions/<session_id>/export')
834
- def api_export(project_id, session_id):
862
+ @app.get("/api/projects/{project_id}/sessions/{session_id}/export")
863
+ async def api_export(project_id: str, session_id: str, source: Optional[str] = None):
835
864
  """Export conversation as text file."""
836
- source_id = request.args.get('source', current_source)
865
+ source_id = source or current_source
837
866
  conversation = get_conversation(project_id, session_id, source_id)
838
867
 
839
868
  if "error" in conversation:
840
- return jsonify(conversation), 404
869
+ return JSONResponse(conversation, status_code=404)
841
870
 
842
871
  # Build text content
843
872
  lines = []
@@ -851,7 +880,7 @@ def api_export(project_id, session_id):
851
880
  if conversation.get("summaries"):
852
881
  lines.append("SUMMARIES:")
853
882
  for s in conversation["summaries"]:
854
- lines.append(f" * {s}")
883
+ lines.append(f" {s}")
855
884
  lines.append("")
856
885
  lines.append("-" * 60)
857
886
  lines.append("")
@@ -902,63 +931,100 @@ def api_export(project_id, session_id):
902
931
  text_content = "\n".join(lines)
903
932
 
904
933
  return Response(
905
- text_content,
906
- mimetype="text/plain",
934
+ content=text_content,
935
+ media_type="text/plain",
907
936
  headers={"Content-Disposition": f"attachment; filename={session_id}.txt"}
908
937
  )
909
938
 
910
939
 
911
- @app.route('/api/sync', methods=['POST'])
912
- def api_sync():
940
+ @app.post("/api/sync")
941
+ async def api_sync(source: Optional[str] = None):
913
942
  """Manually trigger a data sync."""
914
- source_id = request.args.get('source', current_source)
943
+ source_id = source or current_source
915
944
  try:
916
945
  sync_data(source_id=source_id, silent=True)
917
- return jsonify({
946
+ return {
918
947
  "status": "success",
919
948
  "source": source_id,
920
949
  "last_sync": last_sync_time.get(source_id).isoformat() if last_sync_time.get(source_id) else None
921
- })
950
+ }
922
951
  except Exception as e:
923
- return jsonify({"status": "error", "message": str(e)}), 500
952
+ return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
924
953
 
925
954
 
926
- @app.route('/api/status')
927
- def api_status():
955
+ @app.get("/api/status")
956
+ async def api_status(source: Optional[str] = None):
928
957
  """Get sync status."""
929
- source_id = request.args.get('source', current_source)
958
+ source_id = source or current_source
930
959
  source_config = SOURCES.get(source_id, {})
931
960
  data_dir = DATA_DIR / source_config.get("data_subdir", "")
932
961
 
933
- return jsonify({
962
+ return {
934
963
  "source": source_id,
935
964
  "last_sync": last_sync_time.get(source_id).isoformat() if last_sync_time.get(source_id) else None,
936
965
  "sync_interval_hours": SYNC_INTERVAL / 3600,
937
966
  "data_dir": str(data_dir)
938
- })
939
-
967
+ }
940
968
 
941
- def find_available_port(host, start_port, max_attempts=100):
942
- """Find an available port starting from start_port."""
943
- import socket
944
969
 
945
- for port in range(start_port, start_port + max_attempts):
970
+ def kill_process_on_port(port, max_retries=3):
971
+ """Kill any process running on the specified port."""
972
+ for attempt in range(max_retries):
946
973
  try:
947
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
948
- s.bind((host, port))
949
- return port
950
- except OSError:
951
- continue
952
- raise RuntimeError(f"Could not find an available port in range {start_port}-{start_port + max_attempts}")
953
-
974
+ # Find process ID on the port
975
+ result = subprocess.run(
976
+ ["lsof", "-ti", f":{port}"],
977
+ capture_output=True,
978
+ text=True
979
+ )
980
+ if result.returncode == 0 and result.stdout.strip():
981
+ pids = result.stdout.strip().split('\n')
982
+ for pid in pids:
983
+ print(f"⚠️ Port {port} is in use by process {pid}")
984
+ print(f"🔄 Killing process {pid}...")
985
+ try:
986
+ os.kill(int(pid), signal.SIGKILL) # Use SIGKILL for immediate termination
987
+ except ProcessLookupError:
988
+ pass # Process already gone
989
+ except Exception as e:
990
+ print(f"Warning: Could not kill process: {e}")
991
+
992
+ # Wait longer and verify
993
+ time.sleep(1.5)
994
+
995
+ # Check if port is now free
996
+ check = subprocess.run(
997
+ ["lsof", "-ti", f":{port}"],
998
+ capture_output=True,
999
+ text=True
1000
+ )
1001
+ if check.returncode != 0 or not check.stdout.strip():
1002
+ print(f"✓ Port {port} is now free")
1003
+ return True
1004
+ else:
1005
+ return True # Port is already free
1006
+ except FileNotFoundError:
1007
+ # lsof not available (not on Unix-like system)
1008
+ return True
1009
+ except Exception as e:
1010
+ print(f"Warning: Could not check port: {e}")
1011
+ return False
1012
+
1013
+ print(f"❌ Failed to free port {port} after {max_retries} attempts")
1014
+ return False
954
1015
 
955
- def run_server(host="127.0.0.1", port=5050, skip_sync=False, debug=False):
956
- """Run the Flask server."""
957
- from clicodelog import __version__
958
1016
 
1017
+ def run_server(host="127.0.0.1", port=6126, skip_sync=False, debug=False):
1018
+ """Run the FastAPI server with uvicorn."""
959
1019
  print("=" * 60)
960
- print(f"cli code log v{__version__}")
1020
+ print("CLI Code Log - AI Conversation History Viewer")
961
1021
  print("=" * 60)
1022
+
1023
+ # Kill any process on the port
1024
+ if not kill_process_on_port(port):
1025
+ print(f"\n❌ Could not free port {port}. Please manually stop the process or use a different port.")
1026
+ print(f" Try: lsof -ti:{port} | xargs kill -9")
1027
+ return
962
1028
 
963
1029
  if not skip_sync:
964
1030
  # Sync data from all sources
@@ -969,28 +1035,37 @@ def run_server(host="127.0.0.1", port=5050, skip_sync=False, debug=False):
969
1035
  print(f" Backup: {DATA_DIR / config['data_subdir']}")
970
1036
 
971
1037
  if sync_data(source_id=source_id):
972
- print(" Sync completed!")
1038
+ print(f" Sync completed!")
973
1039
  else:
974
- print(" Warning: Could not sync. Using existing local data if available.")
975
-
976
- # Start background sync thread
977
- print(f"\nBackground sync: Every {SYNC_INTERVAL // 3600} hour(s)")
978
- sync_thread = threading.Thread(target=background_sync, daemon=True)
979
- sync_thread.start()
980
- print("Background sync thread started.")
1040
+ print(f" Warning: Could not sync. Using existing local data if available.")
981
1041
  else:
982
1042
  print("\nSkipping initial sync (--no-sync flag)")
983
1043
 
984
- # Find available port
985
- actual_port = find_available_port(host, port)
986
- if actual_port != port:
987
- print(f"\nPort {port} is busy, using port {actual_port} instead")
1044
+ # Start background sync thread
1045
+ print(f"\nBackground sync: Every {SYNC_INTERVAL // 3600} hour(s)")
1046
+ sync_thread = threading.Thread(target=background_sync, daemon=True)
1047
+ sync_thread.start()
1048
+ print("Background sync thread started.")
988
1049
 
1050
+ url = f"http://{host}:{port}"
989
1051
  print(f"\nStarting server...")
990
- print(f"Open http://{host}:{actual_port} in your browser")
1052
+ print(f"🌐 Opening {url} in your browser...")
991
1053
  print("=" * 60)
992
- app.run(host=host, port=actual_port, debug=debug, use_reloader=False)
1054
+
1055
+ # Open browser after a short delay
1056
+ def open_browser():
1057
+ time.sleep(1.5)
1058
+ try:
1059
+ webbrowser.open(url)
1060
+ except Exception as e:
1061
+ print(f"Could not open browser automatically: {e}")
1062
+
1063
+ browser_thread = threading.Thread(target=open_browser, daemon=True)
1064
+ browser_thread.start()
1065
+
1066
+ uvicorn.run(app, host=host, port=port, log_level="warning" if not debug else "info")
993
1067
 
994
1068
 
995
1069
  if __name__ == '__main__':
996
- run_server()
1070
+ # For direct execution
1071
+ run_server(debug=True)
clicodelog/cli.py CHANGED
@@ -19,8 +19,8 @@ def main():
19
19
  parser.add_argument(
20
20
  "--port", "-p",
21
21
  type=int,
22
- default=5050,
23
- help="Port to run the server on (default: 5050)",
22
+ default=6126,
23
+ help="Port to run the server on (default: 6126)",
24
24
  )
25
25
  parser.add_argument(
26
26
  "--host", "-H",
@@ -62,7 +62,8 @@
62
62
  .app {
63
63
  display: grid;
64
64
  grid-template-columns: 280px 320px 1fr;
65
- height: 100vh;
65
+ height: calc(100vh - 60px);
66
+ margin-top: 60px;
66
67
  }
67
68
 
68
69
  .panel {
@@ -576,6 +577,118 @@
576
577
  [data-theme="light"] .source-selector select {
577
578
  background: var(--bg-secondary);
578
579
  }
580
+
581
+ /* Search Results Modal */
582
+ .search-modal {
583
+ display: none;
584
+ position: fixed;
585
+ top: 70px;
586
+ right: 16px;
587
+ width: 400px;
588
+ max-height: 500px;
589
+ background: var(--bg-secondary);
590
+ border: 1px solid var(--border-color);
591
+ border-radius: 8px;
592
+ box-shadow: 0 4px 16px var(--shadow-color);
593
+ z-index: 99;
594
+ overflow: hidden;
595
+ }
596
+
597
+ .search-modal.active {
598
+ display: flex;
599
+ flex-direction: column;
600
+ }
601
+
602
+ .search-modal-header {
603
+ padding: 12px 16px;
604
+ border-bottom: 1px solid var(--border-color);
605
+ display: flex;
606
+ align-items: center;
607
+ justify-content: space-between;
608
+ }
609
+
610
+ .search-modal-header h3 {
611
+ font-size: 14px;
612
+ font-weight: 600;
613
+ color: var(--text-primary);
614
+ }
615
+
616
+ .search-modal-close {
617
+ cursor: pointer;
618
+ font-size: 18px;
619
+ color: var(--text-muted);
620
+ padding: 4px;
621
+ }
622
+
623
+ .search-modal-close:hover {
624
+ color: var(--text-primary);
625
+ }
626
+
627
+ .search-modal-content {
628
+ flex: 1;
629
+ overflow-y: auto;
630
+ padding: 8px;
631
+ }
632
+
633
+ .search-result-item {
634
+ padding: 12px;
635
+ margin-bottom: 8px;
636
+ background: var(--bg-tertiary);
637
+ border-radius: 6px;
638
+ cursor: pointer;
639
+ transition: all 0.2s;
640
+ border-left: 3px solid var(--accent-blue);
641
+ }
642
+
643
+ .search-result-item:hover {
644
+ background: var(--bg-primary);
645
+ transform: translateX(2px);
646
+ }
647
+
648
+ .search-result-title {
649
+ font-size: 13px;
650
+ font-weight: 500;
651
+ margin-bottom: 4px;
652
+ color: var(--text-primary);
653
+ }
654
+
655
+ .search-result-meta {
656
+ font-size: 11px;
657
+ color: var(--text-muted);
658
+ }
659
+
660
+ .search-input-container {
661
+ padding: 12px 16px;
662
+ border-bottom: 1px solid var(--border-color);
663
+ }
664
+
665
+ .search-input-container input {
666
+ width: 100%;
667
+ padding: 8px 12px;
668
+ background: var(--bg-tertiary);
669
+ border: 1px solid var(--border-color);
670
+ border-radius: 6px;
671
+ color: var(--text-primary);
672
+ font-size: 13px;
673
+ }
674
+
675
+ .search-input-container input:focus {
676
+ outline: none;
677
+ border-color: var(--accent-blue);
678
+ }
679
+
680
+ .no-search-results {
681
+ padding: 40px 20px;
682
+ text-align: center;
683
+ color: var(--text-muted);
684
+ font-size: 13px;
685
+ }
686
+
687
+ .control-btn.active {
688
+ background: var(--accent-blue);
689
+ color: white;
690
+ border-color: var(--accent-blue);
691
+ }
579
692
  </style>
580
693
  </head>
581
694
  <body>
@@ -590,6 +703,18 @@
590
703
  </select>
591
704
  </div>
592
705
  <span class="sync-status" id="sync-status" title="Last sync time">Syncing...</span>
706
+ <button class="control-btn" id="thinking-toggle-btn" onclick="toggleAllThinking()" title="Toggle all thinking blocks">
707
+ <span class="icon">🧠</span>
708
+ <span>Show Thinking</span>
709
+ </button>
710
+ <button class="control-btn" id="search-btn" onclick="toggleSearch()" title="Search conversations">
711
+ <span class="icon">🔍</span>
712
+ <span>Search</span>
713
+ </button>
714
+ <button class="control-btn" id="copy-btn" onclick="copyConversation()" title="Copy conversation to clipboard" disabled>
715
+ <span class="icon">📋</span>
716
+ <span>Copy</span>
717
+ </button>
593
718
  <button class="control-btn" id="export-btn" onclick="exportConversation()" title="Export conversation as TXT" disabled>
594
719
  <span class="icon">📥</span>
595
720
  <span>Export</span>
@@ -604,6 +729,20 @@
604
729
  </button>
605
730
  </div>
606
731
 
732
+ <!-- Search Modal -->
733
+ <div class="search-modal" id="search-modal">
734
+ <div class="search-modal-header">
735
+ <h3>Search Conversations</h3>
736
+ <span class="search-modal-close" onclick="toggleSearch()">✕</span>
737
+ </div>
738
+ <div class="search-input-container">
739
+ <input type="text" id="global-search-input" placeholder="Search across all conversations...">
740
+ </div>
741
+ <div class="search-modal-content" id="search-results">
742
+ <div class="no-search-results">Type to search across conversations</div>
743
+ </div>
744
+ </div>
745
+
607
746
  <div class="app">
608
747
  <!-- Projects Panel -->
609
748
  <div class="panel" id="projects-panel">
@@ -652,6 +791,8 @@
652
791
  let currentSource = 'claude-code';
653
792
  let projects = [];
654
793
  let availableSources = [];
794
+ let allThinkingExpanded = false;
795
+ let currentConversation = null;
655
796
 
656
797
  // Theme Management
657
798
  function getPreferredTheme() {
@@ -719,6 +860,7 @@
719
860
 
720
861
  // Update UI
721
862
  document.getElementById('export-btn').disabled = true;
863
+ document.getElementById('copy-btn').disabled = true;
722
864
 
723
865
  // Reset panels
724
866
  document.getElementById('sessions-list').innerHTML = `
@@ -811,6 +953,182 @@
811
953
  document.body.removeChild(link);
812
954
  }
813
955
 
956
+ // Copy conversation to clipboard
957
+ async function copyConversation() {
958
+ if (!currentConversation) return;
959
+
960
+ try {
961
+ const text = buildConversationText(currentConversation);
962
+ await navigator.clipboard.writeText(text);
963
+
964
+ // Show feedback
965
+ const btn = document.getElementById('copy-btn');
966
+ const originalText = btn.querySelector('span:last-child').textContent;
967
+ btn.querySelector('span:last-child').textContent = 'Copied!';
968
+ setTimeout(() => {
969
+ btn.querySelector('span:last-child').textContent = originalText;
970
+ }, 2000);
971
+ } catch (error) {
972
+ console.error('Failed to copy:', error);
973
+ alert('Failed to copy to clipboard');
974
+ }
975
+ }
976
+
977
+ // Build conversation text (same format as export)
978
+ function buildConversationText(conversation) {
979
+ const lines = [];
980
+ lines.push('='.repeat(60));
981
+ lines.push(`Session: ${conversation.session_id}`);
982
+ lines.push(`Source: ${currentSource}`);
983
+ lines.push('='.repeat(60));
984
+ lines.push('');
985
+
986
+ // Add summaries if present
987
+ if (conversation.summaries && conversation.summaries.length > 0) {
988
+ lines.push('SUMMARIES:');
989
+ conversation.summaries.forEach(s => lines.push(` • ${s}`));
990
+ lines.push('');
991
+ lines.push('-'.repeat(60));
992
+ lines.push('');
993
+ }
994
+
995
+ // Add messages
996
+ conversation.messages.forEach(msg => {
997
+ const role = msg.role.toUpperCase();
998
+ const timestamp = msg.timestamp || '';
999
+
1000
+ lines.push(`[${role}] ${timestamp}`);
1001
+ if (msg.model) {
1002
+ lines.push(`Model: ${msg.model}`);
1003
+ }
1004
+ lines.push('-'.repeat(40));
1005
+
1006
+ // Content
1007
+ if (msg.content) {
1008
+ lines.push(msg.content);
1009
+ }
1010
+
1011
+ // Thinking
1012
+ if (msg.thinking) {
1013
+ lines.push('');
1014
+ lines.push('--- THINKING ---');
1015
+ lines.push(msg.thinking);
1016
+ lines.push('--- END THINKING ---');
1017
+ }
1018
+
1019
+ // Tool uses
1020
+ if (msg.tool_uses && msg.tool_uses.length > 0) {
1021
+ lines.push('');
1022
+ msg.tool_uses.forEach(tool => {
1023
+ lines.push(`[TOOL: ${tool.name}]`);
1024
+ if (typeof tool.input === 'object') {
1025
+ for (const [k, v] of Object.entries(tool.input)) {
1026
+ const val = String(v).length > 200 ? String(v).substring(0, 200) + '...' : String(v);
1027
+ lines.push(` ${k}: ${val}`);
1028
+ }
1029
+ } else {
1030
+ lines.push(` ${tool.input || ''}`);
1031
+ }
1032
+ });
1033
+ }
1034
+
1035
+ // Usage stats
1036
+ if (msg.usage) {
1037
+ const tokens = (msg.usage.input_tokens || 0) + (msg.usage.output_tokens || 0);
1038
+ lines.push(`\n[Tokens: ${tokens}]`);
1039
+ }
1040
+
1041
+ lines.push('');
1042
+ lines.push('='.repeat(60));
1043
+ lines.push('');
1044
+ });
1045
+
1046
+ return lines.join('\n');
1047
+ }
1048
+
1049
+ // Toggle all thinking blocks
1050
+ function toggleAllThinking() {
1051
+ allThinkingExpanded = !allThinkingExpanded;
1052
+ const thinkingBlocks = document.querySelectorAll('.thinking-block');
1053
+ const btn = document.getElementById('thinking-toggle-btn');
1054
+
1055
+ thinkingBlocks.forEach(block => {
1056
+ if (allThinkingExpanded) {
1057
+ block.classList.add('expanded');
1058
+ } else {
1059
+ block.classList.remove('expanded');
1060
+ }
1061
+ });
1062
+
1063
+ // Update button state
1064
+ if (allThinkingExpanded) {
1065
+ btn.classList.add('active');
1066
+ btn.querySelector('span:last-child').textContent = 'Hide Thinking';
1067
+ } else {
1068
+ btn.classList.remove('active');
1069
+ btn.querySelector('span:last-child').textContent = 'Show Thinking';
1070
+ }
1071
+ }
1072
+
1073
+ // Toggle search modal
1074
+ function toggleSearch() {
1075
+ const modal = document.getElementById('search-modal');
1076
+ const btn = document.getElementById('search-btn');
1077
+
1078
+ if (modal.classList.contains('active')) {
1079
+ modal.classList.remove('active');
1080
+ btn.classList.remove('active');
1081
+ } else {
1082
+ modal.classList.add('active');
1083
+ btn.classList.add('active');
1084
+ document.getElementById('global-search-input').focus();
1085
+ }
1086
+ }
1087
+
1088
+ // Search across conversations
1089
+ let searchTimeout = null;
1090
+ async function searchConversations(query) {
1091
+ if (!query || query.length < 2) {
1092
+ document.getElementById('search-results').innerHTML = '<div class="no-search-results">Type at least 2 characters to search</div>';
1093
+ return;
1094
+ }
1095
+
1096
+ document.getElementById('search-results').innerHTML = '<div class="loading"><div class="spinner"></div>Searching...</div>';
1097
+
1098
+ try {
1099
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&source=${currentSource}`);
1100
+ const results = await response.json();
1101
+
1102
+ if (results.length === 0) {
1103
+ document.getElementById('search-results').innerHTML = '<div class="no-search-results">No results found</div>';
1104
+ return;
1105
+ }
1106
+
1107
+ const html = results.map(result => `
1108
+ <div class="search-result-item" onclick="selectSearchResult('${result.project_id}', '${result.session_id}')">
1109
+ <div class="search-result-title">${escapeHtml(result.session_id)}</div>
1110
+ <div class="search-result-meta">${escapeHtml(result.project_name)}</div>
1111
+ </div>
1112
+ `).join('');
1113
+
1114
+ document.getElementById('search-results').innerHTML = html;
1115
+ } catch (error) {
1116
+ console.error('Search error:', error);
1117
+ document.getElementById('search-results').innerHTML = '<div class="no-search-results">Search failed</div>';
1118
+ }
1119
+ }
1120
+
1121
+ // Select a search result
1122
+ async function selectSearchResult(projectId, sessionId) {
1123
+ toggleSearch(); // Close search modal
1124
+ currentProjectId = projectId;
1125
+ currentSessionId = sessionId;
1126
+
1127
+ // Load the conversation
1128
+ await selectProject(projectId);
1129
+ await selectSession(sessionId);
1130
+ }
1131
+
814
1132
  // Format timestamp
815
1133
  function formatTime(timestamp) {
816
1134
  if (!timestamp) return '';
@@ -886,8 +1204,9 @@
886
1204
  currentSessionId = null;
887
1205
  renderProjects(projects);
888
1206
 
889
- // Disable export button when switching projects
1207
+ // Disable export and copy buttons when switching projects
890
1208
  document.getElementById('export-btn').disabled = true;
1209
+ document.getElementById('copy-btn').disabled = true;
891
1210
 
892
1211
  const sessionsContainer = document.getElementById('sessions-list');
893
1212
  sessionsContainer.innerHTML = `
@@ -967,6 +1286,7 @@
967
1286
 
968
1287
  // Render conversation
969
1288
  function renderConversation(conversation) {
1289
+ currentConversation = conversation; // Store for copy function
970
1290
  const container = document.getElementById('conversation-content');
971
1291
 
972
1292
  let summariesHtml = '';
@@ -982,8 +1302,9 @@
982
1302
  const messagesHtml = conversation.messages.map(msg => {
983
1303
  let thinkingHtml = '';
984
1304
  if (msg.thinking) {
1305
+ const expandedClass = allThinkingExpanded ? ' expanded' : '';
985
1306
  thinkingHtml = `
986
- <div class="thinking-block" onclick="this.classList.toggle('expanded')">
1307
+ <div class="thinking-block${expandedClass}" onclick="this.classList.toggle('expanded')">
987
1308
  <div class="thinking-header">Thinking</div>
988
1309
  <div class="thinking-content">${escapeHtml(msg.thinking)}</div>
989
1310
  </div>
@@ -1043,8 +1364,9 @@
1043
1364
  </div>
1044
1365
  `;
1045
1366
 
1046
- // Enable export button
1367
+ // Enable export and copy buttons
1047
1368
  document.getElementById('export-btn').disabled = false;
1369
+ document.getElementById('copy-btn').disabled = false;
1048
1370
  }
1049
1371
 
1050
1372
  // Search projects
@@ -1056,6 +1378,13 @@
1056
1378
  renderProjects(filtered);
1057
1379
  });
1058
1380
 
1381
+ // Global search input handler with debounce
1382
+ document.getElementById('global-search-input').addEventListener('input', (e) => {
1383
+ clearTimeout(searchTimeout);
1384
+ const query = e.target.value;
1385
+ searchTimeout = setTimeout(() => searchConversations(query), 300);
1386
+ });
1387
+
1059
1388
  // Initialize
1060
1389
  async function init() {
1061
1390
  await loadSources();
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clicodelog
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: A lightweight, local-first web app to browse, inspect, and export logs from CLI-based AI coding agents
5
5
  Author: monk1337
6
6
  License-Expression: MIT
@@ -25,8 +25,9 @@ Classifier: Topic :: Utilities
25
25
  Requires-Python: >=3.7
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
- Requires-Dist: flask>=2.0
29
- Requires-Dist: flask-cors>=3.0
28
+ Requires-Dist: fastapi>=0.104.0
29
+ Requires-Dist: uvicorn[standard]>=0.24.0
30
+ Requires-Dist: jinja2>=3.1.0
30
31
  Provides-Extra: dev
31
32
  Requires-Dist: pytest>=7.0; extra == "dev"
32
33
  Requires-Dist: build; extra == "dev"
@@ -34,7 +35,10 @@ Requires-Dist: twine; extra == "dev"
34
35
  Dynamic: license-file
35
36
 
36
37
  <div align="center">
37
- <h1>cli code log</h1>
38
+ <div align="center">
39
+ <img width="220px" src="https://raw.githubusercontent.com/monk1337/clicodelog/refs/heads/main/screenshots/logo.png">
40
+ </div>
41
+
38
42
  <p>
39
43
  A lightweight, local-first web app to browse, inspect, and export logs from
40
44
  CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
@@ -50,7 +54,7 @@ CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
50
54
 
51
55
  <p>
52
56
  <img src="https://img.shields.io/badge/Python-3.7+-blue.svg" alt="Python 3.7+" />
53
- <img src="https://img.shields.io/badge/Flask-2.0+-green.svg" alt="Flask" />
57
+ <img src="https://img.shields.io/badge/FastAPI-0.104+-00c7b7.svg" alt="FastAPI" />
54
58
  <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License MIT" />
55
59
  <a href="http://makeapullrequest.com">
56
60
  <img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square" alt="PRs Welcome" />
@@ -58,10 +62,97 @@ CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
58
62
  </p>
59
63
  </div>
60
64
 
61
- ![Gemini CLI Screenshot](screenshots/dark.png)
65
+ <!-- <div align="center">
66
+ <table>
67
+ <tr>
68
+ <td align="center">
69
+ <img src="screenshots/claude.png" width="80" alt="Claude Code"><br>
70
+ <sub><b>Claude Code</b></sub>
71
+ </td>
72
+ <td align="center">
73
+ <img src="screenshots/codex.png" width="80" alt="OpenAI Codex"><br>
74
+ <sub><b>OpenAI Codex</b></sub>
75
+ </td>
76
+ <td align="center">
77
+ <img src="screenshots/gemini.png" width="80" alt="Gemini CLI"><br>
78
+ <sub><b>Gemini CLI</b></sub>
79
+ </td>
80
+ </tr>
81
+ </table>
82
+ </div> -->
62
83
 
63
84
  ---
64
85
 
86
+ ## Installation
87
+
88
+ ### Via uv tool (Recommended)
89
+
90
+ [uv](https://github.com/astral-sh/uv) is a fast Python package installer. Install it first if you haven't:
91
+
92
+ ```bash
93
+ curl -LsSf https://astral.sh/uv/install.sh | sh
94
+ ```
95
+
96
+ Then install clicodelog as an isolated tool:
97
+
98
+ ```bash
99
+ uv tool install clicodelog
100
+ ```
101
+
102
+ To upgrade:
103
+ ```bash
104
+ uv tool upgrade clicodelog
105
+ ```
106
+
107
+ ### Via pip
108
+
109
+ ```bash
110
+ pip install clicodelog
111
+ ```
112
+
113
+ ### From source
114
+
115
+ ```bash
116
+ git clone https://github.com/monk1337/clicodelog.git
117
+ cd clicodelog
118
+ uv tool install -e .
119
+ # or with pip:
120
+ pip install -e .
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Usage
126
+
127
+ After installation, simply run:
128
+
129
+ ```bash
130
+ clicodelog
131
+ ```
132
+
133
+ The app will:
134
+ - Auto-kill any process on port **6126** (if occupied)
135
+ - Sync data from all AI coding agent sources
136
+ - Start a web server at **http://localhost:6126**
137
+
138
+ ### Command Options
139
+
140
+ ```bash
141
+ clicodelog --help # Show all options
142
+ clicodelog --port 8080 # Use custom port
143
+ clicodelog --host 0.0.0.0 # Bind to all interfaces
144
+ clicodelog --no-sync # Skip initial data sync
145
+ clicodelog --debug # Run in debug mode
146
+ ```
147
+
148
+ ### Alternative: Run from source
149
+
150
+ ```bash
151
+ git clone https://github.com/monk1337/clicodelog.git
152
+ cd clicodelog
153
+ python -m clicodelog.cli
154
+ ```
155
+
65
156
  ## Features
66
157
 
67
158
  - **Multi-source support** — View logs from Claude Code, OpenAI Codex, and Gemini CLI
@@ -104,56 +195,18 @@ CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
104
195
 
105
196
  ---
106
197
 
107
- ## Installation
108
-
109
- ### Via pip (Recommended)
110
-
111
- ```bash
112
- pip install clicodelog
113
- ```
114
-
115
- ### From source
116
-
117
- ```bash
118
- git clone https://github.com/monk1337/clicodelog.git
119
- cd clicodelog
120
- pip install -e .
121
- ```
122
-
123
- ---
124
-
125
- ## Usage
126
-
127
- If installed via pip:
128
-
129
- ```bash
130
- clicodelog
131
- ```
132
-
133
- Or run directly from source:
134
-
135
- ```bash
136
- ./run.sh
137
- ```
138
-
139
- Or manually:
198
+ ### CLI Options
140
199
 
141
200
  ```bash
142
- pip install -r requirements.txt
143
- python app.py
201
+ clicodelog --help # Show help message
202
+ clicodelog --version # Show version
203
+ clicodelog --port 8080 # Run on custom port (default: 6126)
204
+ clicodelog --host 0.0.0.0 # Bind to all interfaces (default: 127.0.0.1)
205
+ clicodelog --no-sync # Skip initial data sync
206
+ clicodelog --debug # Run in debug mode
144
207
  ```
145
208
 
146
- Open http://localhost:5050 in your browser.
147
-
148
- ### CLI Options
149
-
150
- ```
151
- clicodelog --help
152
- clicodelog --port 8080 # Run on custom port
153
- clicodelog --host 0.0.0.0 # Bind to all interfaces
154
- clicodelog --no-sync # Skip initial data sync
155
- clicodelog --debug # Run in debug mode
156
- ```
209
+ **Note:** The app automatically kills any process running on the specified port before starting.
157
210
 
158
211
  ---
159
212
 
@@ -289,17 +342,3 @@ MIT
289
342
  </div>
290
343
 
291
344
  ```
292
-
293
- @misc{clicodelog2026,
294
- title = {clicodelog: Browse, inspect CLI-based AI coding agents},
295
- author = {Pal, Ankit},
296
- year = {2026},
297
- howpublished = {\url{https://github.com/monk1337/clicodelog}},
298
- note = {A lightweight, local-first web app to browse, inspect, and export logs from CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.}
299
- }
300
-
301
- ```
302
-
303
- ## 💁 Contributing
304
-
305
- Welcome any contributions to open source project, including new features, improvements to infrastructure, and more comprehensive documentation.
@@ -0,0 +1,11 @@
1
+ clicodelog/__init__.py,sha256=ZRRWF0lVHMqhbZi5O9reskm45hUqG911bWa21dRMvvA,136
2
+ clicodelog/__main__.py,sha256=JDb49rUInCUlT5Ya0aGfusfJm8e7wGqA447mrnVqDkA,119
3
+ clicodelog/app.py,sha256=Wfefm-ie0qDdZU9p0CpPELfy_d9b-bgydrG50PghiGw,40860
4
+ clicodelog/cli.py,sha256=c34P5fOpE5Hy_DJog52BGO4ab4cUBRZZTsW5nxaFUL4,1297
5
+ clicodelog/templates/index.html,sha256=2F6iCVq50nzkY_381QeuAGYJW9KJWJk2Taav1UmcfKk,46501
6
+ clicodelog-0.2.1.dist-info/licenses/LICENSE,sha256=enO5ZtMm5_HoQnB9jA_d3CCajd9I6Kzg-m1FmNCsAvk,1065
7
+ clicodelog-0.2.1.dist-info/METADATA,sha256=kaqtFBPFdK-cvJ3G0-NGbtrvZdXBdI7BNjDzay10yKE,9124
8
+ clicodelog-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ clicodelog-0.2.1.dist-info/entry_points.txt,sha256=6Zodty_o0mVbDoIcNPQ31aI-P-KLZ0Xr_qsfrQkDYck,51
10
+ clicodelog-0.2.1.dist-info/top_level.txt,sha256=YvkoZJtBm4QSRL2YlLr5TeVILsiptIF7D43i29RG5oc,11
11
+ clicodelog-0.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,11 +0,0 @@
1
- clicodelog/__init__.py,sha256=HYMXrxX39PPodu0qTmWqbgTr09RLzUtl3QNFDQCz_r4,136
2
- clicodelog/__main__.py,sha256=JDb49rUInCUlT5Ya0aGfusfJm8e7wGqA447mrnVqDkA,119
3
- clicodelog/app.py,sha256=W-7mQSwcB5N69ZEYhdOkg-fs93wxR4XccPAQ2H1GKO8,37823
4
- clicodelog/cli.py,sha256=mH_fjEVg2RovP2sPpZPOyIzejnLcvaqi0hf41Xnxnao,1297
5
- clicodelog/templates/index.html,sha256=kUYQJivjWOLonREjinUsIHvgiGUHbhuJvlLWyjIst9Y,34392
6
- clicodelog-0.1.0.dist-info/licenses/LICENSE,sha256=enO5ZtMm5_HoQnB9jA_d3CCajd9I6Kzg-m1FmNCsAvk,1065
7
- clicodelog-0.1.0.dist-info/METADATA,sha256=fVvrn6ehZcS2z3CD_5E64vNVZwR6O6cSaYi1jNWkxNM,8058
8
- clicodelog-0.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
9
- clicodelog-0.1.0.dist-info/entry_points.txt,sha256=6Zodty_o0mVbDoIcNPQ31aI-P-KLZ0Xr_qsfrQkDYck,51
10
- clicodelog-0.1.0.dist-info/top_level.txt,sha256=YvkoZJtBm4QSRL2YlLr5TeVILsiptIF7D43i29RG5oc,11
11
- clicodelog-0.1.0.dist-info/RECORD,,