clicodelog 0.2.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.2.0"
5
+ __version__ = "0.2.1"
6
6
  __author__ = "monk1337"
clicodelog/app.py CHANGED
@@ -13,10 +13,17 @@ import signal
13
13
  import subprocess
14
14
  import threading
15
15
  import time
16
+ import webbrowser
16
17
  from datetime import datetime
17
18
  from pathlib import Path
18
- from flask import Flask, render_template, jsonify, request
19
- from flask_cors import CORS
19
+ from typing import Optional
20
+
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
20
27
 
21
28
  # Package directory for templates
22
29
  PACKAGE_DIR = Path(__file__).parent
@@ -717,18 +724,29 @@ def parse_gemini_conversation(session_file, session_id):
717
724
  }
718
725
 
719
726
 
720
- # Create Flask app
721
- app = Flask(__name__, template_folder=str(PACKAGE_DIR / "templates"))
722
- CORS(app)
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
+ )
723
738
 
739
+ # Templates
740
+ templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
724
741
 
725
- @app.route('/')
726
- def index():
727
- return render_template('index.html')
728
742
 
743
+ @app.get("/", response_class=HTMLResponse)
744
+ async def index(request: Request):
745
+ return templates.TemplateResponse("index.html", {"request": request})
729
746
 
730
- @app.route('/api/sources')
731
- def api_sources():
747
+
748
+ @app.get("/api/sources")
749
+ async def api_sources():
732
750
  """Get available sources."""
733
751
  sources = []
734
752
  for source_id, config in SOURCES.items():
@@ -737,55 +755,55 @@ def api_sources():
737
755
  "name": config["name"],
738
756
  "available": config["source_dir"].exists()
739
757
  })
740
- return jsonify({
758
+ return {
741
759
  "sources": sources,
742
760
  "current": current_source
743
- })
761
+ }
744
762
 
745
763
 
746
- @app.route('/api/sources/<source_id>', methods=['POST'])
747
- def api_set_source(source_id):
764
+ @app.post("/api/sources/{source_id}")
765
+ async def api_set_source(source_id: str):
748
766
  """Set the current source."""
749
767
  global current_source
750
768
  if source_id not in SOURCES:
751
- return jsonify({"error": "Unknown source"}), 400
769
+ return JSONResponse({"error": "Unknown source"}, status_code=400)
752
770
  current_source = source_id
753
- return jsonify({"status": "success", "current": current_source})
771
+ return {"status": "success", "current": current_source}
754
772
 
755
773
 
756
- @app.route('/api/projects')
757
- def api_projects():
758
- source_id = request.args.get('source', current_source)
759
- 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)
760
778
 
761
779
 
762
- @app.route('/api/projects/<project_id>/sessions')
763
- def api_sessions(project_id):
764
- source_id = request.args.get('source', current_source)
765
- 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)
766
784
 
767
785
 
768
- @app.route('/api/projects/<project_id>/sessions/<session_id>')
769
- def api_conversation(project_id, session_id):
770
- source_id = request.args.get('source', current_source)
771
- 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)
772
790
 
773
791
 
774
- @app.route('/api/search')
775
- def api_search():
792
+ @app.get("/api/search")
793
+ async def api_search(q: Optional[str] = None, source: Optional[str] = None):
776
794
  """Search across all conversations."""
777
- query = request.args.get('q', '').lower()
778
- source_id = request.args.get('source', current_source)
795
+ query = (q or '').lower()
796
+ source_id = source or current_source
779
797
 
780
798
  if not query:
781
- return jsonify([])
799
+ return []
782
800
 
783
801
  if source_id not in SOURCES:
784
- return jsonify([])
802
+ return []
785
803
 
786
804
  data_dir = DATA_DIR / SOURCES[source_id]["data_subdir"]
787
805
  if not data_dir.exists():
788
- return jsonify([])
806
+ return []
789
807
 
790
808
  results = []
791
809
 
@@ -838,17 +856,17 @@ def api_search():
838
856
  except Exception:
839
857
  continue
840
858
 
841
- return jsonify(results[:50]) # Limit results
859
+ return results[:50] # Limit results
842
860
 
843
861
 
844
- @app.route('/api/projects/<project_id>/sessions/<session_id>/export')
845
- 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):
846
864
  """Export conversation as text file."""
847
- source_id = request.args.get('source', current_source)
865
+ source_id = source or current_source
848
866
  conversation = get_conversation(project_id, session_id, source_id)
849
867
 
850
868
  if "error" in conversation:
851
- return jsonify(conversation), 404
869
+ return JSONResponse(conversation, status_code=404)
852
870
 
853
871
  # Build text content
854
872
  lines = []
@@ -912,80 +930,101 @@ def api_export(project_id, session_id):
912
930
 
913
931
  text_content = "\n".join(lines)
914
932
 
915
- from flask import Response
916
933
  return Response(
917
- text_content,
918
- mimetype="text/plain",
934
+ content=text_content,
935
+ media_type="text/plain",
919
936
  headers={"Content-Disposition": f"attachment; filename={session_id}.txt"}
920
937
  )
921
938
 
922
939
 
923
- @app.route('/api/sync', methods=['POST'])
924
- def api_sync():
940
+ @app.post("/api/sync")
941
+ async def api_sync(source: Optional[str] = None):
925
942
  """Manually trigger a data sync."""
926
- source_id = request.args.get('source', current_source)
943
+ source_id = source or current_source
927
944
  try:
928
945
  sync_data(source_id=source_id, silent=True)
929
- return jsonify({
946
+ return {
930
947
  "status": "success",
931
948
  "source": source_id,
932
949
  "last_sync": last_sync_time.get(source_id).isoformat() if last_sync_time.get(source_id) else None
933
- })
950
+ }
934
951
  except Exception as e:
935
- return jsonify({"status": "error", "message": str(e)}), 500
952
+ return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
936
953
 
937
954
 
938
- @app.route('/api/status')
939
- def api_status():
955
+ @app.get("/api/status")
956
+ async def api_status(source: Optional[str] = None):
940
957
  """Get sync status."""
941
- source_id = request.args.get('source', current_source)
958
+ source_id = source or current_source
942
959
  source_config = SOURCES.get(source_id, {})
943
960
  data_dir = DATA_DIR / source_config.get("data_subdir", "")
944
961
 
945
- return jsonify({
962
+ return {
946
963
  "source": source_id,
947
964
  "last_sync": last_sync_time.get(source_id).isoformat() if last_sync_time.get(source_id) else None,
948
965
  "sync_interval_hours": SYNC_INTERVAL / 3600,
949
966
  "data_dir": str(data_dir)
950
- })
967
+ }
951
968
 
952
969
 
953
- def kill_process_on_port(port):
970
+ def kill_process_on_port(port, max_retries=3):
954
971
  """Kill any process running on the specified port."""
955
- try:
956
- # Find process ID on the port
957
- result = subprocess.run(
958
- ["lsof", "-ti", f":{port}"],
959
- capture_output=True,
960
- text=True
961
- )
962
- if result.returncode == 0 and result.stdout.strip():
963
- pid = result.stdout.strip()
964
- print(f"⚠️ Port {port} is in use by process {pid}")
965
- print(f"🔄 Killing process {pid}...")
966
- try:
967
- os.kill(int(pid), signal.SIGTERM)
968
- time.sleep(0.5) # Give it a moment to terminate
969
- print(f"✓ Process killed successfully")
970
- except ProcessLookupError:
971
- pass # Process already gone
972
- except Exception as e:
973
- print(f"Warning: Could not kill process: {e}")
974
- except FileNotFoundError:
975
- # lsof not available (not on Unix-like system)
976
- pass
977
- except Exception as e:
978
- print(f"Warning: Could not check port: {e}")
972
+ for attempt in range(max_retries):
973
+ try:
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
979
1015
 
980
1016
 
981
1017
  def run_server(host="127.0.0.1", port=6126, skip_sync=False, debug=False):
982
- """Run the Flask development server."""
1018
+ """Run the FastAPI server with uvicorn."""
983
1019
  print("=" * 60)
984
1020
  print("CLI Code Log - AI Conversation History Viewer")
985
1021
  print("=" * 60)
986
1022
 
987
1023
  # Kill any process on the port
988
- kill_process_on_port(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
989
1028
 
990
1029
  if not skip_sync:
991
1030
  # Sync data from all sources
@@ -1008,10 +1047,23 @@ def run_server(host="127.0.0.1", port=6126, skip_sync=False, debug=False):
1008
1047
  sync_thread.start()
1009
1048
  print("Background sync thread started.")
1010
1049
 
1050
+ url = f"http://{host}:{port}"
1011
1051
  print(f"\nStarting server...")
1012
- print(f"Open http://{host}:{port} in your browser")
1052
+ print(f"🌐 Opening {url} in your browser...")
1013
1053
  print("=" * 60)
1014
- app.run(host=host, port=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")
1015
1067
 
1016
1068
 
1017
1069
  if __name__ == '__main__':
@@ -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.2.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"
@@ -53,7 +54,7 @@ CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
53
54
 
54
55
  <p>
55
56
  <img src="https://img.shields.io/badge/Python-3.7+-blue.svg" alt="Python 3.7+" />
56
- <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" />
57
58
  <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License MIT" />
58
59
  <a href="http://makeapullrequest.com">
59
60
  <img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square" alt="PRs Welcome" />
@@ -84,7 +85,7 @@ CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
84
85
 
85
86
  ## Installation
86
87
 
87
- ### Via uv (Recommended)
88
+ ### Via uv tool (Recommended)
88
89
 
89
90
  [uv](https://github.com/astral-sh/uv) is a fast Python package installer. Install it first if you haven't:
90
91
 
@@ -92,10 +93,15 @@ CLI-based AI coding agents — Claude Code, OpenAI Codex, and Gemini CLI.
92
93
  curl -LsSf https://astral.sh/uv/install.sh | sh
93
94
  ```
94
95
 
95
- Then install clicodelog:
96
+ Then install clicodelog as an isolated tool:
96
97
 
97
98
  ```bash
98
- uv pip install clicodelog
99
+ uv tool install clicodelog
100
+ ```
101
+
102
+ To upgrade:
103
+ ```bash
104
+ uv tool upgrade clicodelog
99
105
  ```
100
106
 
101
107
  ### Via pip
@@ -109,7 +115,7 @@ pip install clicodelog
109
115
  ```bash
110
116
  git clone https://github.com/monk1337/clicodelog.git
111
117
  cd clicodelog
112
- uv pip install -e .
118
+ uv tool install -e .
113
119
  # or with pip:
114
120
  pip install -e .
115
121
  ```
@@ -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,11 +0,0 @@
1
- clicodelog/__init__.py,sha256=qs3bijM7cWYmV4ckBeuZd5dLhViUzuaUal4AoJUT_io,136
2
- clicodelog/__main__.py,sha256=JDb49rUInCUlT5Ya0aGfusfJm8e7wGqA447mrnVqDkA,119
3
- clicodelog/app.py,sha256=iHoeIK3IVQtTpm-CjqJO_Tn5L8krUeoOUfSdeqGAJK4,38870
4
- clicodelog/cli.py,sha256=c34P5fOpE5Hy_DJog52BGO4ab4cUBRZZTsW5nxaFUL4,1297
5
- clicodelog/templates/index.html,sha256=kUYQJivjWOLonREjinUsIHvgiGUHbhuJvlLWyjIst9Y,34392
6
- clicodelog-0.2.0.dist-info/licenses/LICENSE,sha256=enO5ZtMm5_HoQnB9jA_d3CCajd9I6Kzg-m1FmNCsAvk,1065
7
- clicodelog-0.2.0.dist-info/METADATA,sha256=6vj1ryFe10ijvIWqaLtLSQfPB1qk8EqTIQGWWA9caaM,8993
8
- clicodelog-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
- clicodelog-0.2.0.dist-info/entry_points.txt,sha256=6Zodty_o0mVbDoIcNPQ31aI-P-KLZ0Xr_qsfrQkDYck,51
10
- clicodelog-0.2.0.dist-info/top_level.txt,sha256=YvkoZJtBm4QSRL2YlLr5TeVILsiptIF7D43i29RG5oc,11
11
- clicodelog-0.2.0.dist-info/RECORD,,