tscode-kg 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.
tscode_kg/app.py ADDED
@@ -0,0 +1,1355 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ app.py — TypeScriptKG Streamlit Visualizer
4
+
5
+ Interactive knowledge-graph explorer with:
6
+ • Sidebar: configure repo/db paths and query parameters
7
+ • Graph tab: pyvis interactive graph of the full KG or query results
8
+ • Query tab: hybrid semantic+structural query with ranked node results
9
+ • Snippets tab: source-grounded snippet pack viewer
10
+
11
+ Run with:
12
+ tscodekg viz
13
+
14
+ Author: Eric G. Suchanek, PhD
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ import streamlit as st
25
+ from kg_utils.store import DEFAULT_RELS, GraphStore
26
+ from pyvis.network import Network
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Constants — colours and shapes per node kind
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _KIND_COLOR: dict[str, str] = {
33
+ "module": "#4A90D9", # blue
34
+ "class": "#E67E22", # orange
35
+ "interface": "#16A085", # teal
36
+ "type_alias": "#F1C40F", # yellow
37
+ "enum": "#C0392B", # crimson
38
+ "namespace": "#E84393", # pink
39
+ "function": "#27AE60", # green
40
+ "method": "#8E44AD", # purple
41
+ "symbol": "#95A5A6", # grey
42
+ }
43
+
44
+ _KIND_SHAPE: dict[str, str] = {
45
+ "module": "box",
46
+ "class": "diamond",
47
+ "interface": "hexagon",
48
+ "type_alias": "square",
49
+ "enum": "star",
50
+ "namespace": "database",
51
+ "function": "ellipse",
52
+ "method": "dot",
53
+ "symbol": "triangle",
54
+ }
55
+
56
+ _REL_COLOR: dict[str, str] = {
57
+ "CONTAINS": "#BDC3C7",
58
+ "CALLS": "#E74C3C",
59
+ "IMPORTS": "#3498DB",
60
+ "INHERITS": "#F39C12",
61
+ "IMPLEMENTS": "#D4AC0D",
62
+ "EXTENDS": "#E67E22",
63
+ }
64
+
65
+ # Honour the TSCODEKG_DB env var so the Docker image (which mounts
66
+ # persistent data at /data) works out of the box without the user
67
+ # having to change the sidebar path manually.
68
+ import os as _os # noqa: E402
69
+
70
+ _DEFAULT_DB = _os.environ.get("TSCODEKG_DB", ".tscodekg/graph.sqlite")
71
+ _DEFAULT_VECTORS = _os.environ.get("TSCODEKG_VECTORS", ".tscodekg/vectors.sqlite")
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Page config (must be first Streamlit call)
75
+ # ---------------------------------------------------------------------------
76
+
77
+ st.set_page_config(
78
+ page_title="TypeScriptKG Explorer",
79
+ page_icon="🕸️",
80
+ layout="wide",
81
+ initial_sidebar_state="expanded",
82
+ )
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Minimal CSS tweaks
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Session-state initialisation
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def _init_state() -> None:
95
+ """
96
+ Initialize Streamlit session state with default values.
97
+
98
+ Sets keys for database path, store, query/pack results, graph data,
99
+ TypeScriptKG instance, and selected node if they are not already present.
100
+ """
101
+ defaults = {
102
+ "db_path": _DEFAULT_DB,
103
+ "store": None,
104
+ "store_loaded_path": None,
105
+ "query_result": None,
106
+ "pack_result": None,
107
+ "graph_nodes": None,
108
+ "graph_edges": None,
109
+ "kg": None,
110
+ "kg_loaded_path": None,
111
+ "selected_node_id": None,
112
+ }
113
+ for k, v in defaults.items():
114
+ if k not in st.session_state:
115
+ st.session_state[k] = v
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Store helpers
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ @st.cache_resource(show_spinner="Opening SQLite store…")
124
+ def _load_store(db_path: str) -> GraphStore | None:
125
+ """
126
+ Load and cache a GraphStore from the given SQLite database path.
127
+
128
+ Returns ``None`` if the file does not exist.
129
+
130
+ :param db_path: Filesystem path to the SQLite database file.
131
+ :return: A connected ``GraphStore`` instance, or ``None`` if the file is absent.
132
+ """
133
+ p = Path(db_path)
134
+ if not p.exists():
135
+ return None
136
+ return GraphStore(db_path)
137
+
138
+
139
+ def _get_store() -> GraphStore | None:
140
+ """
141
+ Retrieve the current GraphStore, loading it if the database path has changed.
142
+
143
+ Compares the cached loaded path against ``st.session_state.db_path`` and
144
+ calls ``_load_store`` only when the path differs.
145
+
146
+ :return: The active ``GraphStore`` instance, or ``None`` if no database is available.
147
+ """
148
+ db = st.session_state.db_path
149
+ if st.session_state.store_loaded_path != db:
150
+ st.session_state.store = _load_store(db)
151
+ st.session_state.store_loaded_path = db
152
+ return st.session_state.store
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # TypeScriptKG helper (lazy, cached per (db_path, repo_root, model))
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ @st.cache_resource(show_spinner="Loading TypeScriptKG (embedder may take a moment)…")
161
+ def _load_kg(repo_root: str, db_path: str, vectors_path: str, model: str):
162
+ """
163
+ Load and cache a ``TypeScriptKG`` instance for the given configuration.
164
+
165
+ Keyed on all four parameters so that changing any one triggers a fresh load.
166
+ This is the main entry point for query/snippet execution in the UI and the
167
+ configuration boundary between Streamlit controls and runtime TypeScriptKG state.
168
+
169
+ :param repo_root: Root directory of the TypeScript/JavaScript repository to analyse.
170
+ :param db_path: Path to the SQLite graph database.
171
+ :param vectors_path: Path to the sqlite-vec vector store.
172
+ :param model: Name of the sentence-transformer embedding model to use.
173
+ :return: An initialised ``TypeScriptKG`` instance.
174
+ """
175
+ from tscode_kg import TypeScriptKG # noqa: PLC0415
176
+
177
+ return TypeScriptKG(
178
+ repo_root=repo_root,
179
+ db_path=db_path,
180
+ vectors_path=vectors_path,
181
+ model=model,
182
+ )
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # pyvis graph builder
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def _build_node_tooltip(n: dict, color: str) -> str:
191
+ """
192
+ Build a rich HTML tooltip for a pyvis node.
193
+
194
+ Shows: kind badge · qualname · module path · line range · full JSDoc.
195
+ Rendered inside the pyvis hover popup (supports basic HTML).
196
+
197
+ :param n: Node attribute dictionary containing keys such as ``kind``,
198
+ ``qualname``, ``module_path``, ``lineno``, ``end_lineno``, and
199
+ ``docstring``.
200
+ :param color: Hex colour string used for the kind badge and left border.
201
+ :return: An HTML string suitable for use as a pyvis node ``title``.
202
+ """
203
+ kind = n.get("kind", "symbol")
204
+ qualname = n.get("qualname") or n.get("name", "")
205
+ module = n.get("module_path") or ""
206
+ lineno = n.get("lineno")
207
+ end_lineno = n.get("end_lineno")
208
+ docstring = (n.get("docstring") or "").strip()
209
+
210
+ # Line range string
211
+ if lineno and end_lineno and end_lineno != lineno:
212
+ line_str = f"lines {lineno}–{end_lineno}"
213
+ elif lineno:
214
+ line_str = f"line {lineno}"
215
+ else:
216
+ line_str = ""
217
+
218
+ # JSDoc — show up to 8 lines, wrap long lines
219
+ doc_html = ""
220
+ if docstring:
221
+ doc_lines = docstring.splitlines()
222
+ shown = doc_lines[:8]
223
+ escaped = [
224
+ line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") for line in shown
225
+ ]
226
+ doc_html = (
227
+ "<hr style='border:0;border-top:1px solid #444;margin:6px 0;'>"
228
+ "<div style='font-family:monospace;font-size:11px;color:#ccc;"
229
+ "white-space:pre-wrap;max-width:380px;'>"
230
+ + "<br>".join(escaped)
231
+ + ("…" if len(doc_lines) > 8 else "")
232
+ + "</div>"
233
+ )
234
+
235
+ tooltip = (
236
+ f"<div style='font-family:sans-serif;font-size:12px;"
237
+ f"background:#1e1e2e;color:#e0e0e0;padding:10px 14px;"
238
+ f"border-radius:8px;border-left:4px solid {color};"
239
+ f"max-width:400px;'>"
240
+ f"<span style='background:{color};color:#fff;border-radius:4px;"
241
+ f"padding:1px 7px;font-size:11px;font-weight:bold;'>{kind}</span>"
242
+ f"&nbsp;&nbsp;<b style='font-size:13px;'>{qualname}</b>"
243
+ + (
244
+ f"<br><span style='color:#888;font-size:11px;'>"
245
+ f"📄 {module}" + (f" &nbsp;·&nbsp; {line_str}" if line_str else "") + "</span>"
246
+ if module
247
+ else ""
248
+ )
249
+ + doc_html
250
+ + "</div>"
251
+ )
252
+ return tooltip
253
+
254
+
255
+ def _build_pyvis(
256
+ nodes: list[dict],
257
+ edges: list[dict],
258
+ *,
259
+ height: str = "620px",
260
+ seed_ids: set[str] | None = None,
261
+ physics: bool = True,
262
+ ) -> str:
263
+ """
264
+ Build a pyvis Network from node/edge dicts and return the HTML string.
265
+
266
+ Seed nodes (from semantic search) are rendered with a gold border.
267
+ Hovering shows a rich tooltip; clicking a node opens a floating detail
268
+ panel inside the graph iframe with the full JSDoc and metadata.
269
+
270
+ :param nodes: List of node attribute dicts, each containing at minimum
271
+ an ``id`` key plus optional ``kind``, ``name``, ``qualname``,
272
+ ``module_path``, ``lineno``, ``end_lineno``, and ``docstring``.
273
+ :param edges: List of edge dicts with ``src``, ``dst``, and ``rel`` keys.
274
+ :param height: CSS height string for the iframe (e.g. ``"620px"``).
275
+ :param seed_ids: Set of node IDs that originated from the semantic seed
276
+ query; these are highlighted with a gold border.
277
+ :param physics: Whether to enable the Barnes-Hut physics simulation.
278
+ :return: A self-contained HTML string that renders the interactive graph.
279
+ """
280
+ net = Network(
281
+ height=height,
282
+ width="100%",
283
+ bgcolor="#0e1117",
284
+ font_color="#e0e0e0",
285
+ directed=True,
286
+ notebook=False,
287
+ )
288
+ net.set_options(
289
+ json.dumps(
290
+ {
291
+ "physics": {
292
+ "enabled": physics,
293
+ "barnesHut": {
294
+ "gravitationalConstant": -8000,
295
+ "centralGravity": 0.3,
296
+ "springLength": 120,
297
+ "springConstant": 0.04,
298
+ "damping": 0.09,
299
+ },
300
+ "stabilization": {"iterations": 150},
301
+ },
302
+ "edges": {
303
+ "smooth": {"type": "dynamic"},
304
+ "arrows": {"to": {"enabled": True, "scaleFactor": 0.6}},
305
+ "font": {"size": 10, "color": "#aaaaaa"},
306
+ },
307
+ "interaction": {
308
+ "hover": True,
309
+ "tooltipDelay": 80,
310
+ "navigationButtons": True,
311
+ "keyboard": True,
312
+ },
313
+ }
314
+ )
315
+ )
316
+
317
+ seed_ids = seed_ids or set()
318
+
319
+ # Build a JS-safe node data map for the click panel
320
+ node_data_js: dict[str, dict] = {}
321
+
322
+ for n in nodes:
323
+ kind = n.get("kind", "symbol")
324
+ color = _KIND_COLOR.get(kind, "#95A5A6")
325
+ shape = _KIND_SHAPE.get(kind, "dot")
326
+ label = n.get("name", n["id"])
327
+ if len(label) > 28:
328
+ label = label[:25] + "…"
329
+ border_color = "#FFD700" if n["id"] in seed_ids else color
330
+ tooltip = _build_node_tooltip(n, color)
331
+ net.add_node(
332
+ n["id"],
333
+ label=label,
334
+ title=tooltip,
335
+ color={
336
+ "background": color,
337
+ "border": border_color,
338
+ "highlight": {"background": color, "border": "#FFFFFF"},
339
+ },
340
+ shape=shape,
341
+ size=18 if kind in ("class", "module", "interface", "namespace") else 12,
342
+ borderWidth=3 if n["id"] in seed_ids else 1,
343
+ font={"size": 11},
344
+ )
345
+ node_data_js[n["id"]] = {
346
+ "id": n["id"],
347
+ "kind": kind,
348
+ "color": color,
349
+ "qualname": n.get("qualname") or n.get("name", ""),
350
+ "module": n.get("module_path") or "",
351
+ "lineno": n.get("lineno"),
352
+ "end_lineno": n.get("end_lineno"),
353
+ "docstring": (n.get("docstring") or "").strip(),
354
+ }
355
+
356
+ for e in edges:
357
+ rel = e.get("rel", "")
358
+ ecolor = _REL_COLOR.get(rel, "#888888")
359
+ net.add_edge(
360
+ e["src"],
361
+ e["dst"],
362
+ label=rel,
363
+ color=ecolor,
364
+ width=1.5,
365
+ title=rel,
366
+ )
367
+
368
+ # Write to a temp file and read back as HTML string
369
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w") as f:
370
+ tmp_path = f.name
371
+ net.save_graph(tmp_path)
372
+ html = Path(tmp_path).read_text(encoding="utf-8")
373
+ os.unlink(tmp_path)
374
+
375
+ # Inject: floating click-detail panel + node data map
376
+ node_data_json = json.dumps(node_data_js, ensure_ascii=False)
377
+
378
+ panel_css = """
379
+ <style>
380
+ #tscodekg-panel {
381
+ display: none;
382
+ position: fixed;
383
+ top: 12px;
384
+ right: 12px;
385
+ width: 340px;
386
+ max-height: 88vh;
387
+ overflow-y: auto;
388
+ background: #1e1e2e;
389
+ border-radius: 10px;
390
+ box-shadow: 0 4px 24px rgba(0,0,0,0.6);
391
+ z-index: 9999;
392
+ font-family: sans-serif;
393
+ font-size: 13px;
394
+ color: #e0e0e0;
395
+ }
396
+ #tscodekg-panel-inner { padding: 14px 16px 16px 16px; }
397
+ #tscodekg-panel-close {
398
+ position: absolute;
399
+ top: 8px; right: 10px;
400
+ cursor: pointer;
401
+ font-size: 18px;
402
+ color: #888;
403
+ line-height: 1;
404
+ background: none;
405
+ border: none;
406
+ }
407
+ #tscodekg-panel-close:hover { color: #fff; }
408
+ #tscodekg-panel-docstring {
409
+ background: #12121f;
410
+ border: 1px solid #2a2a3e;
411
+ border-radius: 6px;
412
+ padding: 8px 10px;
413
+ font-family: monospace;
414
+ font-size: 12px;
415
+ color: #c9d1d9;
416
+ white-space: pre-wrap;
417
+ word-break: break-word;
418
+ margin-top: 8px;
419
+ max-height: 300px;
420
+ overflow-y: auto;
421
+ }
422
+ </style>
423
+ """
424
+
425
+ panel_html = """
426
+ <div id="tscodekg-panel">
427
+ <button id="tscodekg-panel-close" onclick="document.getElementById('tscodekg-panel').style.display='none'">✕</button>
428
+ <div id="tscodekg-panel-inner">
429
+ <div id="tscodekg-panel-badge"></div>
430
+ <div id="tscodekg-panel-qualname" style="font-size:15px;font-weight:bold;margin:6px 0 2px 0;"></div>
431
+ <div id="tscodekg-panel-meta" style="color:#888;font-size:11px;font-family:monospace;"></div>
432
+ <div id="tscodekg-panel-id" style="color:#444;font-size:10px;font-family:monospace;margin-top:2px;"></div>
433
+ <div id="tscodekg-panel-docstring"></div>
434
+ </div>
435
+ </div>
436
+ """
437
+
438
+ panel_js = f"""
439
+ <script>
440
+ (function() {{
441
+ var NODE_DATA = {node_data_json};
442
+
443
+ function showPanel(nodeId) {{
444
+ var n = NODE_DATA[nodeId];
445
+ if (!n) return;
446
+
447
+ var panel = document.getElementById('tscodekg-panel');
448
+ var badge = document.getElementById('tscodekg-panel-badge');
449
+ var qname = document.getElementById('tscodekg-panel-qualname');
450
+ var meta = document.getElementById('tscodekg-panel-meta');
451
+ var nid = document.getElementById('tscodekg-panel-id');
452
+ var doc = document.getElementById('tscodekg-panel-docstring');
453
+
454
+ badge.innerHTML = '<span style="background:' + n.color + ';color:#fff;border-radius:4px;' +
455
+ 'padding:2px 8px;font-size:11px;font-weight:bold;font-family:monospace;">' +
456
+ n.kind + '</span>';
457
+
458
+ qname.textContent = n.qualname;
459
+ qname.style.color = '#f0f0f0';
460
+
461
+ var lineStr = '';
462
+ if (n.lineno && n.end_lineno && n.end_lineno !== n.lineno) {{
463
+ lineStr = ' · lines ' + n.lineno + '–' + n.end_lineno;
464
+ }} else if (n.lineno) {{
465
+ lineStr = ' · line ' + n.lineno;
466
+ }}
467
+ meta.textContent = (n.module || '—') + lineStr;
468
+
469
+ nid.textContent = 'id: ' + n.id;
470
+
471
+ if (n.docstring) {{
472
+ doc.style.display = 'block';
473
+ doc.textContent = n.docstring;
474
+ }} else {{
475
+ doc.style.display = 'block';
476
+ doc.textContent = '(no JSDoc)';
477
+ doc.style.color = '#555';
478
+ }}
479
+
480
+ panel.style.borderLeft = '5px solid ' + n.color;
481
+ panel.style.display = 'block';
482
+ }}
483
+
484
+ function fixHtmlTitles() {{
485
+ // vis-network renders string titles as plain text; swap in DOM elements
486
+ // so that the rich HTML tooltips display correctly.
487
+ var ids = network.body.data.nodes.getIds();
488
+ ids.forEach(function(id) {{
489
+ var node = network.body.data.nodes.get(id);
490
+ if (node && typeof node.title === 'string' && node.title.trim().charAt(0) === '<') {{
491
+ var div = document.createElement('div');
492
+ div.innerHTML = node.title;
493
+ network.body.data.nodes.update({{id: id, title: div}});
494
+ }}
495
+ }});
496
+ }}
497
+
498
+ function waitForNetwork() {{
499
+ if (typeof network === 'undefined') {{
500
+ setTimeout(waitForNetwork, 200);
501
+ return;
502
+ }}
503
+ fixHtmlTitles();
504
+ network.on('click', function(params) {{
505
+ if (params.nodes && params.nodes.length > 0) {{
506
+ showPanel(String(params.nodes[0]));
507
+ }} else {{
508
+ // click on empty space — hide panel
509
+ document.getElementById('tscodekg-panel').style.display = 'none';
510
+ }}
511
+ }});
512
+ }}
513
+ waitForNetwork();
514
+ }})();
515
+ </script>
516
+ """
517
+
518
+ html = html.replace("</head>", panel_css + "\n</head>")
519
+ html = html.replace("</body>", panel_html + panel_js + "\n</body>")
520
+ return html
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Node detail panel
525
+ # ---------------------------------------------------------------------------
526
+
527
+
528
+ def _render_node_detail(node: dict, store: GraphStore | None = None) -> None:
529
+ """
530
+ Render a rich detail card for a single node.
531
+
532
+ Shows: kind badge, qualname, module + line range, full JSDoc,
533
+ and (if store is provided) the node's immediate edges.
534
+
535
+ :param node: Node attribute dictionary containing at minimum an ``id``
536
+ key plus optional ``kind``, ``qualname``, ``name``, ``module_path``,
537
+ ``lineno``, ``end_lineno``, and ``docstring``.
538
+ :param store: An optional ``GraphStore`` used to look up adjacent edges.
539
+ When ``None``, the edges section is omitted.
540
+ """
541
+ kind = node.get("kind", "symbol")
542
+ color = _KIND_COLOR.get(kind, "#95A5A6")
543
+ qualname = node.get("qualname") or node.get("name", "")
544
+ module = node.get("module_path") or ""
545
+ lineno = node.get("lineno")
546
+ end_lineno = node.get("end_lineno")
547
+ docstring = (node.get("docstring") or "").strip()
548
+ node_id = node.get("id", "")
549
+
550
+ # Line range
551
+ if lineno and end_lineno and end_lineno != lineno:
552
+ line_str = f"lines {lineno}-{end_lineno}"
553
+ elif lineno:
554
+ line_str = f"line {lineno}"
555
+ else:
556
+ line_str = "-"
557
+
558
+ st.markdown(
559
+ f"""
560
+ <div style="background:#1e1e2e;border-left:5px solid {color};
561
+ border-radius:8px;padding:14px 18px;margin-bottom:8px;">
562
+ <span style="background:{color};color:#fff;border-radius:4px;
563
+ padding:2px 9px;font-size:12px;font-weight:bold;
564
+ font-family:monospace;">{kind}</span>
565
+ &nbsp;
566
+ <span style="font-size:17px;font-weight:bold;color:#f0f0f0;">
567
+ {qualname}
568
+ </span>
569
+ <br>
570
+ <span style="color:#888;font-size:12px;font-family:monospace;">
571
+ 📄 {module or "—"} &nbsp;·&nbsp; {line_str}
572
+ </span>
573
+ <br>
574
+ <span style="color:#555;font-size:10px;font-family:monospace;">
575
+ id: {node_id}
576
+ </span>
577
+ </div>
578
+ """,
579
+ unsafe_allow_html=True,
580
+ )
581
+
582
+ if docstring:
583
+ st.markdown("**📝 JSDoc**")
584
+ st.markdown(
585
+ f"""
586
+ <div style="background:#12121f;border-radius:6px;padding:10px 14px;
587
+ font-family:monospace;font-size:13px;color:#c9d1d9;
588
+ white-space:pre-wrap;border:1px solid #2a2a3e;">
589
+ {docstring.replace("<", "&lt;").replace(">", "&gt;")}
590
+ </div>
591
+ """,
592
+ unsafe_allow_html=True,
593
+ )
594
+ else:
595
+ st.caption("*No JSDoc.*")
596
+
597
+ # Immediate edges from the store
598
+ if store and node_id:
599
+ st.markdown("**🔗 Edges**")
600
+ try:
601
+ rows = store.con.execute(
602
+ "SELECT src, rel, dst FROM edges WHERE src = ? OR dst = ? LIMIT 60",
603
+ (node_id, node_id),
604
+ ).fetchall()
605
+ if rows:
606
+ import pandas as pd # noqa: PLC0415
607
+
608
+ edf = pd.DataFrame([{"src": r[0], "rel": r[1], "dst": r[2]} for r in rows])
609
+ st.dataframe(edf, use_container_width=True, hide_index=True)
610
+ else:
611
+ st.caption("*No edges.*")
612
+ except (AttributeError, ValueError, RuntimeError, KeyError):
613
+ pass
614
+
615
+
616
+ def _node_detail_section(
617
+ nodes: list[dict],
618
+ store: GraphStore | None,
619
+ *,
620
+ key_prefix: str = "detail",
621
+ ) -> None:
622
+ """
623
+ Render a searchable node-detail section below a graph.
624
+
625
+ Users pick a node from a selectbox (or type a name) and see the full
626
+ detail card. This is the Streamlit-native complement to the pyvis
627
+ hover tooltip — it persists on screen and shows the complete JSDoc
628
+ plus edge table.
629
+
630
+ :param nodes: List of node attribute dicts to populate the selectbox.
631
+ :param store: An optional ``GraphStore`` passed through to
632
+ ``_render_node_detail`` for edge lookups.
633
+ :param key_prefix: String prefix for Streamlit widget keys, used to
634
+ avoid key collisions when multiple instances are rendered on the
635
+ same page.
636
+ """
637
+ if not nodes:
638
+ return
639
+
640
+ st.markdown("---")
641
+ st.subheader("🔎 Node Detail")
642
+
643
+ # Build label → node mapping (qualname preferred, fall back to name)
644
+ label_map: dict[str, dict] = {}
645
+ for n in nodes:
646
+ lbl = n.get("qualname") or n.get("name") or n["id"]
647
+ # Disambiguate duplicates
648
+ if lbl in label_map:
649
+ lbl = f"{lbl} [{n['id']}]"
650
+ label_map[lbl] = n
651
+
652
+ options = ["— select a node —"] + sorted(label_map.keys())
653
+ chosen = st.selectbox(
654
+ "Select node to inspect",
655
+ options=options,
656
+ index=0,
657
+ key=f"{key_prefix}_node_select",
658
+ help="Pick any node to see its full JSDoc and edges.",
659
+ )
660
+
661
+ if chosen and chosen != "— select a node —":
662
+ node = label_map.get(chosen)
663
+ if node:
664
+ _render_node_detail(node, store=store)
665
+
666
+
667
+ # ---------------------------------------------------------------------------
668
+ # Legend widget
669
+ # ---------------------------------------------------------------------------
670
+
671
+
672
+ def _render_legend() -> None:
673
+ """
674
+ Render the graph legend showing node-kind colours and edge-relation colours.
675
+
676
+ Displays colour swatches for each entry in ``_KIND_COLOR`` and
677
+ ``_REL_COLOR`` as inline Streamlit markdown columns.
678
+ """
679
+ st.markdown("**Node kinds**")
680
+ cols = st.columns(len(_KIND_COLOR))
681
+ for col, (kind, color) in zip(cols, _KIND_COLOR.items()):
682
+ col.markdown(
683
+ f'<span style="display:inline-block;width:12px;height:12px;'
684
+ f'background:{color};border-radius:50%;margin-right:4px;"></span>'
685
+ f"`{kind}`",
686
+ unsafe_allow_html=True,
687
+ )
688
+ st.markdown("**Edge relations**")
689
+ cols2 = st.columns(len(_REL_COLOR))
690
+ for col, (rel, color) in zip(cols2, _REL_COLOR.items()):
691
+ col.markdown(
692
+ f'<span style="display:inline-block;width:20px;height:3px;'
693
+ f'background:{color};margin-right:4px;vertical-align:middle;"></span>'
694
+ f"`{rel}`",
695
+ unsafe_allow_html=True,
696
+ )
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # Sidebar
701
+ # ---------------------------------------------------------------------------
702
+
703
+
704
+ def _render_sidebar() -> dict:
705
+ """
706
+ Render the sidebar controls and return a configuration dictionary.
707
+
708
+ Exposes controls for the SQLite path, repo root, sqlite-vec store path,
709
+ embedding model, query parameters (k, hops, relations, include_symbols),
710
+ graph display options (max nodes, physics, height), and build buttons
711
+ for the graph and semantic index.
712
+
713
+ :return: A dict with keys ``db_path``, ``repo_root``, ``vectors_path``,
714
+ ``model``, ``k``, ``hop``, ``rels``, ``include_symbols``,
715
+ ``max_graph_nodes``, ``physics_on``, ``graph_height``, and ``store``.
716
+ """
717
+ st.sidebar.title("TypeScriptKG Explorer")
718
+ st.sidebar.markdown("---")
719
+
720
+ st.sidebar.subheader("Database")
721
+ db_path = st.sidebar.text_input(
722
+ "SQLite path",
723
+ value=st.session_state.db_path,
724
+ help="Path to .tscodekg/graph.sqlite (relative or absolute)",
725
+ )
726
+ st.session_state.db_path = db_path
727
+
728
+ store = _get_store()
729
+ if store is None:
730
+ st.sidebar.warning(
731
+ f"[WARNING] `{db_path}` not found.\n\n"
732
+ "Set **Repo root** below and click **Build Graph** to create it."
733
+ )
734
+ else:
735
+ s = store.stats()
736
+ st.sidebar.success(f"{s['total_nodes']} nodes · {s['total_edges']} edges")
737
+ with st.sidebar.expander("Node counts"):
738
+ for k, v in sorted(s["node_counts"].items()):
739
+ st.write(f"`{k}`: {v}")
740
+ with st.sidebar.expander("Edge counts"):
741
+ for k, v in sorted(s["edge_counts"].items()):
742
+ st.write(f"`{k}`: {v}")
743
+
744
+ st.sidebar.markdown("---")
745
+ st.sidebar.subheader("Paths & model")
746
+
747
+ repo_root = st.sidebar.text_input(
748
+ "Repo root",
749
+ value=str(Path.cwd()),
750
+ help="Root directory of the TypeScript/JavaScript repository to analyse",
751
+ )
752
+ vectors_path = st.sidebar.text_input(
753
+ "Vector store path",
754
+ value=_DEFAULT_VECTORS,
755
+ help="Path to the sqlite-vec vector store",
756
+ )
757
+ model = st.sidebar.selectbox(
758
+ "Embedding model",
759
+ [
760
+ "BAAI/bge-small-en-v1.5",
761
+ "all-mpnet-base-v2",
762
+ "all-MiniLM-L6-v2",
763
+ "jinaai/jina-embeddings-v3",
764
+ "paraphrase-MiniLM-L3-v2",
765
+ ],
766
+ index=0,
767
+ )
768
+ k = st.sidebar.slider("Top-K seeds (k)", min_value=1, max_value=30, value=8)
769
+ hop = st.sidebar.slider("Graph hops", min_value=0, max_value=4, value=1)
770
+
771
+ all_rels = list(DEFAULT_RELS)
772
+ chosen_rels = st.sidebar.multiselect(
773
+ "Edge relations",
774
+ options=all_rels,
775
+ default=all_rels,
776
+ )
777
+
778
+ include_symbols = st.sidebar.checkbox("Include symbol nodes", value=False)
779
+
780
+ st.sidebar.markdown("---")
781
+ st.sidebar.subheader("🗺️ Graph display")
782
+ max_graph_nodes = st.sidebar.slider(
783
+ "Max nodes in graph view", min_value=20, max_value=500, value=150, step=10
784
+ )
785
+ physics_on = st.sidebar.checkbox("Physics simulation", value=True)
786
+ graph_height = st.sidebar.select_slider(
787
+ "Graph height",
788
+ options=["400px", "500px", "620px", "750px", "900px"],
789
+ value="620px",
790
+ )
791
+
792
+ st.sidebar.markdown("---")
793
+ st.sidebar.subheader("🔨 Build pipeline")
794
+
795
+ build_col1, build_col2 = st.sidebar.columns(2)
796
+ build_graph_btn = build_col1.button(
797
+ "🔨 Build Graph",
798
+ help="Run AST extraction → SQLite (fast, no embeddings)",
799
+ use_container_width=True,
800
+ )
801
+ build_index_btn = build_col2.button(
802
+ "🧠 Build Index",
803
+ help="Embed nodes → sqlite-vec (requires graph to exist)",
804
+ use_container_width=True,
805
+ )
806
+ build_all_btn = st.sidebar.button(
807
+ "⚡ Build All (graph + index)",
808
+ help="Full pipeline: AST → SQLite → sqlite-vec",
809
+ use_container_width=True,
810
+ type="primary",
811
+ )
812
+
813
+ if build_graph_btn or build_all_btn:
814
+ with st.sidebar:
815
+ with st.spinner("Building graph (AST → SQLite)…"):
816
+ try:
817
+ from tscode_kg import ( # noqa: PLC0415
818
+ TypeScriptKG,
819
+ )
820
+
821
+ kg = TypeScriptKG(
822
+ repo_root=repo_root,
823
+ db_path=db_path,
824
+ vectors_path=vectors_path,
825
+ model=model,
826
+ )
827
+ if build_all_btn:
828
+ stats = kg.build(wipe=True)
829
+ st.success(
830
+ f"✅ Built: {stats.total_nodes} nodes, "
831
+ f"{stats.total_edges} edges, "
832
+ f"{stats.indexed_rows} vectors"
833
+ )
834
+ else:
835
+ stats = kg.build_graph(wipe=True)
836
+ st.success(
837
+ f"✅ Graph: {stats.total_nodes} nodes, {stats.total_edges} edges"
838
+ )
839
+ # Invalidate cached store so sidebar refreshes
840
+ st.session_state.store_loaded_path = None
841
+ st.session_state.graph_nodes = None
842
+ _load_store.clear() # type: ignore[attr-defined]
843
+ st.rerun()
844
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
845
+ st.error(f"Build failed: {exc}")
846
+
847
+ if build_index_btn and not build_all_btn:
848
+ with st.sidebar:
849
+ with st.spinner("Building semantic index (SQLite → sqlite-vec)…"):
850
+ try:
851
+ from tscode_kg import ( # noqa: PLC0415
852
+ TypeScriptKG,
853
+ )
854
+
855
+ kg = TypeScriptKG(
856
+ repo_root=repo_root,
857
+ db_path=db_path,
858
+ vectors_path=vectors_path,
859
+ model=model,
860
+ )
861
+ stats = kg.build_index(wipe=True)
862
+ st.success(f"✅ Index: {stats.indexed_rows} vectors (dim={stats.index_dim})")
863
+ _load_kg.clear() # type: ignore[attr-defined]
864
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
865
+ st.error(f"Index build failed: {exc}")
866
+
867
+ return {
868
+ "db_path": db_path,
869
+ "repo_root": repo_root,
870
+ "vectors_path": vectors_path,
871
+ "model": model,
872
+ "k": k,
873
+ "hop": hop,
874
+ "rels": tuple(chosen_rels) if chosen_rels else DEFAULT_RELS,
875
+ "include_symbols": include_symbols,
876
+ "max_graph_nodes": max_graph_nodes,
877
+ "physics_on": physics_on,
878
+ "graph_height": graph_height,
879
+ "store": store,
880
+ }
881
+
882
+
883
+ # ---------------------------------------------------------------------------
884
+ # Tab 1 — Full graph browser
885
+ # ---------------------------------------------------------------------------
886
+
887
+
888
+ def _tab_graph(cfg: dict) -> None:
889
+ """
890
+ Render the Graph Browser tab.
891
+
892
+ Loads nodes and edges from the store according to the sidebar filters,
893
+ displays the interactive pyvis graph, a node table expander, and the
894
+ node-detail section.
895
+
896
+ :param cfg: Configuration dictionary returned by ``_render_sidebar``,
897
+ providing keys such as ``store``, ``max_graph_nodes``,
898
+ ``graph_height``, and ``physics_on``.
899
+ """
900
+ st.header("🗺️ Knowledge Graph Browser")
901
+ store: GraphStore | None = cfg["store"]
902
+ if store is None:
903
+ st.warning("No database loaded. Set the SQLite path in the sidebar.")
904
+ return
905
+
906
+ col1, col2, col3 = st.columns([2, 2, 1])
907
+ with col1:
908
+ kind_filter = st.multiselect(
909
+ "Filter node kinds",
910
+ options=list(_KIND_COLOR.keys()),
911
+ default=[
912
+ "module",
913
+ "class",
914
+ "interface",
915
+ "type_alias",
916
+ "enum",
917
+ "namespace",
918
+ "function",
919
+ "method",
920
+ ],
921
+ key="graph_kind_filter",
922
+ )
923
+ with col2:
924
+ module_filter = st.text_input(
925
+ "Filter by module path (substring)",
926
+ value="",
927
+ key="graph_module_filter",
928
+ )
929
+ with col3:
930
+ st.write("")
931
+ st.write("")
932
+ load_btn = st.button("🔄 Load / Refresh", key="graph_load_btn", type="primary")
933
+
934
+ if load_btn or st.session_state.graph_nodes is None:
935
+ with st.spinner("Loading nodes and edges…"):
936
+ nodes = store.query_nodes(kinds=kind_filter if kind_filter else None)
937
+ if module_filter.strip():
938
+ nodes = [n for n in nodes if module_filter.strip() in (n.get("module_path") or "")]
939
+ max_n = cfg["max_graph_nodes"]
940
+ if len(nodes) > max_n:
941
+ st.info(f"Showing first {max_n} of {len(nodes)} nodes (increase limit in sidebar).")
942
+ nodes = nodes[:max_n]
943
+ node_ids = {n["id"] for n in nodes}
944
+ edges = store.edges_within(node_ids)
945
+ st.session_state.graph_nodes = nodes
946
+ st.session_state.graph_edges = edges
947
+
948
+ nodes = st.session_state.graph_nodes or []
949
+ edges = st.session_state.graph_edges or []
950
+
951
+ if not nodes:
952
+ st.info("No nodes match the current filters.")
953
+ return
954
+
955
+ st.caption(f"Showing **{len(nodes)}** nodes · **{len(edges)}** edges")
956
+ _render_legend()
957
+ st.markdown("---")
958
+
959
+ html = _build_pyvis(
960
+ nodes,
961
+ edges,
962
+ height=cfg["graph_height"],
963
+ physics=cfg["physics_on"],
964
+ )
965
+ st.iframe(html, height=int(cfg["graph_height"].replace("px", "")))
966
+
967
+ with st.expander("📋 Node table"):
968
+ import pandas as pd # noqa: PLC0415
969
+
970
+ df = pd.DataFrame(
971
+ [
972
+ {
973
+ "id": n["id"],
974
+ "kind": n["kind"],
975
+ "name": n["name"],
976
+ "qualname": n.get("qualname", ""),
977
+ "module": n.get("module_path", ""),
978
+ "line": n.get("lineno", ""),
979
+ }
980
+ for n in nodes
981
+ ]
982
+ )
983
+ st.dataframe(df, use_container_width=True, hide_index=True)
984
+
985
+ # Node detail panel — hover tooltip complement
986
+ _node_detail_section(nodes, store, key_prefix="graph")
987
+
988
+
989
+ # ---------------------------------------------------------------------------
990
+ # Tab 2 — Hybrid query
991
+ # ---------------------------------------------------------------------------
992
+
993
+
994
+ def _tab_query(cfg: dict) -> None:
995
+ """
996
+ Render the Hybrid Query tab.
997
+
998
+ Accepts a natural-language query, runs it through the TypeScriptKG hybrid
999
+ semantic+structural search, and displays the results as a graph,
1000
+ node table, edge table, and raw JSON (with a download button).
1001
+
1002
+ Error handling strategy: query execution failures are caught and surfaced
1003
+ as inline Streamlit errors so the app remains interactive.
1004
+
1005
+ :param cfg: Configuration dictionary returned by ``_render_sidebar``,
1006
+ providing keys such as ``store``, ``repo_root``, ``db_path``,
1007
+ ``vectors_path``, ``model``, ``k``, ``hop``, ``rels``,
1008
+ ``include_symbols``, ``graph_height``, and ``physics_on``.
1009
+ """
1010
+ st.header("🔍 Hybrid Query")
1011
+ store: GraphStore | None = cfg["store"]
1012
+ if store is None:
1013
+ st.warning("No database loaded. Set the SQLite path in the sidebar.")
1014
+ return
1015
+
1016
+ query_text = st.text_input(
1017
+ "Natural-language query",
1018
+ placeholder="e.g. database connection setup",
1019
+ key="query_input",
1020
+ )
1021
+
1022
+ run_btn = st.button("▶ Run Query", type="primary", key="run_query_btn")
1023
+
1024
+ if run_btn and query_text.strip():
1025
+ with st.spinner("Running hybrid query…"):
1026
+ try:
1027
+ kg = _load_kg(
1028
+ cfg["repo_root"],
1029
+ cfg["db_path"],
1030
+ cfg["vectors_path"],
1031
+ cfg["model"],
1032
+ )
1033
+ result = kg.query(
1034
+ query_text.strip(),
1035
+ k=cfg["k"],
1036
+ hop=cfg["hop"],
1037
+ rels=cfg["rels"],
1038
+ include_symbols=cfg["include_symbols"],
1039
+ )
1040
+ st.session_state.query_result = result
1041
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
1042
+ st.error(f"Query failed: {exc}")
1043
+ return
1044
+
1045
+ result = st.session_state.query_result
1046
+ if result is None:
1047
+ st.info("Enter a query above and click **Run Query**.")
1048
+ return
1049
+
1050
+ # Summary metrics
1051
+ c1, c2, c3, c4 = st.columns(4)
1052
+ c1.metric("Seeds", result.seeds)
1053
+ c2.metric("Expanded", result.expanded_nodes)
1054
+ c3.metric("Returned", result.returned_nodes)
1055
+ c4.metric("Edges", len(result.edges))
1056
+
1057
+ tab_graph, tab_table, tab_edges, tab_json = st.tabs(
1058
+ ["🗺️ Graph", "📋 Nodes", "🔗 Edges", "{ } JSON"]
1059
+ )
1060
+
1061
+ with tab_graph:
1062
+ if result.nodes:
1063
+ _render_legend()
1064
+ html = _build_pyvis(
1065
+ result.nodes,
1066
+ result.edges,
1067
+ height=cfg["graph_height"],
1068
+ physics=cfg["physics_on"],
1069
+ )
1070
+ st.iframe(html, height=int(cfg["graph_height"].replace("px", "")))
1071
+ else:
1072
+ st.info("No nodes to display.")
1073
+
1074
+ with tab_table:
1075
+ import pandas as pd # noqa: PLC0415
1076
+
1077
+ df = pd.DataFrame(
1078
+ [
1079
+ {
1080
+ "kind": n["kind"],
1081
+ "name": n["name"],
1082
+ "qualname": n.get("qualname", ""),
1083
+ "module": n.get("module_path", ""),
1084
+ "line": n.get("lineno", ""),
1085
+ "docstring": (
1086
+ (n.get("docstring") or "").strip().splitlines()[0][:80]
1087
+ if n.get("docstring")
1088
+ else ""
1089
+ ),
1090
+ }
1091
+ for n in result.nodes
1092
+ ]
1093
+ )
1094
+ st.dataframe(df, use_container_width=True, hide_index=True)
1095
+
1096
+ with tab_edges:
1097
+ if result.edges:
1098
+ import pandas as pd # noqa: PLC0415
1099
+
1100
+ edf = pd.DataFrame(
1101
+ [
1102
+ {"src": e["src"], "rel": e["rel"], "dst": e["dst"]}
1103
+ for e in sorted(result.edges, key=lambda x: (x["rel"], x["src"]))
1104
+ ]
1105
+ )
1106
+ st.dataframe(edf, use_container_width=True, hide_index=True)
1107
+ else:
1108
+ st.info("No edges in result set.")
1109
+
1110
+ with tab_json:
1111
+ st.download_button(
1112
+ "⬇ Download JSON",
1113
+ data=result.to_json(),
1114
+ file_name="query_result.json",
1115
+ mime="application/json",
1116
+ )
1117
+ st.code(result.to_json(), language="json")
1118
+
1119
+ # Node detail panel — below the sub-tabs
1120
+ _node_detail_section(result.nodes, store, key_prefix="query")
1121
+
1122
+
1123
+ # ---------------------------------------------------------------------------
1124
+ # Tab 3 — Snippet pack
1125
+ # ---------------------------------------------------------------------------
1126
+
1127
+
1128
+ def _tab_snippets(cfg: dict) -> None:
1129
+ """
1130
+ Render the Snippet Pack tab.
1131
+
1132
+ Accepts a query, builds a source-grounded snippet pack via TypeScriptKG,
1133
+ and displays metrics, download buttons, a pack graph, per-node
1134
+ expandable code snippets, and an edges table.
1135
+
1136
+ Configuration defaults come from sidebar controls (model, graph/index paths,
1137
+ and traversal parameters). Runtime failures are reported inline to preserve
1138
+ the interactive debugging loop.
1139
+
1140
+ :param cfg: Configuration dictionary returned by ``_render_sidebar``,
1141
+ providing keys such as ``store``, ``repo_root``, ``db_path``,
1142
+ ``vectors_path``, ``model``, ``k``, ``hop``, ``rels``,
1143
+ ``include_symbols``, and ``physics_on``.
1144
+ """
1145
+ st.header("📦 Snippet Pack")
1146
+ store: GraphStore | None = cfg["store"]
1147
+ if store is None:
1148
+ st.warning("No database loaded. Set the SQLite path in the sidebar.")
1149
+ return
1150
+
1151
+ col_q, col_ctx, col_ml, col_mn = st.columns([3, 1, 1, 1])
1152
+ with col_q:
1153
+ pack_query = st.text_input(
1154
+ "Query for snippet pack",
1155
+ placeholder="e.g. configuration loading",
1156
+ key="pack_query_input",
1157
+ )
1158
+ with col_ctx:
1159
+ context_lines = st.number_input("Context lines", min_value=0, max_value=20, value=5)
1160
+ with col_ml:
1161
+ max_lines = st.number_input(
1162
+ "Max lines/snippet", min_value=20, max_value=400, value=160, step=20
1163
+ )
1164
+ with col_mn:
1165
+ max_nodes = st.number_input("Max nodes", min_value=5, max_value=100, value=50, step=5)
1166
+
1167
+ pack_btn = st.button("📦 Build Pack", type="primary", key="pack_btn")
1168
+
1169
+ if pack_btn and pack_query.strip():
1170
+ with st.spinner("Building snippet pack…"):
1171
+ try:
1172
+ kg = _load_kg(
1173
+ cfg["repo_root"],
1174
+ cfg["db_path"],
1175
+ cfg["vectors_path"],
1176
+ cfg["model"],
1177
+ )
1178
+ pack = kg.pack(
1179
+ pack_query.strip(),
1180
+ k=cfg["k"],
1181
+ hop=cfg["hop"],
1182
+ rels=cfg["rels"],
1183
+ include_symbols=cfg["include_symbols"],
1184
+ context=int(context_lines),
1185
+ max_lines=int(max_lines),
1186
+ max_nodes=int(max_nodes),
1187
+ )
1188
+ st.session_state.pack_result = pack
1189
+ except (AttributeError, ValueError, RuntimeError, OSError) as exc:
1190
+ st.error(f"Pack failed: {exc}")
1191
+ return
1192
+
1193
+ pack = st.session_state.pack_result
1194
+ if pack is None:
1195
+ st.info("Enter a query above and click **Build Pack**.")
1196
+ return
1197
+
1198
+ # Summary
1199
+ c1, c2, c3, c4 = st.columns(4)
1200
+ c1.metric("Seeds", pack.seeds)
1201
+ c2.metric("Expanded", pack.expanded_nodes)
1202
+ c3.metric("Returned", pack.returned_nodes)
1203
+ c4.metric("Model", pack.model)
1204
+
1205
+ # Download buttons
1206
+ dl1, dl2 = st.columns(2)
1207
+ dl1.download_button(
1208
+ "⬇ Download Markdown",
1209
+ data=pack.to_markdown(),
1210
+ file_name="snippet_pack.md",
1211
+ mime="text/markdown",
1212
+ )
1213
+ dl2.download_button(
1214
+ "⬇ Download JSON",
1215
+ data=pack.to_json(),
1216
+ file_name="snippet_pack.json",
1217
+ mime="application/json",
1218
+ )
1219
+
1220
+ st.markdown("---")
1221
+
1222
+ # Graph of pack nodes
1223
+ with st.expander("🗺️ Pack graph", expanded=False):
1224
+ _render_legend()
1225
+ html = _build_pyvis(
1226
+ pack.nodes,
1227
+ pack.edges,
1228
+ height="500px",
1229
+ physics=cfg["physics_on"],
1230
+ )
1231
+ st.iframe(html, height=500)
1232
+
1233
+ # Node cards with snippets
1234
+ st.subheader(f"Nodes ({len(pack.nodes)})")
1235
+ for n in pack.nodes:
1236
+ kind = n.get("kind", "?")
1237
+ color = _KIND_COLOR.get(kind, "#95A5A6")
1238
+ qualname = n.get("qualname") or n.get("name", "")
1239
+ module = n.get("module_path") or ""
1240
+ lineno = n.get("lineno")
1241
+ doc = (n.get("docstring") or "").strip()
1242
+ doc0 = doc.splitlines()[0][:120] if doc else ""
1243
+ snippet = n.get("snippet")
1244
+
1245
+ header = f"**`{kind}`** — `{qualname}`"
1246
+ if module:
1247
+ header += f" · `{module}`"
1248
+ if lineno:
1249
+ header += f" line {lineno}"
1250
+
1251
+ with st.expander(header, expanded=bool(snippet)):
1252
+ st.markdown(
1253
+ f'<div style="border-left:4px solid {color};padding-left:10px;">'
1254
+ f'<code style="color:{color}">{kind}</code> '
1255
+ f"<b>{qualname}</b><br>"
1256
+ f'<small style="color:#888">{module}'
1257
+ + (f" · line {lineno}" if lineno else "")
1258
+ + "</small>"
1259
+ + (f"<br><i>{doc0}</i>" if doc0 else "")
1260
+ + "</div>",
1261
+ unsafe_allow_html=True,
1262
+ )
1263
+ if snippet:
1264
+ st.code(snippet["text"], language="typescript")
1265
+ st.caption(f"`{snippet['path']}` lines {snippet['start']}–{snippet['end']}")
1266
+ elif doc:
1267
+ st.markdown(f"*{doc[:300]}*")
1268
+
1269
+ # Edges table
1270
+ if pack.edges:
1271
+ with st.expander(f"🔗 Edges ({len(pack.edges)})"):
1272
+ import pandas as pd # noqa: PLC0415
1273
+
1274
+ edf = pd.DataFrame(
1275
+ [
1276
+ {"src": e["src"], "rel": e["rel"], "dst": e["dst"]}
1277
+ for e in sorted(pack.edges, key=lambda x: (x["rel"], x["src"]))
1278
+ ]
1279
+ )
1280
+ st.dataframe(edf, use_container_width=True, hide_index=True)
1281
+
1282
+
1283
+ # ---------------------------------------------------------------------------
1284
+ # Main
1285
+ # ---------------------------------------------------------------------------
1286
+
1287
+
1288
+ def _inject_css() -> None:
1289
+ """Inject CSS that matches Streamlit's active theme (light or dark)."""
1290
+ is_dark = st.get_option("theme.base") == "dark"
1291
+ card_bg = "rgba(255,255,255,0.06)" if is_dark else "rgba(0,0,0,0.04)"
1292
+ card_color = "#e0e0e0" if is_dark else "#111"
1293
+ small_color = "#aaa" if is_dark else "#555"
1294
+ edge_color = "#aaa" if is_dark else "#444"
1295
+ st.markdown(
1296
+ f"""
1297
+ <style>
1298
+ .stTabs [data-baseweb="tab-list"] {{ gap: 12px; }}
1299
+ .stTabs [data-baseweb="tab"] {{ font-size: 1rem; padding: 6px 18px; }}
1300
+ .node-card {{
1301
+ background: {card_bg};
1302
+ color: {card_color};
1303
+ border-left: 4px solid #4A90D9;
1304
+ border-radius: 6px;
1305
+ padding: 10px 14px;
1306
+ margin-bottom: 8px;
1307
+ font-family: monospace;
1308
+ font-size: 0.85rem;
1309
+ }}
1310
+ .node-card small {{ color: {small_color}; }}
1311
+ .edge-row {{ font-family: monospace; font-size: 0.82rem; color: {edge_color}; }}
1312
+ </style>
1313
+ """,
1314
+ unsafe_allow_html=True,
1315
+ )
1316
+
1317
+
1318
+ def main() -> None:
1319
+ """
1320
+ Application entry point for the TypeScriptKG Streamlit visualizer.
1321
+
1322
+ Initialises session state, renders the sidebar, and dispatches to the
1323
+ three tab renderers: Graph Browser, Hybrid Query, and Snippet Pack.
1324
+ """
1325
+ _init_state()
1326
+ _inject_css()
1327
+ cfg = _render_sidebar()
1328
+
1329
+ st.title("🕸️ TypeScriptKG Explorer")
1330
+ st.caption(
1331
+ "Interactive knowledge-graph browser for TypeScript/JavaScript codebases. "
1332
+ "Built with [TypeScriptKG](https://github.com/Flux-Frontiers/tscode_kg) · "
1333
+ "Powered by Streamlit + pyvis."
1334
+ )
1335
+
1336
+ tab1, tab2, tab3 = st.tabs(
1337
+ [
1338
+ "🗺️ Graph Browser",
1339
+ "🔍 Hybrid Query",
1340
+ "📦 Snippet Pack",
1341
+ ]
1342
+ )
1343
+
1344
+ with tab1:
1345
+ _tab_graph(cfg)
1346
+
1347
+ with tab2:
1348
+ _tab_query(cfg)
1349
+
1350
+ with tab3:
1351
+ _tab_snippets(cfg)
1352
+
1353
+
1354
+ if __name__ == "__main__":
1355
+ main()