Skip to content

Commit 0e35887

Browse files
clanker-loverclaude
andcommitted
Add setup wizard, GUI launcher, and always-visible code buttons
- Show code action buttons immediately in interactive mode instead of gating on AI analysis completion - Add GET /api/source/{node_id} endpoint to fetch source without triggering AI analysis - Add interactive setup wizard when running codedocent with no args (folder picker, Ollama detection, model selection, mode choice) - Add tkinter GUI launcher via --gui flag and codedocent-gui entry point - Extract shared Ollama utilities to codedocent/ollama_utils.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 54b1515 commit 0e35887

9 files changed

Lines changed: 586 additions & 3 deletions

File tree

codedocent/cli.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33
from __future__ import annotations
44

55
import argparse
6+
import os
67

8+
from codedocent.ollama_utils import check_ollama, fetch_ollama_models
79
from codedocent.parser import CodeNode, parse_directory
810
from codedocent.scanner import scan_directory
911

1012

13+
# Re-export for backwards compatibility and testability
14+
_check_ollama = check_ollama
15+
_fetch_ollama_models = fetch_ollama_models
16+
17+
1118
def print_tree(node: CodeNode, indent: int = 0) -> None:
1219
"""Print a text representation of the code tree."""
1320
prefix = " " * indent
@@ -34,13 +41,103 @@ def print_tree(node: CodeNode, indent: int = 0) -> None:
3441
print_tree(child, indent + 1)
3542

3643

44+
def _ask_folder() -> str:
45+
"""Prompt for a valid folder path, re-asking on invalid input."""
46+
while True:
47+
path = input("What folder do you want to analyze? ").strip()
48+
path = os.path.expanduser(path)
49+
if os.path.isdir(path):
50+
file_count = len(list(scan_directory(path)))
51+
print(f"\u2713 Found {file_count} files\n")
52+
return path
53+
print(f" '{path}' is not a valid directory. Try again.\n")
54+
55+
56+
def _ask_no_ai_fallback() -> bool:
57+
"""Ask user whether to continue without AI. Returns True for no-ai."""
58+
fallback = input("Continue without AI? [Y/n]: ").strip().lower()
59+
if fallback in ("", "y", "yes"):
60+
return True
61+
raise SystemExit(0)
62+
63+
64+
def _pick_model(models: list[str]) -> str:
65+
"""Let the user pick from a numbered list of models."""
66+
print("Available models:")
67+
for i, m in enumerate(models, 1):
68+
print(f" {i}. {m}")
69+
choice = input("Which model? [1]: ").strip()
70+
if choice == "":
71+
return models[0]
72+
try:
73+
idx = int(choice) - 1
74+
if 0 <= idx < len(models):
75+
return models[idx]
76+
except ValueError:
77+
pass
78+
return models[0]
79+
80+
81+
def _run_wizard() -> argparse.Namespace: # pylint: disable=too-many-branches
82+
"""Interactive setup wizard for codedocent."""
83+
print("\ncodedocent \u2014 code visualization for humans\n")
84+
85+
path = _ask_folder()
86+
87+
# --- Ollama check ---
88+
model = "qwen3:14b"
89+
no_ai = False
90+
91+
print("Checking for Ollama...", end=" ", flush=True)
92+
if _check_ollama():
93+
print("found!")
94+
models = _fetch_ollama_models()
95+
if models:
96+
model = _pick_model(models)
97+
else:
98+
print("No models found.")
99+
no_ai = _ask_no_ai_fallback()
100+
else:
101+
print("not found.")
102+
no_ai = _ask_no_ai_fallback()
103+
104+
# --- Mode ---
105+
print("\nHow do you want to view it?")
106+
print(" 1. Interactive \u2014 browse in browser [default]")
107+
print(" 2. Full export \u2014 analyze everything, save HTML")
108+
print(" 3. Text tree \u2014 plain text in terminal")
109+
mode_choice = input("Choice [1]: ").strip()
110+
111+
text = mode_choice == "3"
112+
full = mode_choice == "2"
113+
114+
print()
115+
116+
return argparse.Namespace(
117+
path=path,
118+
text=text,
119+
output="codedocent_output.html",
120+
model=model,
121+
no_ai=no_ai,
122+
full=full,
123+
port=None,
124+
workers=1,
125+
gui=False,
126+
)
127+
128+
37129
def main() -> None:
38130
"""Entry point for the codedocent CLI."""
39131
parser = argparse.ArgumentParser(
40132
prog="codedocent",
41133
description="Code visualization for non-programmers",
42134
)
43-
parser.add_argument("path", help="Path to the directory to scan")
135+
parser.add_argument(
136+
"path",
137+
nargs="?",
138+
default=None,
139+
help="Path to the directory to scan",
140+
)
44141
parser.add_argument(
45142
"--text",
46143
action="store_true",
@@ -85,9 +182,23 @@ def main() -> None:
85182
default=1,
86183
help="Number of parallel AI workers for --full mode (default: 1)",
87184
)
185+
parser.add_argument(
186+
"--gui",
187+
action="store_true",
188+
help="Open GUI launcher",
189+
)
88190

89191
args = parser.parse_args()
90192

193+
if args.gui:
194+
from codedocent.gui import main as gui_main # pylint: disable=import-outside-toplevel # noqa: E501
195+
196+
gui_main()
197+
return
198+
199+
if args.path is None:
200+
args = _run_wizard()
201+
91202
scanned = scan_directory(args.path)
92203
tree = parse_directory(scanned, root=args.path)
93204

codedocent/gui.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Tkinter GUI launcher for codedocent."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess # nosec B404
6+
import sys
7+
8+
from codedocent.ollama_utils import check_ollama, fetch_ollama_models
9+
10+
try:
11+
import tkinter as tk
12+
from tkinter import ttk, filedialog
13+
except ImportError:
14+
tk = None # type: ignore[assignment]
15+
16+
_HAS_TK = tk is not None
17+
18+
# Re-export for testability
19+
_check_ollama = check_ollama
20+
_fetch_ollama_models = fetch_ollama_models
21+
22+
23+
def _build_gui() -> None:
24+
"""Build and run the tkinter GUI window."""
25+
root = tk.Tk()
26+
root.title("codedocent")
27+
root.resizable(False, False)
28+
29+
frame = ttk.Frame(root, padding=16)
30+
frame.grid(row=0, column=0, sticky="nsew")
31+
32+
# --- Folder picker ---
33+
ttk.Label(frame, text="Folder to analyze:").grid(
34+
row=0, column=0, sticky="w", pady=(0, 4),
35+
)
36+
37+
folder_var = tk.StringVar()
38+
folder_entry = ttk.Entry(frame, textvariable=folder_var, width=40)
39+
folder_entry.grid(row=1, column=0, sticky="ew", padx=(0, 6))
40+
41+
def _browse() -> None:
42+
path = filedialog.askdirectory()
43+
if path:
44+
folder_var.set(path)
45+
46+
ttk.Button(frame, text="Browse...", command=_browse).grid(
47+
row=1, column=1, sticky="w",
48+
)
49+
50+
# --- Model dropdown ---
51+
ttk.Label(frame, text="Model:").grid(
52+
row=2, column=0, sticky="w", pady=(12, 4),
53+
)
54+
55+
ollama_ok = _check_ollama()
56+
models = _fetch_ollama_models() if ollama_ok else []
57+
58+
model_values = models if models else ["No AI"]
59+
model_var = tk.StringVar(value=model_values[0])
60+
model_combo = ttk.Combobox(
61+
frame, textvariable=model_var, values=model_values,
62+
state="readonly", width=37,
63+
)
64+
model_combo.grid(row=3, column=0, columnspan=2, sticky="ew")
65+
66+
# --- Mode selector ---
67+
ttk.Label(frame, text="Mode:").grid(
68+
row=4, column=0, sticky="w", pady=(12, 4),
69+
)
70+
71+
mode_var = tk.StringVar(value="interactive")
72+
modes_frame = ttk.Frame(frame)
73+
modes_frame.grid(row=5, column=0, columnspan=2, sticky="w")
74+
75+
ttk.Radiobutton(
76+
modes_frame, text="Interactive", variable=mode_var,
77+
value="interactive",
78+
).pack(anchor="w")
79+
ttk.Radiobutton(
80+
modes_frame, text="Full export", variable=mode_var,
81+
value="full",
82+
).pack(anchor="w")
83+
ttk.Radiobutton(
84+
modes_frame, text="Text tree", variable=mode_var,
85+
value="text",
86+
).pack(anchor="w")
87+
88+
# --- Go button ---
89+
def _go() -> None:
90+
folder = folder_var.get().strip()
91+
if not folder:
92+
return
93+
94+
cmd = [sys.executable, "-m", "codedocent", folder]
95+
96+
selected_model = model_var.get()
97+
if selected_model == "No AI":
98+
cmd.append("--no-ai")
99+
else:
100+
cmd.extend(["--model", selected_model])
101+
102+
mode = mode_var.get()
103+
if mode == "full":
104+
cmd.append("--full")
105+
elif mode == "text":
106+
cmd.append("--text")
107+
108+
subprocess.Popen(cmd) # pylint: disable=consider-using-with # nosec B603 # noqa: E501
109+
root.destroy()
110+
111+
ttk.Button(frame, text="Go", command=_go).grid(
112+
row=6, column=0, columnspan=2, pady=(16, 0),
113+
)
114+
115+
root.mainloop()
116+
117+
118+
def main() -> None:
119+
"""Entry point for codedocent-gui."""
120+
if not _HAS_TK:
121+
print("tkinter is not installed.")
122+
print("Install it with: sudo apt install python3-tk")
123+
raise SystemExit(1)
124+
_build_gui()
125+
126+
127+
if __name__ == "__main__":
128+
main()

codedocent/ollama_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Shared Ollama utility functions for CLI and GUI."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import urllib.request
7+
8+
9+
def check_ollama() -> bool:
10+
"""Return True if Ollama is reachable at localhost:11434."""
11+
try:
12+
req = urllib.request.Request(
13+
"http://localhost:11434", method="GET",
14+
)
15+
with urllib.request.urlopen(req, timeout=3): # nosec B310
16+
return True
17+
except (OSError, urllib.error.URLError):
18+
return False
19+
20+
21+
def fetch_ollama_models() -> list[str]:
22+
"""Fetch model names from the Ollama API."""
23+
try:
24+
req = urllib.request.Request(
25+
"http://localhost:11434/api/tags", method="GET",
26+
)
27+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310
28+
data = json.loads(resp.read().decode())
29+
return [m["name"] for m in data.get("models", [])]
30+
except (OSError, urllib.error.URLError, json.JSONDecodeError, KeyError):
31+
return []

codedocent/server.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ def do_GET(self): # pylint: disable=invalid-name
108108
self._serve_html()
109109
elif self.path == "/api/tree":
110110
self._serve_tree()
111+
elif self.path.startswith("/api/source/"):
112+
node_id = self.path[len("/api/source/"):]
113+
self._handle_source(node_id)
111114
else:
112115
self.send_error(404)
113116

@@ -142,6 +145,19 @@ def _serve_tree(self):
142145
self.end_headers()
143146
self.wfile.write(data)
144147

148+
def _handle_source(self, node_id: str):
149+
if node_id not in node_lookup:
150+
self.send_error(404, "Unknown node ID")
151+
return
152+
node = node_lookup[node_id]
153+
result = {"source": node.source or ""}
154+
data = json.dumps(result).encode("utf-8")
155+
self.send_response(200)
156+
self.send_header("Content-Type", "application/json")
157+
self.send_header("Content-Length", str(len(data)))
158+
self.end_headers()
159+
self.wfile.write(data)
160+
145161
def _handle_analyze(self, node_id: str):
146162
if node_id not in node_lookup:
147163
self.send_error(404, "Unknown node ID")

codedocent/templates/interactive.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ <h1 class="cd-header__title">codedocent</h1>
539539
callback(sourceCache[nodeId]);
540540
return;
541541
}
542-
fetch('/api/analyze/' + nodeId, {method: 'POST'})
542+
fetch('/api/source/' + nodeId)
543543
.then(function(r) { return r.json(); })
544544
.then(function(data) {
545545
if (data.source) {
@@ -871,7 +871,7 @@ <h1 class="cd-header__title">codedocent</h1>
871871
}
872872

873873
// Code export buttons (for pre-analyzed nodes with summaries at load time)
874-
if (!isDir && node.node_id && node.summary) {
874+
if (!isDir && node.node_id) {
875875
cdCreateCodeActions(body, node.node_id, node.node_type, node.name, node.filepath, node.language);
876876
}
877877

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ codedocent = ["templates/*.html"]
2525

2626
[project.scripts]
2727
codedocent = "codedocent.cli:main"
28+
codedocent-gui = "codedocent.gui:main"

0 commit comments

Comments
 (0)