repolens-cli 0.1.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.
repolens/tui/app.py ADDED
@@ -0,0 +1,951 @@
1
+ """RepoLens TUI — Textual-based terminal interface."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from rich.text import Text
9
+ from textual import on, work
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
13
+ from textual.reactive import reactive
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import (
16
+ Button,
17
+ Footer,
18
+ Header,
19
+ Input,
20
+ Label,
21
+ Markdown,
22
+ Static,
23
+ Tree,
24
+ )
25
+ from textual.widgets.tree import TreeNode
26
+
27
+ from ..models import FileAnalysis, FunctionNode, GraphStats, RepoAnalysis
28
+ from .. import ai_client, graph as graph_mod
29
+
30
+
31
+ # ── CSS ───────────────────────────────────────────────────────────────────────
32
+
33
+ CSS = """
34
+ Screen {
35
+ background: #1a1a2e;
36
+ }
37
+
38
+ Header {
39
+ background: #0d1b2a;
40
+ color: #a8d8ea;
41
+ text-style: bold;
42
+ }
43
+
44
+ Footer {
45
+ background: #0d1b2a;
46
+ color: #4a5568;
47
+ }
48
+
49
+ /* ── Sidebar ── */
50
+ #sidebar {
51
+ width: 28;
52
+ min-width: 14;
53
+ max-width: 60;
54
+ border-right: solid #1e3a5f;
55
+ background: #0d1b2a;
56
+ }
57
+
58
+ #sidebar.-focused-pane {
59
+ border-right: solid #a8d8ea;
60
+ }
61
+
62
+ #sidebar-label {
63
+ height: 2;
64
+ background: #0d1b2a;
65
+ color: #a8d8ea;
66
+ text-style: bold;
67
+ padding: 0 1;
68
+ border-bottom: solid #1e3a5f;
69
+ content-align: left middle;
70
+ }
71
+
72
+ #file-tree {
73
+ height: 1fr;
74
+ background: #0d1b2a;
75
+ padding: 0 0;
76
+ scrollbar-color: #1e3a5f;
77
+ scrollbar-background: #0d1b2a;
78
+ }
79
+
80
+ /* ── Main area ── */
81
+ #main-area {
82
+ background: #1a1a2e;
83
+ }
84
+
85
+ #tab-bar {
86
+ height: 3;
87
+ background: #0d1b2a;
88
+ padding: 0 1;
89
+ }
90
+
91
+ .tab-btn {
92
+ background: #0d1b2a;
93
+ border: none;
94
+ color: #4a5568;
95
+ min-width: 16;
96
+ height: 3;
97
+ padding: 1 2 0 2;
98
+ }
99
+
100
+ .tab-btn:hover {
101
+ background: #1e3a5f;
102
+ color: #cbd5e0;
103
+ padding: 1 2 0 2;
104
+ }
105
+
106
+ .tab-btn.-active {
107
+ background: #1e3a5f;
108
+ border: solid #a8d8ea;
109
+ color: #a8d8ea;
110
+ text-style: bold;
111
+ padding: 0 2;
112
+ }
113
+
114
+ #content-area {
115
+ height: 1fr;
116
+ padding: 1 3;
117
+ background: #1a1a2e;
118
+ overflow: scroll scroll;
119
+ border-top: solid #1e3a5f;
120
+ }
121
+
122
+ #content-area.-focused-pane {
123
+ border: solid #a8d8ea;
124
+ }
125
+
126
+ #stats-bar {
127
+ height: 2;
128
+ background: #0d1b2a;
129
+ border-top: solid #1e3a5f;
130
+ padding: 0 3;
131
+ color: #4a5568;
132
+ content-align: left middle;
133
+ }
134
+
135
+ /* AI chat screen */
136
+ AIScreen {
137
+ align: center middle;
138
+ }
139
+
140
+ #ai-dialog {
141
+ width: 86%;
142
+ height: 86%;
143
+ border: solid #0f3460;
144
+ background: #16213e;
145
+ }
146
+
147
+ #ai-header {
148
+ height: 3;
149
+ width: 100%;
150
+ background: #0f3460;
151
+ padding: 0 2;
152
+ color: #a8d8ea;
153
+ text-style: bold;
154
+ content-align: left middle;
155
+ }
156
+
157
+ #chat-history {
158
+ height: 1fr;
159
+ width: 100%;
160
+ padding: 1 2;
161
+ overflow-y: auto;
162
+ background: #16213e;
163
+ }
164
+
165
+ .msg-user {
166
+ color: #a8d8ea;
167
+ text-style: bold;
168
+ margin-top: 1;
169
+ }
170
+
171
+ .msg-user-text {
172
+ color: #e2e8f0;
173
+ margin-left: 4;
174
+ margin-bottom: 1;
175
+ }
176
+
177
+ .msg-ai-label {
178
+ color: #68d391;
179
+ text-style: bold;
180
+ }
181
+
182
+ .msg-thinking {
183
+ color: #718096;
184
+ text-style: italic;
185
+ margin-left: 4;
186
+ margin-bottom: 1;
187
+ }
188
+
189
+ .msg-divider {
190
+ color: #2d3748;
191
+ margin-top: 1;
192
+ margin-bottom: 1;
193
+ }
194
+
195
+ #ai-input-bar {
196
+ height: 5;
197
+ width: 100%;
198
+ dock: bottom;
199
+ background: #0f3460;
200
+ padding: 1 1;
201
+ align: left middle;
202
+ }
203
+
204
+ #ai-input {
205
+ width: 1fr;
206
+ border: solid #4a5568;
207
+ background: #1a1a2e;
208
+ color: #e2e8f0;
209
+ }
210
+
211
+ #ai-input:focus {
212
+ border: solid #a8d8ea;
213
+ }
214
+
215
+ #btn-close-chat {
216
+ width: 12;
217
+ height: 3;
218
+ margin-left: 1;
219
+ background: #1a1a2e;
220
+ border: solid #4a5568;
221
+ color: #a0aec0;
222
+ }
223
+
224
+ #btn-close-chat:hover {
225
+ background: #2d3748;
226
+ color: #e2e8f0;
227
+ }
228
+
229
+ /* Dep content */
230
+ .section-title {
231
+ color: #a8d8ea;
232
+ text-style: bold;
233
+ margin-top: 1;
234
+ }
235
+
236
+ .circular {
237
+ color: #fc8181;
238
+ }
239
+
240
+ .hub {
241
+ color: #f6ad55;
242
+ }
243
+
244
+ .entry {
245
+ color: #68d391;
246
+ }
247
+
248
+ .file-path {
249
+ color: #90cdf4;
250
+ }
251
+
252
+ .import-arrow {
253
+ color: #718096;
254
+ }
255
+
256
+ .count-badge {
257
+ color: #f6ad55;
258
+ }
259
+ """
260
+
261
+
262
+ # ── AI Chat Modal ─────────────────────────────────────────────────────────────
263
+
264
+ class AIScreen(ModalScreen):
265
+ BINDINGS = [Binding("escape", "dismiss", "Close")]
266
+
267
+ def __init__(self, analysis: RepoAnalysis, mode: str = "ask") -> None:
268
+ super().__init__()
269
+ self._analysis = analysis
270
+ self._mode = mode
271
+ self._history: list[dict] = []
272
+ self._thinking = False
273
+ self._thinking_widget: Optional[Markdown] = None # tracked by ref, no ID games
274
+
275
+ def compose(self) -> ComposeResult:
276
+ with Vertical(id="ai-dialog"):
277
+ if self._mode == "onboard":
278
+ yield Label(" Onboarding Guide", id="ai-header")
279
+ else:
280
+ yield Label(" Ask AI — multi-turn chat (Esc to close)", id="ai-header")
281
+
282
+ yield ScrollableContainer(id="chat-history")
283
+
284
+ if self._mode == "ask":
285
+ with Horizontal(id="ai-input-bar"):
286
+ yield Input(
287
+ placeholder="Ask a follow-up… (Enter to send)",
288
+ id="ai-input",
289
+ )
290
+ yield Button("Close", id="btn-close-chat")
291
+
292
+ def on_mount(self) -> None:
293
+ if self._mode == "onboard":
294
+ self._run_onboard()
295
+ else:
296
+ self.query_one("#ai-input", Input).focus()
297
+
298
+ # ── Chat history rendering ────────────────────────────────────────────────
299
+
300
+ def _append_user_bubble(self, question: str) -> None:
301
+ container = self.query_one("#chat-history", ScrollableContainer)
302
+ container.mount(Label("You", classes="msg-user"))
303
+ container.mount(Label(question, classes="msg-user-text"))
304
+ container.scroll_end(animate=False)
305
+
306
+ def _append_thinking(self) -> None:
307
+ container = self.query_one("#chat-history", ScrollableContainer)
308
+ container.mount(Label("RepoLens AI", classes="msg-ai-label"))
309
+ self._thinking_widget = Markdown("_thinking…_", classes="msg-thinking")
310
+ container.mount(self._thinking_widget)
311
+ container.scroll_end(animate=False)
312
+
313
+ def _replace_thinking(self, answer: str) -> None:
314
+ if self._thinking_widget is not None:
315
+ self._thinking_widget.update(answer)
316
+ self._thinking_widget = None
317
+ container = self.query_one("#chat-history", ScrollableContainer)
318
+ container.mount(Label("─" * 60, classes="msg-divider"))
319
+ container.scroll_end(animate=False)
320
+
321
+ # ── Onboarding mode ───────────────────────────────────────────────────────
322
+
323
+ @work(thread=True)
324
+ def _run_onboard(self) -> None:
325
+ container = self.app.call_from_thread(self._start_onboard_ui)
326
+ try:
327
+ result = ai_client.generate_onboarding(self._analysis)
328
+ except Exception as exc:
329
+ result = f"**Error:** {exc}"
330
+ self.app.call_from_thread(self._finish_onboard_ui, result)
331
+
332
+ def _start_onboard_ui(self) -> None:
333
+ container = self.query_one("#chat-history", ScrollableContainer)
334
+ container.mount(Label("RepoLens AI", classes="msg-ai-label"))
335
+ self._thinking_widget = Markdown("_Generating onboarding guide…_", classes="msg-thinking")
336
+ container.mount(self._thinking_widget)
337
+
338
+ def _finish_onboard_ui(self, text: str) -> None:
339
+ self._replace_thinking(text)
340
+
341
+ # ── Ask / follow-up ───────────────────────────────────────────────────────
342
+
343
+ @on(Input.Submitted, "#ai-input")
344
+ def _on_submitted(self, event: Input.Submitted) -> None:
345
+ question = event.value.strip()
346
+ if not question or self._thinking:
347
+ return
348
+ self._thinking = True
349
+ self.query_one("#ai-input", Input).value = ""
350
+ self._append_user_bubble(question)
351
+ self._append_thinking()
352
+ self._run_ask(question)
353
+
354
+ @work(thread=True)
355
+ def _run_ask(self, question: str) -> None:
356
+ try:
357
+ answer = ai_client.ask(self._analysis, question, history=list(self._history))
358
+ except Exception as exc:
359
+ answer = f"**Error:** {exc}"
360
+ # Update history for next turn
361
+ self._history.append({"role": "user", "content": question})
362
+ self._history.append({"role": "assistant", "content": answer})
363
+ self.app.call_from_thread(self._on_answer_ready, answer)
364
+
365
+ def _on_answer_ready(self, answer: str) -> None:
366
+ self._replace_thinking(answer)
367
+ self._thinking = False
368
+ self.query_one("#ai-input", Input).focus()
369
+
370
+ @on(Button.Pressed, "#btn-close-chat")
371
+ def _close(self) -> None:
372
+ self.dismiss()
373
+
374
+
375
+ # ── Main App ──────────────────────────────────────────────────────────────────
376
+
377
+ class RepoLensApp(App):
378
+ TITLE = "RepoLens"
379
+ CSS = CSS
380
+
381
+ BINDINGS = [
382
+ Binding("q", "quit", "Quit"),
383
+ Binding("1", "tab_deps", "Deps"),
384
+ Binding("2", "tab_calls", "Calls"),
385
+ Binding("3", "tab_graph", "Full Graph"),
386
+ Binding("a", "ask_ai", "Ask AI"),
387
+ Binding("o", "onboard", "Onboard"),
388
+ Binding("f", "focus_next_pane", "Focus pane"),
389
+ Binding("]", "sidebar_grow", "Sidebar ▶"),
390
+ Binding("[", "sidebar_shrink", "◀ Sidebar"),
391
+ Binding("j", "cursor_down", show=False),
392
+ Binding("k", "cursor_up", show=False),
393
+ ]
394
+
395
+ current_tab: reactive[str] = reactive("deps")
396
+ selected_file: reactive[Optional[str]] = reactive(None)
397
+ sidebar_width: reactive[int] = reactive(28)
398
+ _focus_on_content: bool = False
399
+
400
+ def __init__(self, analysis: RepoAnalysis) -> None:
401
+ super().__init__()
402
+ self._analysis = analysis
403
+
404
+ def compose(self) -> ComposeResult:
405
+ yield Header(show_clock=True)
406
+ with Horizontal():
407
+ with Vertical(id="sidebar"):
408
+ yield Label(" FILES", id="sidebar-label")
409
+ yield Tree(".", id="file-tree")
410
+ with Vertical(id="main-area"):
411
+ with Horizontal(id="tab-bar"):
412
+ yield Button("1 Dependencies", classes="tab-btn -active", id="tab-deps-btn")
413
+ yield Button("2 Call Graph", classes="tab-btn", id="tab-calls-btn")
414
+ yield Button("3 Full Graph", classes="tab-btn", id="tab-graph-btn")
415
+ yield ScrollableContainer(
416
+ Static("", id="content"),
417
+ id="content-area",
418
+ )
419
+ yield Static("", id="stats-bar")
420
+ yield Footer()
421
+
422
+ def on_mount(self) -> None:
423
+ self._populate_file_tree()
424
+ self._update_stats_bar()
425
+ self._render_content()
426
+
427
+ # ── File Tree ─────────────────────────────────────────────────────────────
428
+
429
+ def _populate_file_tree(self) -> None:
430
+ tree = self.query_one("#file-tree", Tree)
431
+ tree.root.expand()
432
+ stats = self._analysis.stats
433
+ root_path = Path(self._analysis.root).name
434
+
435
+ # Build directory hierarchy
436
+ dir_nodes: dict[str, TreeNode] = {}
437
+
438
+ def get_dir_node(parts: list[str]) -> TreeNode:
439
+ key = "/".join(parts)
440
+ if key in dir_nodes:
441
+ return dir_nodes[key]
442
+ if len(parts) == 1:
443
+ node = tree.root.add(f" {parts[0]}/", expand=True)
444
+ else:
445
+ parent = get_dir_node(parts[:-1])
446
+ node = parent.add(f" {parts[-1]}/", expand=True)
447
+ dir_nodes[key] = node
448
+ return node
449
+
450
+ for file_node in self._analysis.files:
451
+ parts = file_node.path.split("/")
452
+ in_deg = stats.in_degree.get(file_node.path, 0)
453
+
454
+ lang_icon = {
455
+ "python": "🐍",
456
+ "javascript": "󰌞",
457
+ "typescript": "󰛦",
458
+ "go": "󰟓",
459
+ "rust": "",
460
+ }.get(file_node.language, "")
461
+
462
+ label = parts[-1]
463
+ if in_deg > 0:
464
+ label += f" ({in_deg} importers)"
465
+
466
+ is_circular = any(file_node.path in c for c in stats.circular_deps)
467
+ is_hub = in_deg >= 5
468
+
469
+ rich_label = Text(label)
470
+ if is_circular:
471
+ rich_label.stylize("bold red")
472
+ elif is_hub:
473
+ rich_label.stylize("bold yellow")
474
+
475
+ if len(parts) == 1:
476
+ leaf = tree.root.add_leaf(label)
477
+ else:
478
+ parent = get_dir_node(parts[:-1])
479
+ leaf = parent.add_leaf(label)
480
+
481
+ leaf.data = file_node.path # store path for selection
482
+
483
+ tree.root.label = Text(f" {root_path} ({len(self._analysis.files)} files)")
484
+
485
+ # ── Stats Bar ─────────────────────────────────────────────────────────────
486
+
487
+ def _update_stats_bar(self) -> None:
488
+ stats = self._analysis.stats
489
+ n_files = len(self._analysis.files)
490
+ n_funcs = len(stats.functions)
491
+ n_cycles = len(stats.circular_deps)
492
+ cycle_str = (
493
+ f" [red]! {n_cycles} circular dep{'s' if n_cycles != 1 else ''}[/]"
494
+ if n_cycles else
495
+ " [green]no circular deps[/]"
496
+ )
497
+ text = (
498
+ f" [bold]{n_files}[/] files"
499
+ f" [bold]{n_funcs}[/] functions"
500
+ f"{cycle_str}"
501
+ f" [bold]{len(stats.entry_points)}[/] entry points"
502
+ f" [dim][ / ] resize [f] switch pane[/]"
503
+ )
504
+ self.query_one("#stats-bar", Static).update(text)
505
+
506
+ # ── Tab switching ─────────────────────────────────────────────────────────
507
+
508
+ def _set_active_tab(self, tab: str) -> None:
509
+ self.current_tab = tab
510
+ for btn_id in ("tab-deps-btn", "tab-calls-btn", "tab-graph-btn"):
511
+ btn = self.query_one(f"#{btn_id}", Button)
512
+ btn.remove_class("-active")
513
+ self.query_one(f"#tab-{tab}-btn", Button).add_class("-active")
514
+ self._render_content()
515
+
516
+ @on(Button.Pressed, "#tab-deps-btn")
517
+ def _tab_deps(self) -> None:
518
+ self._set_active_tab("deps")
519
+
520
+ @on(Button.Pressed, "#tab-calls-btn")
521
+ def _tab_calls(self) -> None:
522
+ self._set_active_tab("calls")
523
+
524
+ @on(Button.Pressed, "#tab-graph-btn")
525
+ def _tab_graph(self) -> None:
526
+ self._set_active_tab("graph")
527
+
528
+ def action_tab_deps(self) -> None:
529
+ self._set_active_tab("deps")
530
+
531
+ def action_tab_calls(self) -> None:
532
+ self._set_active_tab("calls")
533
+
534
+ def action_tab_graph(self) -> None:
535
+ self._set_active_tab("graph")
536
+
537
+ # ── Tree selection ────────────────────────────────────────────────────────
538
+
539
+ @on(Tree.NodeHighlighted, "#file-tree")
540
+ def _on_file_highlighted(self, event: Tree.NodeHighlighted) -> None:
541
+ # data is set on file leaves; directory nodes have no data → show overview
542
+ self.selected_file = event.node.data or None
543
+ self._render_content()
544
+
545
+ # ── Content rendering ─────────────────────────────────────────────────────
546
+
547
+ def _render_content(self) -> None:
548
+ content = self.query_one("#content", Static)
549
+ if self.selected_file and self.selected_file.endswith(".md"):
550
+ content.update(self._render_doc_file())
551
+ return
552
+ if self.current_tab == "deps":
553
+ content.update(self._render_deps())
554
+ elif self.current_tab == "calls":
555
+ content.update(self._render_calls())
556
+ elif self.current_tab == "graph":
557
+ content.update(self._render_full_graph())
558
+
559
+ def _render_doc_file(self) -> Text:
560
+ t = Text()
561
+ file_node = next((f for f in self._analysis.files if f.path == self.selected_file), None)
562
+ if not file_node or not file_node.content:
563
+ t.append(" (empty)", style="#4a5568")
564
+ return t
565
+ t.append(f"\n {self.selected_file}\n\n", style="bold cyan")
566
+ t.append(file_node.content, style="#e2e8f0")
567
+ return t
568
+
569
+ # ── Tree-drawing helpers ──────────────────────────────────────────────────
570
+
571
+ @staticmethod
572
+ def _branch(t: Text, indent: str, items: list[tuple[str, str, str, str]]) -> None:
573
+ """Append tree-branch lines to *t*.
574
+
575
+ items: list of (prefix_label, prefix_style, body_label, body_style)
576
+ Uses ├──→ for all but the last item, └──→ for the last.
577
+ """
578
+ for i, (pre_label, pre_style, body_label, body_style) in enumerate(items):
579
+ is_last = i == len(items) - 1
580
+ connector = "└── " if is_last else "├── "
581
+ t.append(indent + connector, style="#4a5568")
582
+ t.append(pre_label, style=pre_style)
583
+ if body_label:
584
+ t.append(body_label, style=body_style)
585
+ t.append("\n")
586
+
587
+ @staticmethod
588
+ def _branch_arrow(
589
+ t: Text,
590
+ indent: str,
591
+ items: list[tuple[str, str]], # (label, style)
592
+ arrow: str = "──→",
593
+ ) -> None:
594
+ """Append arrow-branch lines (├──→ / └──→) for dependency lists."""
595
+ for i, (label, style) in enumerate(items):
596
+ is_last = i == len(items) - 1
597
+ connector = f"└{arrow} " if is_last else f"├{arrow} "
598
+ t.append(indent + connector, style="#4a5568")
599
+ t.append(label + "\n", style=style)
600
+
601
+ # ── Deps view ─────────────────────────────────────────────────────────────
602
+
603
+ def _render_deps(self) -> Text:
604
+ stats = self._analysis.stats
605
+ t = Text()
606
+
607
+ if self.selected_file:
608
+ fa = self._analysis.file_analyses.get(self.selected_file)
609
+ t.append(f"\n {self.selected_file}\n", style="bold cyan")
610
+
611
+ if fa:
612
+ deps = stats.import_edges.get(self.selected_file, [])
613
+ importers = graph_mod.importers_of(self.selected_file, stats)
614
+ has_funcs = bool(fa.functions)
615
+ has_classes = bool(fa.classes)
616
+
617
+ # ── IMPORTS ──────────────────────────────────────────────────
618
+ section_connector = "├── " if (importers or has_funcs or has_classes) else "└── "
619
+ t.append(f" {section_connector}", style="#4a5568")
620
+ t.append("IMPORTS\n", style="bold #a8d8ea")
621
+
622
+ if deps:
623
+ cont = "│ " if (importers or has_funcs or has_classes) else " "
624
+ dep_items = []
625
+ for dep in deps:
626
+ in_deg = stats.in_degree.get(dep, 0)
627
+ is_circ = any(self.selected_file in c and dep in c for c in stats.circular_deps)
628
+ label = dep
629
+ if in_deg > 0:
630
+ label += f" (used by {in_deg} files)"
631
+ if is_circ:
632
+ label += " ⚠ CIRCULAR"
633
+ dep_items.append((label, "bold red" if is_circ else "#90cdf4"))
634
+ self._branch_arrow(t, f" {cont}", dep_items, arrow="──→")
635
+ else:
636
+ cont = "│ " if (importers or has_funcs or has_classes) else " "
637
+ t.append(f" {cont} (no local imports)\n", style="#4a5568")
638
+
639
+ # ── IMPORTED BY ───────────────────────────────────────────────
640
+ if importers or has_funcs or has_classes:
641
+ section_connector = "├── " if (has_funcs or has_classes) else "└── "
642
+ t.append(f" {section_connector}", style="#4a5568")
643
+ t.append("IMPORTED BY\n", style="bold #a8d8ea")
644
+ cont = "│ " if (has_funcs or has_classes) else " "
645
+ if importers:
646
+ imp_items = [(imp, "#90cdf4") for imp in importers]
647
+ self._branch_arrow(t, f" {cont}", imp_items, arrow="──←")
648
+ else:
649
+ t.append(f" {cont} (entry point — nothing imports this)\n", style="#68d391")
650
+
651
+ # ── FUNCTIONS ────────────────────────────────────────────────
652
+ if has_funcs:
653
+ section_connector = "├── " if has_classes else "└── "
654
+ t.append(f" {section_connector}", style="#4a5568")
655
+ t.append("FUNCTIONS\n", style="bold #a8d8ea")
656
+ cont = "│ " if has_classes else " "
657
+ fns = fa.functions[:20]
658
+ for i, fn in enumerate(fns):
659
+ is_last_fn = i == len(fns) - 1
660
+ fn_conn = "└── " if is_last_fn else "├── "
661
+ fn_cont = " " if is_last_fn else "│ "
662
+ # function name + line number
663
+ t.append(f" {cont}{fn_conn}", style="#4a5568")
664
+ t.append(fn.name, style="bold #e2e8f0")
665
+ t.append(f" line {fn.line_start}\n", style="#718096")
666
+ # docstring directly under its function, labelled
667
+ if fn.docstring:
668
+ t.append(f" {cont}{fn_cont} ", style="#4a5568")
669
+ t.append('"""', style="#4a5568")
670
+ t.append(f" {fn.docstring}\n", style="italic #a0aec0")
671
+
672
+ # ── CLASSES ──────────────────────────────────────────────────
673
+ if has_classes:
674
+ t.append(" └── ", style="#4a5568")
675
+ t.append("CLASSES\n", style="bold #a8d8ea")
676
+ cls_items = [("", "", cls, "#e2e8f0") for cls in fa.classes]
677
+ self._branch(t, " ", cls_items)
678
+
679
+ else:
680
+ # ── Overview ─────────────────────────────────────────────────────
681
+ t.append("\n DEPENDENCY OVERVIEW\n", style="bold #a8d8ea")
682
+
683
+ if stats.circular_deps:
684
+ t.append("\n ⚠ CIRCULAR DEPENDENCIES\n", style="bold red")
685
+ for cycle in stats.circular_deps:
686
+ t.append(" │\n", style="#4a5568")
687
+ chain = " ──→ ".join(cycle) + " ──→ " + cycle[0]
688
+ t.append(f" └── {chain}\n", style="red")
689
+
690
+ t.append("\n MOST IMPORTED FILES\n", style="bold #a8d8ea")
691
+ hub = [(p, c) for p, c in stats.hub_files[:10] if c > 0]
692
+ for i, (path, count) in enumerate(hub):
693
+ is_last = i == len(hub) - 1
694
+ conn = "└── " if is_last else "├── "
695
+ bar = "▪" * min(count, 15)
696
+ t.append(f" {conn}", style="#4a5568")
697
+ t.append(f"{bar} {count:>2} ", style="#f6ad55")
698
+ t.append(path + "\n", style="#90cdf4")
699
+
700
+ t.append("\n ENTRY POINTS\n", style="bold #a8d8ea")
701
+ eps = stats.entry_points[:15]
702
+ for i, ep in enumerate(eps):
703
+ is_last = i == len(eps) - 1
704
+ conn = "└──> " if is_last else "├──> "
705
+ t.append(f" {conn}", style="#4a5568")
706
+ t.append(ep + "\n", style="#68d391")
707
+
708
+ return t
709
+
710
+ # ── Call graph view ───────────────────────────────────────────────────────
711
+
712
+ def _render_calls(self) -> Text:
713
+ stats = self._analysis.stats
714
+ t = Text()
715
+
716
+ if self.selected_file:
717
+ fa = self._analysis.file_analyses.get(self.selected_file)
718
+ t.append(f"\n {self.selected_file}\n", style="bold cyan")
719
+
720
+ if fa and fa.functions:
721
+ for fn_idx, fn in enumerate(fa.functions[:30]):
722
+ fid = f"{self.selected_file}::{fn.name}"
723
+ fn_obj = stats.functions.get(fid)
724
+ callees = graph_mod.callees_of(fid, stats) if fn_obj else []
725
+ callers = graph_mod.callers_of(fid, stats) if fn_obj else []
726
+
727
+ is_last_fn = fn_idx == len(fa.functions[:30]) - 1
728
+ fn_conn = "└── " if is_last_fn else "├── "
729
+ fn_cont = " " if is_last_fn else "│ "
730
+
731
+ # Function header
732
+ t.append(f" {fn_conn}", style="#4a5568")
733
+ t.append(f"fn {fn.name}", style="bold #e2e8f0")
734
+ t.append(f" line {fn.line_start}\n", style="#718096")
735
+
736
+ # Docstring — labelled inline under the function name
737
+ if fn.docstring:
738
+ t.append(f" {fn_cont} ", style="#4a5568")
739
+ t.append('"""', style="#4a5568")
740
+ t.append(f" {fn.docstring}\n", style="italic #a0aec0")
741
+
742
+ has_calls = bool(callees)
743
+ has_callers = bool(callers)
744
+
745
+ # what this function calls
746
+ if has_calls:
747
+ sub_conn = "├── " if has_callers else "└── "
748
+ sub_cont = "│ " if has_callers else " "
749
+ t.append(f" {fn_cont}{sub_conn}", style="#4a5568")
750
+ t.append(f"calls {len(callees)} function(s)\n", style="#a0aec0")
751
+ call_items = [(c, "#90cdf4") for c in callees[:8]]
752
+ self._branch_arrow(t, f" {fn_cont}{sub_cont}", call_items, arrow="──→")
753
+
754
+ # what calls this function
755
+ if has_callers:
756
+ t.append(f" {fn_cont}└── ", style="#4a5568")
757
+ t.append(f"called by {len(callers)} function(s)\n", style="#a0aec0")
758
+ caller_items = [(c, "#68d391") for c in callers[:8]]
759
+ self._branch_arrow(t, f" {fn_cont} ", caller_items, arrow="──←")
760
+
761
+ if not is_last_fn:
762
+ t.append(f" │\n", style="#4a5568")
763
+ else:
764
+ t.append(" └── (no functions found)\n", style="#718096")
765
+
766
+ else:
767
+ self._render_call_overview(t, stats)
768
+
769
+ return t
770
+
771
+ def _render_call_overview(self, t: Text, stats: "GraphStats") -> None:
772
+ all_fns = list(stats.functions.values())
773
+ total = len(all_fns)
774
+ t.append(f"\n CALL GRAPH · {total} functions\n", style="bold #a8d8ea")
775
+
776
+ if not all_fns:
777
+ t.append(" No functions found.\n", style="#718096")
778
+ return
779
+
780
+ # ── Ranked table ─────────────────────────────────────────────────────
781
+ by_total = sorted(all_fns, key=lambda f: len(f.callers) + len(f.calls), reverse=True)[:20]
782
+ max_callers = max((len(f.callers) for f in by_total), default=1) or 1
783
+ max_calls = max((len(f.calls) for f in by_total), default=1) or 1
784
+
785
+ t.append("\n MOST CONNECTED FUNCTIONS\n", style="bold #a8d8ea")
786
+ # column header
787
+ t.append(" " + "─" * 72 + "\n", style="#2d3748")
788
+ t.append(
789
+ f" {'#':<4}{'function':<26}{'callers':<22}{'calls':<22}{'file'}\n",
790
+ style="#4a5568",
791
+ )
792
+ t.append(" " + "─" * 72 + "\n", style="#2d3748")
793
+
794
+ for rank, fn in enumerate(by_total, 1):
795
+ n_callers = len(fn.callers)
796
+ n_calls = len(fn.calls)
797
+
798
+ # colour-code by role
799
+ if n_callers == 0:
800
+ fn_style = "#68d391" # green — entry / standalone
801
+ elif n_calls == 0:
802
+ fn_style = "#fc8181" # red — sink / leaf
803
+ elif n_callers >= 4:
804
+ fn_style = "#f6ad55" # orange — hot hub
805
+ else:
806
+ fn_style = "#e2e8f0" # white — normal
807
+
808
+ # proportional bars (max 10 chars each)
809
+ caller_bar = "▪" * round(n_callers / max_callers * 10)
810
+ calls_bar = "▪" * round(n_calls / max_calls * 10)
811
+
812
+ caller_col = f"{caller_bar:<10} {n_callers}"
813
+ calls_col = f"{calls_bar:<10} {n_calls}"
814
+
815
+ fname = fn.name[:24]
816
+ fpath = fn.file_path
817
+
818
+ t.append(f" {rank:<4}", style="#4a5568")
819
+ t.append(f"{fname:<26}", style=fn_style)
820
+ t.append(f"{caller_col:<22}", style="#a8d8ea")
821
+ t.append(f"{calls_col:<22}", style="#90cdf4")
822
+ t.append(f"{fpath}\n", style="#4a5568")
823
+
824
+ t.append(" " + "─" * 72 + "\n", style="#2d3748")
825
+
826
+ # ── Legend ────────────────────────────────────────────────────────────
827
+ t.append("\n LEGEND ", style="#4a5568")
828
+ t.append("* ", style="#68d391"); t.append("entry (nothing calls it) ", style="#718096")
829
+ t.append("* ", style="#f6ad55"); t.append("hub (called 4+ times) ", style="#718096")
830
+ t.append("* ", style="#fc8181"); t.append("leaf (calls nothing)\n", style="#718096")
831
+
832
+ # ── Entry functions ───────────────────────────────────────────────────
833
+ entries = [f for f in all_fns if not f.callers][:10]
834
+ if entries:
835
+ t.append("\n ENTRY FUNCTIONS (nothing calls these — start reading here)\n",
836
+ style="bold #a8d8ea")
837
+ entry_items = [(fn.name, "#68d391", f" {fn.file_path}", "#4a5568") for fn in entries]
838
+ self._branch(t, " ", entry_items)
839
+
840
+ # ── Hottest hubs ──────────────────────────────────────────────────────
841
+ hubs = [f for f in all_fns if len(f.callers) >= 4]
842
+ if hubs:
843
+ hubs.sort(key=lambda f: len(f.callers), reverse=True)
844
+ t.append("\n HOT HUBS (called most frequently — high-impact functions)\n",
845
+ style="bold #a8d8ea")
846
+ hub_items = [
847
+ (fn.name, "#f6ad55", f" called {len(fn.callers)}× · {fn.file_path}", "#4a5568")
848
+ for fn in hubs[:8]
849
+ ]
850
+ self._branch(t, " ", hub_items)
851
+
852
+ # ── Full graph view ───────────────────────────────────────────────────────
853
+
854
+ def _render_full_graph(self) -> Text:
855
+ stats = self._analysis.stats
856
+ t = Text()
857
+ t.append("\n FULL IMPORT GRAPH\n", style="bold #a8d8ea")
858
+
859
+ entries = [(src, deps) for src, deps in sorted(stats.import_edges.items()) if deps]
860
+
861
+ if not entries:
862
+ t.append(" └── No inter-file imports found.\n", style="#718096")
863
+ return t
864
+
865
+ for src_idx, (src, deps) in enumerate(entries):
866
+ is_last_src = src_idx == len(entries) - 1
867
+ src_conn = "└── " if is_last_src else "├── "
868
+ src_cont = " " if is_last_src else "│ "
869
+
870
+ is_circ = any(src in c for c in stats.circular_deps)
871
+ t.append(f"\n {src_conn}", style="#4a5568")
872
+ t.append(src, style="bold red" if is_circ else "bold #90cdf4")
873
+ if is_circ:
874
+ t.append(" ⚠ CIRCULAR", style="bold red")
875
+ t.append("\n")
876
+
877
+ dep_items = []
878
+ for dep in deps:
879
+ dep_circ = any(dep in c for c in stats.circular_deps)
880
+ in_deg = stats.in_degree.get(dep, 0)
881
+ label = dep + (f" (used by {in_deg} files)" if in_deg > 1 else "")
882
+ dep_items.append((label, "red" if dep_circ else "#a0aec0"))
883
+ self._branch_arrow(t, f" {src_cont}", dep_items, arrow="──→")
884
+
885
+ return t
886
+
887
+ # ── Sidebar resize ────────────────────────────────────────────────────────
888
+
889
+ def watch_sidebar_width(self, width: int) -> None:
890
+ self.query_one("#sidebar").styles.width = width
891
+
892
+ def action_sidebar_grow(self) -> None:
893
+ self.sidebar_width = min(self.sidebar_width + 2, 60)
894
+
895
+ def action_sidebar_shrink(self) -> None:
896
+ self.sidebar_width = max(self.sidebar_width - 2, 14)
897
+
898
+ # ── Pane focus switching ──────────────────────────────────────────────────
899
+
900
+ def action_focus_next_pane(self) -> None:
901
+ self._focus_on_content = not self._focus_on_content
902
+ if self._focus_on_content:
903
+ area = self.query_one("#content-area", ScrollableContainer)
904
+ area.focus()
905
+ area.add_class("-focused-pane")
906
+ self.query_one("#sidebar").remove_class("-focused-pane")
907
+ else:
908
+ self.query_one("#file-tree", Tree).focus()
909
+ self.query_one("#sidebar").add_class("-focused-pane")
910
+ self.query_one("#content-area", ScrollableContainer).remove_class("-focused-pane")
911
+
912
+ # ── Override j/k to go to correct widget ─────────────────────────────────
913
+
914
+ def action_cursor_down(self) -> None:
915
+ if self._focus_on_content:
916
+ self.query_one("#content-area", ScrollableContainer).scroll_down()
917
+ else:
918
+ self.query_one("#file-tree", Tree).action_cursor_down()
919
+
920
+ def action_cursor_up(self) -> None:
921
+ if self._focus_on_content:
922
+ self.query_one("#content-area", ScrollableContainer).scroll_up()
923
+ else:
924
+ self.query_one("#file-tree", Tree).action_cursor_up()
925
+
926
+ # ── Actions ───────────────────────────────────────────────────────────────
927
+
928
+ def action_ask_ai(self) -> None:
929
+ if not ai_client.is_configured():
930
+ self.notify(
931
+ "No AI provider configured. Set GEMINI_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, or ANTHROPIC_API_KEY.",
932
+ title="AI not configured",
933
+ severity="warning",
934
+ timeout=6,
935
+ )
936
+ return
937
+ self.push_screen(AIScreen(self._analysis, mode="ask"))
938
+
939
+ def action_onboard(self) -> None:
940
+ if not ai_client.is_configured():
941
+ self.notify(
942
+ "No AI provider configured. Set GEMINI_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, or ANTHROPIC_API_KEY.",
943
+ title="AI not configured",
944
+ severity="warning",
945
+ timeout=6,
946
+ )
947
+ return
948
+ self.push_screen(AIScreen(self._analysis, mode="onboard"))
949
+
950
+ def action_refresh_view(self) -> None:
951
+ self._render_content()