Version 1.0

This commit is contained in:
Alexander R.
2026-07-21 22:24:25 +00:00
parent f3645fbfbc
commit 9edbfd3f77
4134 changed files with 1448752 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware
from src.core.loader import ChallengeLoader
from src.core.executor import executor
from src.core.linter import linter
from src.core.prompts import get_socratic_prompt
app = FastAPI(title="Socratic Tutor API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
loader = ChallengeLoader()
class LintRequest(BaseModel):
language: str
code: str
from typing import Optional
class GuideRequest(BaseModel):
challenge_id: str
language: str
code: str
question: Optional[str] = ""
class RunRequest(BaseModel):
language: str
code: str
stdin: Optional[str] = ""
@app.get("/challenges")
def list_challenges():
"""Returns a list of all challenge details."""
challenges = loader.load_all()
return {"challenges": [c.to_dict(challenge_id=k) for k, c in sorted(challenges.items())]}
@app.get("/challenges/{challenge_id}")
def get_challenge(challenge_id: str):
"""Returns details of a single challenge."""
challenges = loader.load_all()
if challenge_id not in challenges:
raise HTTPException(status_code=404, detail=f"Challenge '{challenge_id}' not found.")
return challenges[challenge_id].to_dict(challenge_id=challenge_id)
@app.post("/lint")
def lint_code(req: LintRequest):
"""Runs linting on user code using Core Linter."""
res = linter.lint(req.language, req.code)
if res.get("stderr") == f"Language '{req.language}' is not supported.":
raise HTTPException(status_code=404, detail=res["stderr"])
return res
@app.post("/run")
def run_code(req: RunRequest):
"""Executes user code using Core Executor."""
res = executor.run(req.language, req.code, stdin=req.stdin or "")
if res.get("stderr") == f"Language '{req.language}' is not supported.":
raise HTTPException(status_code=404, detail=res["stderr"])
return res
from src.core.mentor import mentor
from src.core.generator import generator
class GenerateChallengeRequest(BaseModel):
prompt: str
difficulty: Optional[str] = "Medium"
language: Optional[str] = "Python"
subject: Optional[str] = "General"
class SaveChallengeRequest(BaseModel):
filename: str
markdown: str
@app.post("/guide")
async def guide_code(req: GuideRequest):
"""Provides mentor guidance based on user's code and question for a challenge."""
challenges = loader.load_all()
if req.challenge_id not in challenges:
raise HTTPException(status_code=404, detail=f"Challenge '{req.challenge_id}' not found.")
challenge = challenges[req.challenge_id]
guidance = await mentor.get_guidance_async(
challenge, req.code, user_question=req.question or ""
)
return {
"challenge_id": req.challenge_id,
"status": guidance.get("status"),
"mentor_response": guidance.get("mentor_response"),
}
@app.post("/challenges/generate")
async def generate_challenge_endpoint(req: GenerateChallengeRequest):
"""Generates a new challenge markdown document via LLM."""
res = await generator.generate_challenge_async(
prompt_text=req.prompt,
difficulty=req.difficulty or "Medium",
language=req.language or "Python",
subject=req.subject or "General",
)
if res.get("status") == "error":
raise HTTPException(status_code=500, detail=res.get("message"))
return res
@app.post("/challenges/save")
def save_challenge_endpoint(req: SaveChallengeRequest):
"""Saves generated markdown challenge content to disk."""
res = generator.save_challenge(req.filename, req.markdown)
if res.get("status") == "error":
raise HTTPException(status_code=500, detail=res.get("message"))
return res
from src.core.handbook import handbook_service
class HandbookExampleRequest(BaseModel):
language: str
topic_id: str
topic_title: str
@app.get("/handbook/catalog/{language}")
def get_handbook_catalog_endpoint(language: str):
"""Returns W3Schools-style syntax handbook catalog (functions & subjects) for specified language."""
catalog = handbook_service.get_catalog(language)
return {"language": language, "catalog": catalog}
@app.get("/handbook/topics/{language}")
def get_handbook_topics_endpoint(language: str):
"""Returns syntax handbook catalog for backwards compatibility."""
catalog = handbook_service.get_catalog(language)
return {"language": language, "topics": catalog.get("functions", []) + catalog.get("subjects", [])}
@app.post("/handbook/example")
async def get_handbook_example_endpoint(req: HandbookExampleRequest):
"""Generates an LLM code example for a handbook reference topic."""
res = await handbook_service.generate_example_async(
language=req.language,
topic_id=req.topic_id,
topic_title=req.topic_title,
)
if res.get("status") == "error":
raise HTTPException(status_code=500, detail=res.get("message"))
return res
if __name__ == "__main__":
import uvicorn
from src.core.config import config
uvicorn.run("src.api.main:app", host=config.web_host, port=config.web_port, reload=True)
+19
View File
@@ -0,0 +1,19 @@
# Challenge: Hello World
**Difficulty:** Easy
**Language:** Python
**Subject:** Basic I/O
## Description
Write a Python script that prints the phrase "Hello, World!" to the console.
## Requirements
- The script must output exactly "Hello, World!".
- The script should be named `hello.py`.
## Hints
- Use the `print()` function in Python.
- Ensure there are no extra spaces or characters in the string.
## Validation
- **Check:** `python3 -c "import hello; print(hello.main())"`
- **Expected Output:** Hello, World!
+20
View File
@@ -0,0 +1,20 @@
# Challenge: Variables and Assignment
**Difficulty:** Easy
**Language:** Python
**Subject:** Variables & Types
## Description
Create a variable named `score` and assign it the value 100. Then, print the message "The score is 100" to the console.
## Requirements
- Create a variable named `score`.
- Assign the integer value 100 to it.
- Print "The score is 100".
## Hints
- Use `score = 100` to create the variable.
- Use `print("The score is 100")` to output the text.
## Validation
- **Check:** `python3 -c "import main; print(main.score)"`
- **Expected Output:** 100
+24
View File
@@ -0,0 +1,24 @@
# Challenge: For Loops
**Difficulty:** Easy
**Language:** Python
**Subject:** Control Flow (Loops)
## Description
Write a loop that prints the numbers from 1 to 5 (inclusive).
## Requirements
- Use a `for` loop.
- Print each number on a new line.
## Hints
- The `range(1, 6)` function generates numbers from 1 to 5.
- Use `print()` inside the loop body.
## Validation
- **Check:** `python3 -c "import main; [print(i) for i in range(1, 6)]"`
- **Expected Output:**
1
2
3
4
5
+20
View File
@@ -0,0 +1,20 @@
# Challenge: Conditionals
**Difficulty:** Easy
**Language:** Python
**Subject:** Control Flow (Conditionals)
## Description
Write a program that checks if a number is positive, negative, or zero.
## Requirements
- Create a variable `num` and assign it a value (e.g., -5).
- Use `if`, `elif`, and `else` to check the value.
- Print "Positive", "Negative", or "Zero" based on the value.
## Hints
- Use `num > 0`, `num < 0`, and `num == 0` for the conditions.
- Remember to use indentation for the code inside the blocks.
## Validation
- **Check:** `python3 -c "import main; print(main.check_num(-5))"`
- **Expected Output:** Negative
+20
View File
@@ -0,0 +1,20 @@
# Challenge: Functions
**Difficulty:** Easy
**Language:** Python
**Subject:** Modular Programming (Functions)
## Description
Write a function named `add_numbers` that takes two parameters, `a` and `b`, and returns their sum.
## Requirements
- Define the function `add_numbers(a, b)`.
- Use the `return` keyword.
- Call the function and print the result of adding 5 and 10.
## Hints
- `def add_numbers(a, b):` is the standard way to define a function.
- Use `print(add_numbers(5, 10))` to see the result.
## Validation
- **Check:** `python3 -c "import main; print(main.add_numbers(5, 10))"`
- **Expected Output:** 15
+20
View File
@@ -0,0 +1,20 @@
# Challenge: List Basics
**Difficulty:** Easy
**Language:** Python
**Subject:** Data Structures (Lists)
## Description
Create a list of 3 fruits. Add a 4th fruit to the list and then print the final list.
## Requirements
- Create a list named `fruits`.
- Use `.append()` to add another item.
- Print the final list.
## Hints
- `fruits = ["apple", "banana", "cherry"]`
- `fruits.append("date")`
## Validation
- **Check:** `python3 -c "import main; print(main.fruits)"`
- **Expected Output:** ['apple', 'banana', 'cherry', 'date']
@@ -0,0 +1,44 @@
# Challenge: LRU Cache Implementation
**Difficulty:** Medium
**Language:** Java
**Subject:** Data Structures
## Description
In high-performance software engineering, caching is a critical technique used to reduce data retrieval time by storing frequently accessed information in fast-access memory. One of the most common eviction policies is **LRU (Least Recently Used)**. This policy discards the least recently accessed items first when the cache reaches its capacity.
Your task is to design and implement a data structure for an LRU Cache. The cache must support two primary operations: `get` and `put`.
- `get(key)`: Retrieve the value associated with the key. If the key exists, it should be marked as "recently used." If it doesn't exist, return -1.
- `put(key, value)`: Insert or update the value for a given key. If the key already exists, update its value and mark it as "recently used." If the key is new and the cache is at full capacity, you must remove the least recently used item before inserting the new one.
The primary constraint is that both `get` and `put` operations must run in **O(1)** average time complexity.
## Requirements
- Implement a class `LRUCache` that takes an integer `capacity` as a constructor argument.
- Implement the `get(int key)` method:
- Return the value if the key exists; otherwise, return -1.
- Moving a key to the "most recently used" position must happen automatically upon access.
- Implement the `put(int key, int value)` method:
- If the key exists, update the value and move it to the "most recently used" position.
- If the key is new, add it to the cache.
- If the cache exceeds `capacity`, remove the entry that was accessed least recently.
- **Performance Constraint:** You must achieve $O(1)$ time complexity for both operations. (Hint: Using only a HashMap or only a Linked List will not satisfy the time complexity requirements for both operations simultaneously).
## Hints
- To achieve $O(1)$ lookup, a `HashMap` is essential. However, a standard `HashMap` does not maintain the order of access.
- To achieve $O(1)$ removal and insertion at specific positions, a **Doubly Linked List** is the ideal companion to the HashMap.
- The HashMap should store the key as the map key and the corresponding Node object (from your Doubly Linked List) as the map value. This allows you to jump directly to the node in the list to re-link it in constant time.
## Validation
- **Check:** Initialize `LRUCache cache = new LRUCache(2)`.
- **Sequence:**
1. `cache.put(1, 1)`
2. `cache.put(2, 2)`
3. `cache.get(1)` (Should return 1, and 1 becomes most recent)
4. `cache.put(3, 3)` (Capacity is 2, so the least recently used key '2' should be evicted)
5. `cache.get(2)` (Should return -1)
6. `cache.get(3)` (Should return 3)
7. `cache.put(4, 4)` (Capacity is 2, so the least recently used key '1' should be evicted)
8. `cache.get(1)` (Should return -1)
9. `cache.get(3)` (Should return 3)
10. `cache.get(4)` (Should return 4)
- **Expected Output:** `[1, -1, 3, -1, 3, 4]`
+120
View File
@@ -0,0 +1,120 @@
"""Configuration loader for TactTerm."""
import os
import json
from typing import Dict, Any
class Config:
"""Manages application configuration settings."""
DEFAULT_CONFIG = {
"llm": {
"base_url": "http://localhost:8080/v1",
"model": "local-model",
"api_key": "not-needed",
"temperature": 0.7,
"max_tokens": 512,
"timeout_seconds": 5.0,
},
"web": {
"host": "127.0.0.1",
"port": 8000,
"public": False,
},
}
def __init__(self, config_path: str = "config.json") -> None:
self.config_path = config_path
self._data = self.load_config()
def load_config(self) -> Dict[str, Any]:
# Deep copy default configuration structure
config = json.loads(json.dumps(self.DEFAULT_CONFIG))
if os.path.exists(self.config_path):
try:
with open(self.config_path, "r", encoding="utf-8") as f:
user_config = json.load(f)
if "llm" in user_config and isinstance(user_config["llm"], dict):
config["llm"].update(user_config["llm"])
if "web" in user_config and isinstance(user_config["web"], dict):
config["web"].update(user_config["web"])
except Exception as e:
print(f"Warning: Failed to load {self.config_path}: {e}")
else:
try:
dirname = os.path.dirname(self.config_path)
if dirname:
os.makedirs(dirname, exist_ok=True)
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
except Exception as e:
print(f"Warning: Failed to create default {self.config_path}: {e}")
# Allow environment variable overrides
env_base_url = os.getenv("TACTTERM_LLM_BASE_URL")
if env_base_url:
config["llm"]["base_url"] = env_base_url
env_model = os.getenv("TACTTERM_LLM_MODEL")
if env_model:
config["llm"]["model"] = env_model
env_web_host = os.getenv("TACTTERM_WEB_HOST")
if env_web_host:
config["web"]["host"] = env_web_host
env_web_port = os.getenv("TACTTERM_WEB_PORT")
if env_web_port:
try:
config["web"]["port"] = int(env_web_port)
except ValueError:
pass
env_web_public = os.getenv("TACTTERM_WEB_PUBLIC")
if env_web_public is not None:
config["web"]["public"] = env_web_public.lower() in ("true", "1", "yes")
return config
@property
def llm_base_url(self) -> str:
return self._data["llm"]["base_url"].rstrip("/")
@property
def llm_model(self) -> str:
return self._data["llm"]["model"]
@property
def llm_api_key(self) -> str:
return self._data["llm"]["api_key"]
@property
def llm_temperature(self) -> float:
return float(self._data["llm"]["temperature"])
@property
def llm_max_tokens(self) -> int:
return int(self._data["llm"]["max_tokens"])
@property
def llm_timeout(self) -> float:
return float(self._data["llm"]["timeout_seconds"])
@property
def web_public(self) -> bool:
return bool(self._data.get("web", {}).get("public", False))
@property
def web_host(self) -> str:
if self.web_public:
return "0.0.0.0"
return self._data.get("web", {}).get("host", "127.0.0.1")
@property
def web_port(self) -> int:
return int(self._data.get("web", {}).get("port", 8000))
config = Config()
+215
View File
@@ -0,0 +1,215 @@
"""Service for executing user code safely across 10 programming languages with stdin support."""
import sys
import os
import tempfile
import subprocess
from typing import Dict, Any
from src.core.registry import registry
class CodeExecutor:
"""Executes code for all 10 supported programming languages with stdin support."""
def run(self, language: str, code: str, stdin: str = "") -> Dict[str, Any]:
config = registry.get_config(language)
if not config:
return {
"language": language,
"exit_code": 1,
"stdout": "",
"stderr": f"Language '{language}' is not supported.",
}
lang_key = registry.canonical_name(language)
ext = config.get("ext", ".txt")
with tempfile.TemporaryDirectory() as tmpdir:
if lang_key == "java":
temp_file_name = "Main.java"
elif lang_key == "cpp":
temp_file_name = "main.cpp"
elif lang_key == "rust":
temp_file_name = "main.rs"
elif lang_key == "csharp":
temp_file_name = "Program.cs"
else:
temp_file_name = f"main{ext}"
temp_file_path = os.path.join(tmpdir, temp_file_name)
with open(temp_file_path, "w", encoding="utf-8") as f:
f.write(code)
try:
if lang_key == "python":
cmd = [sys.executable, temp_file_path]
result = subprocess.run(
cmd,
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "html":
return {
"language": language,
"exit_code": 0,
"stdout": code,
"stderr": "",
}
elif lang_key == "javascript":
result = subprocess.run(
["node", temp_file_path],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "typescript":
result = subprocess.run(
["npx", "ts-node", temp_file_path],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "go":
result = subprocess.run(
["go", "run", temp_file_path],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "lua":
result = subprocess.run(
["lua", temp_file_path],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "cpp":
compile_res = subprocess.run(
["g++", "-O2", temp_file_path, "-o", "main"],
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
if compile_res.returncode != 0:
return {
"language": language,
"exit_code": compile_res.returncode,
"stdout": compile_res.stdout,
"stderr": f"Compilation Error:\n{compile_res.stderr}",
}
result = subprocess.run(
["./main"],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "rust":
compile_res = subprocess.run(
["rustc", temp_file_path, "-o", "main"],
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
if compile_res.returncode != 0:
return {
"language": language,
"exit_code": compile_res.returncode,
"stdout": compile_res.stdout,
"stderr": f"Rustc Compilation Error:\n{compile_res.stderr}",
}
result = subprocess.run(
["./main"],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "java":
compile_res = subprocess.run(
["javac", temp_file_path],
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
if compile_res.returncode != 0:
return {
"language": language,
"exit_code": compile_res.returncode,
"stdout": compile_res.stdout,
"stderr": f"Javac Compilation Error:\n{compile_res.stderr}",
}
result = subprocess.run(
["java", "Main"],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
elif lang_key == "csharp":
result = subprocess.run(
["dotnet", "run"],
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
else:
exec_cmd = config["exec"]
result = subprocess.run(
f"{exec_cmd} {temp_file_path}",
shell=True,
input=stdin,
capture_output=True,
text=True,
cwd=tmpdir,
timeout=15,
)
return {
"language": language,
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
except FileNotFoundError as fnf:
return {
"language": language,
"exit_code": 127,
"stdout": "",
"stderr": f"Compiler/Interpreter binary not found for {language}: {fnf}",
}
except subprocess.TimeoutExpired:
return {
"language": language,
"exit_code": 124,
"stdout": "",
"stderr": "Execution timed out (exceeded 15 seconds).",
}
except Exception as e:
return {
"language": language,
"exit_code": 1,
"stdout": "",
"stderr": str(e),
}
executor = CodeExecutor()
+143
View File
@@ -0,0 +1,143 @@
"""Service for generating and saving new programming challenges via LLM backend."""
import os
import re
import httpx
from typing import Dict, Any
from src.core.config import config
from src.core.mentor import get_chat_completions_url
SYSTEM_PROMPT_GENERATOR = """You are a Master Software Engineering Challenge Creator for TactiTerm.
Your task is to generate a complete, high-quality coding challenge formatted strictly as a Markdown document according to the specification below.
### STRICT MARKDOWN FORMAT SPECIFICATION:
```markdown
# Challenge: <Short Clear Title>
**Difficulty:** <Easy|Medium|Hard|Advanced>
**Language:** <Python|C#|C++|Java|JavaScript|TypeScript|Rust|Lua|HTML|Go>
**Subject:** <Specific Subject/Topic>
## Description
<Clear multi-paragraph explanation of the problem, background context, and what the student needs to build.>
## Requirements
- <Clear, objective requirement 1>
- <Clear, objective requirement 2>
- <Clear, objective requirement 3>
## Hints
- <Helpful conceptual hint 1>
- <Helpful conceptual hint 2>
## Validation
- **Check:** <Validation command or test description>
- **Expected Output:** <Expected output or return value>
```
### RULES:
1. ONLY return the valid Markdown content starting with `# Challenge: `.
2. Do NOT enclose the entire Markdown output in outer markdown triple backticks.
3. Ensure the Language is one of: Python, C#, C++, Java, JavaScript, TypeScript, Rust, Lua, HTML, Go.
4. Ensure Difficulty is one of: Easy, Medium, Hard, Advanced.
5. Create realistic, engaging, and pedagogically sound requirements and hints.
"""
class ChallengeGenerator:
"""Generates new challenge markdown files using the configured LLM backend."""
def __init__(self, challenges_dir: str = "src/challenges/"):
self.challenges_dir = challenges_dir
async def generate_challenge_async(
self, prompt_text: str, difficulty: str = "Medium", language: str = "Python", subject: str = "General"
) -> Dict[str, Any]:
endpoint = get_chat_completions_url(config.llm_base_url)
user_content = (
f"Generate a {difficulty} difficulty challenge in {language} focusing on the subject '{subject}'.\n"
f"User Prompt Details: {prompt_text}"
)
payload = {
"model": config.llm_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT_GENERATOR},
{"role": "user", "content": user_content},
],
"temperature": 0.7,
"max_tokens": config.llm_max_tokens,
}
headers = {"Content-Type": "application/json"}
if config.llm_api_key and config.llm_api_key != "not-needed":
headers["Authorization"] = f"Bearer {config.llm_api_key}"
try:
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
response = await client.post(endpoint, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
raw_md = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
# Clean up outer markdown wrapper if present
if raw_md.startswith("```markdown"):
raw_md = raw_md.replace("```markdown", "", 1).rstrip("` \n")
elif raw_md.startswith("```"):
raw_md = raw_md.replace("```", "", 1).rstrip("` \n")
if raw_md:
# Generate filename slug from title
title_match = re.search(r"# Challenge: (.*)", raw_md)
title = title_match.group(1).strip() if title_match else "new-challenge"
slug = re.sub(r"[^\w\s-]", "", title.lower()).strip().replace(" ", "-")
filename = f"{slug}.md"
return {
"status": "success",
"filename": filename,
"markdown": raw_md,
"title": title,
}
else:
return {
"status": "error",
"message": "Received empty response from LLM server.",
}
except Exception as e:
return {
"status": "error",
"message": f"Failed to generate challenge via LLM: {e}",
}
def save_challenge(self, filename: str, markdown_content: str) -> Dict[str, Any]:
"""Save a generated markdown challenge file into src/challenges/."""
if not filename.endswith(".md"):
filename = f"{filename}.md"
# Sanitize filename
safe_filename = os.path.basename(filename)
save_path = os.path.join(self.challenges_dir, safe_filename)
os.makedirs(self.challenges_dir, exist_ok=True)
try:
with open(save_path, "w", encoding="utf-8") as f:
f.write(markdown_content)
return {
"status": "success",
"path": save_path,
"filename": safe_filename,
}
except Exception as e:
return {
"status": "error",
"message": f"Failed to save challenge to {save_path}: {e}",
}
generator = ChallengeGenerator()
+266
View File
@@ -0,0 +1,266 @@
"""W3Schools-Style Language Reference Handbook service for built-in functions, methods, primitive types, and topics."""
import httpx
from typing import Dict, List, Any
from src.core.config import config
from src.core.mentor import get_chat_completions_url
from src.core.registry import registry
HANDBOOK_CATALOG: Dict[str, Dict[str, List[Dict[str, str]]]] = {
"python": {
"functions": [
{"id": "type_int", "title": "int", "desc": "Integer numerical data type (e.g. 42, -7)."},
{"id": "type_float", "title": "float", "desc": "Floating-point real numerical data type (e.g. 3.14159)."},
{"id": "type_bool", "title": "bool", "desc": "Boolean truth value type (True or False)."},
{"id": "type_str", "title": "str", "desc": "Immutable text string data type."},
{"id": "type_list", "title": "list", "desc": "Mutable ordered sequence collection type."},
{"id": "type_dict", "title": "dict", "desc": "Key-value dictionary mapping collection type."},
{"id": "type_set", "title": "set", "desc": "Unordered collection of unique items."},
{"id": "print", "title": "print()", "desc": "Prints specified objects to standard output."},
{"id": "len", "title": "len()", "desc": "Returns the number of items in a container."},
{"id": "range", "title": "range()", "desc": "Generates a sequence of numbers from start to stop."},
{"id": "enumerate", "title": "enumerate()", "desc": "Yields index and value tuples from an iterable."},
{"id": "zip", "title": "zip()", "desc": "Iterates over multiple iterables in parallel."},
{"id": "map", "title": "map()", "desc": "Applies function to all items in an iterable."},
{"id": "filter", "title": "filter()", "desc": "Filters elements of iterable where predicate is True."},
{"id": "sorted", "title": "sorted()", "desc": "Returns new sorted list from items in iterable."},
{"id": "isinstance", "title": "isinstance()", "desc": "Checks if object is an instance of a class."},
{"id": "type_func", "title": "type()", "desc": "Returns the type of an object."},
{"id": "open", "title": "open()", "desc": "Opens a file and returns file object."},
{"id": "input", "title": "input()", "desc": "Reads line from standard input as string."},
{"id": "str_split", "title": "str.split()", "desc": "Splits string into list using delimiter."},
{"id": "str_join", "title": "str.join()", "desc": "Concatenates string elements with separator."},
{"id": "dict_get", "title": "dict.get()", "desc": "Returns value for key or default if missing."},
{"id": "list_append", "title": "list.append()", "desc": "Appends item to end of list."},
{"id": "sum", "title": "sum()", "desc": "Sums items of an iterable."},
{"id": "min_max", "title": "min() / max()", "desc": "Returns smallest or largest item."},
{"id": "abs", "title": "abs()", "desc": "Returns absolute value of number."},
],
"subjects": [
{"id": "vars", "title": "Variables & Primitive Types", "desc": "int, float, bool, str, and dynamic typing."},
{"id": "control", "title": "Control Flow & Loops", "desc": "if, elif, else, for, while, break, continue."},
{"id": "funcs", "title": "Functions & Type Hints", "desc": "def, return, lambda, *args, **kwargs."},
{"id": "collections", "title": "Lists, Dicts & Sets", "desc": "Comprehensions, operations, set math."},
{"id": "errors", "title": "Exception Handling", "desc": "try, except, else, finally, raise."},
{"id": "oop", "title": "Classes & OOP", "desc": "class, __init__, self, inheritance."},
],
},
"rust": {
"functions": [
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
{"id": "type_i32", "title": "i32 / i64", "desc": "Signed integer types (32-bit or 64-bit)."},
{"id": "type_u32", "title": "u32 / u64", "desc": "Unsigned integer types (32-bit or 64-bit)."},
{"id": "type_f64", "title": "f32 / f64", "desc": "Floating-point primitive types."},
{"id": "type_str", "title": "str / String", "desc": "String slice (&str) and owned String type."},
{"id": "type_vec", "title": "Vec<T>", "desc": "Growable heap-allocated vector collection."},
{"id": "type_option", "title": "Option<T>", "desc": "Type representing optional value (Some or None)."},
{"id": "type_result", "title": "Result<T, E>", "desc": "Type representing outcome (Ok or Err)."},
{"id": "println", "title": "println!()", "desc": "Prints formatted text to stdout with newline."},
{"id": "format", "title": "format!()", "desc": "Constructs formatted String using macro interpolation."},
{"id": "vec_macro", "title": "vec![]", "desc": "Creates vector containing given elements."},
{"id": "vec_push", "title": "Vec::push()", "desc": "Appends element to back of vector."},
{"id": "string_from", "title": "String::from()", "desc": "Creates owned String from string literal."},
{"id": "option_unwrap", "title": "Option::unwrap()", "desc": "Returns contained Some value or panics."},
{"id": "result_expect", "title": "Result::expect()", "desc": "Returns contained Ok value or panics with message."},
{"id": "iter_collect", "title": "Iterator::collect()", "desc": "Transforms iterator into collection."},
{"id": "box_new", "title": "Box::new()", "desc": "Allocates memory on heap."},
],
"subjects": [
{"id": "vars", "title": "Variables & Primitives", "desc": "let, let mut, bool, i32, f64, char, str."},
{"id": "ownership", "title": "Ownership & Borrowing", "desc": "Move semantics, references (&), mutable (&mut)."},
{"id": "control", "title": "Control Flow & Match", "desc": "if, loop, while, for, match pattern matching."},
{"id": "structs", "title": "Structs & Enums", "desc": "struct, impl, enum, Option, Result."},
{"id": "traits", "title": "Traits & Generics", "desc": "trait definition, generic type signatures."},
],
},
"cpp": {
"functions": [
{"id": "type_int", "title": "int", "desc": "Signed 32-bit integer primitive type."},
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
{"id": "type_double", "title": "float / double", "desc": "Single or double precision floating point."},
{"id": "type_string", "title": "std::string", "desc": "Standard library string object."},
{"id": "type_vector", "title": "std::vector<T>", "desc": "Dynamic array sequence container."},
{"id": "std_cout", "title": "std::cout / std::cin", "desc": "Standard input and output streams."},
{"id": "vec_push_back", "title": "std::vector::push_back()", "desc": "Adds element to end of vector."},
{"id": "std_sort", "title": "std::sort()", "desc": "Sorts range [first, last) in ascending order."},
{"id": "make_unique", "title": "std::make_unique()", "desc": "Constructs unique pointer object."},
{"id": "make_shared", "title": "std::make_shared()", "desc": "Constructs shared pointer object."},
],
"subjects": [
{"id": "vars", "title": "Primitive Data Types", "desc": "int, float, double, bool, char, void, const, auto."},
{"id": "control", "title": "Control Flow & Loops", "desc": "if, else, for, while, do while, switch."},
{"id": "pointers", "title": "Pointers & References", "desc": "Raw pointers (*), references (&), nullptr."},
{"id": "classes", "title": "Classes & OOP", "desc": "class, struct, public, private, constructors."},
],
},
"go": {
"functions": [
{"id": "type_int", "title": "int / int64", "desc": "Signed integer numerical primitive types."},
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
{"id": "type_string", "title": "string", "desc": "Immutable sequence of bytes / UTF-8 text."},
{"id": "type_error", "title": "error", "desc": "Built-in interface type for error handling."},
{"id": "fmt_println", "title": "fmt.Println() / Printf()", "desc": "Formatted write to standard output."},
{"id": "make", "title": "make()", "desc": "Allocates and initializes slice, map, or chan."},
{"id": "append", "title": "append()", "desc": "Appends elements to slice."},
{"id": "len", "title": "len() / cap()", "desc": "Returns length or capacity of collection."},
],
"subjects": [
{"id": "vars", "title": "Variables & Primitives", "desc": "var, :=, int, float64, bool, string, const."},
{"id": "control", "title": "Control Flow & Switch", "desc": "if with init, for loops, switch, select."},
{"id": "structs", "title": "Structs & Interfaces", "desc": "type struct, methods, interface."},
{"id": "goroutines", "title": "Goroutines & Channels", "desc": "go func(), chan, select statement."},
],
},
"javascript": {
"functions": [
{"id": "type_boolean", "title": "boolean / bool", "desc": "Boolean primitive type (true or false)."},
{"id": "type_number", "title": "number", "desc": "IEEE 754 floating-point numerical type."},
{"id": "type_string", "title": "string", "desc": "Textual string primitive data type."},
{"id": "console_log", "title": "console.log()", "desc": "Outputs message to debugging console."},
{"id": "arr_map", "title": "Array.prototype.map()", "desc": "Creates new array with mapped elements."},
{"id": "arr_filter", "title": "Array.prototype.filter()", "desc": "Filters elements satisfying predicate."},
{"id": "arr_reduce", "title": "Array.prototype.reduce()", "desc": "Executes reducer function on elements."},
{"id": "obj_keys", "title": "Object.keys()", "desc": "Returns array of object key names."},
{"id": "json_parse", "title": "JSON.parse()", "desc": "Parses JSON string into object."},
{"id": "fetch", "title": "fetch()", "desc": "Asynchronously fetches network resource."},
],
"subjects": [
{"id": "vars", "title": "Variables & Primitives", "desc": "const, let, var, number, string, boolean, null."},
{"id": "control", "title": "Control Flow & Logic", "desc": "if, else, ternary, for...of, for...in."},
{"id": "funcs", "title": "Arrow Functions & Async", "desc": "function, =>, async, await, Promises."},
],
},
"typescript": {
"functions": [
{"id": "type_boolean", "title": "boolean / bool", "desc": "Boolean type annotation (true or false)."},
{"id": "type_number", "title": "number / int", "desc": "Numerical primitive type annotation."},
{"id": "type_string", "title": "string", "desc": "Textual string type annotation."},
{"id": "type_any", "title": "any / unknown", "desc": "Escape hatch any or type-safe unknown."},
{"id": "record", "title": "Record<K, V>", "desc": "Constructs object type with keys K and value V."},
{"id": "partial", "title": "Partial<T>", "desc": "Constructs type with all properties optional."},
{"id": "readonly", "title": "Readonly<T>", "desc": "Constructs type with all properties read-only."},
],
"subjects": [
{"id": "types", "title": "Type Annotations & Primitive Types", "desc": "number, string, boolean, interface, type."},
{"id": "unions", "title": "Unions & Narrowing", "desc": "Type A | Type B, typeof, instanceof."},
],
},
"java": {
"functions": [
{"id": "type_int", "title": "int / long", "desc": "Primitive integer data types."},
{"id": "type_boolean", "title": "boolean / bool", "desc": "Primitive boolean true or false."},
{"id": "type_double", "title": "float / double", "desc": "Primitive floating point data types."},
{"id": "type_string", "title": "String", "desc": "Java String object class."},
{"id": "sys_out", "title": "System.out.println()", "desc": "Writes formatted text to standard output."},
{"id": "list_add", "title": "List.add()", "desc": "Appends element to Java List collection."},
],
"subjects": [
{"id": "vars", "title": "Variables & Primitives", "desc": "int, double, boolean, String, char, float."},
{"id": "control", "title": "Control Flow", "desc": "if, else, for, while, switch."},
],
},
"csharp": {
"functions": [
{"id": "type_int", "title": "int / long", "desc": "Signed integer primitive types."},
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
{"id": "type_string", "title": "string", "desc": "UTF-16 text string type."},
{"id": "console_write", "title": "Console.WriteLine()", "desc": "Writes line terminator to stdout."},
{"id": "list_add", "title": "List<T>.Add()", "desc": "Adds item to List collection."},
],
"subjects": [
{"id": "vars", "title": "Variables & Types", "desc": "int, bool, string, double, float, var."},
{"id": "control", "title": "Control Flow & OOP", "desc": "if, else, foreach, class, struct, record."},
],
},
"lua": {
"functions": [
{"id": "type_number", "title": "number / int", "desc": "Numerical data type."},
{"id": "type_boolean", "title": "boolean / bool", "desc": "Boolean truth value (true or false)."},
{"id": "type_string", "title": "string", "desc": "Byte sequence string type."},
{"id": "print", "title": "print()", "desc": "Prints values to standard output."},
],
"subjects": [
{"id": "vars", "title": "Variables & Types", "desc": "local, number, string, boolean, nil, table."},
],
},
"html": {
"functions": [
{"id": "elem_doctype", "title": "<!DOCTYPE html>", "desc": "Declares document type as HTML5."},
{"id": "elem_html", "title": "<html>", "desc": "Root element of HTML document."},
{"id": "elem_body", "title": "<body>", "desc": "Container for all visible web page contents."},
{"id": "elem_div", "title": "<div> / <span>", "desc": "Block or inline layout container elements."},
],
"subjects": [
{"id": "struct", "title": "Document Structure & Elements", "desc": "DOCTYPE, html, head, body, div, p, a, input."},
],
},
}
class HandbookService:
"""W3Schools-Style Language Reference Handbook service."""
def get_catalog(self, language: str) -> Dict[str, List[Dict[str, str]]]:
canonical = registry.canonical_name(language)
return HANDBOOK_CATALOG.get(canonical, HANDBOOK_CATALOG["python"])
async def generate_example_async(self, language: str, topic_id: str, topic_title: str) -> Dict[str, Any]:
endpoint = get_chat_completions_url(config.llm_base_url)
canonical = registry.canonical_name(language)
config_info = registry.get_config(canonical)
lang_name = config_info.get("name", language) if config_info else language
prompt = (
f"Generate a W3Schools-Style Reference Card for '{topic_title}' in {lang_name}.\n"
f"Structure your response strictly into these four Markdown sections:\n"
f"1. **Syntax / Signature:** Clear representation of how to invoke or write it.\n"
f"2. **Description & Parameters:** Brief summary of parameter types and return value.\n"
f"3. **Try It Yourself (Executable Code):** A clean, self-contained, working code snippet in ```{canonical} code blocks.\n"
f"4. **Key Notes & Best Practices:** 2 bullet points on common pitfalls or best practices."
)
sys_prompt = (
"You are an expert W3Schools-Style Documentation Assistant for TactiTerm.\n"
"Provide clean, educational reference cards. Ensure the code example is realistic, complete, and copy-pasteable."
)
payload = {
"model": config.llm_model,
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
"max_tokens": 900,
}
headers = {"Content-Type": "application/json"}
if config.llm_api_key and config.llm_api_key != "not-needed":
headers["Authorization"] = f"Bearer {config.llm_api_key}"
try:
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
response = await client.post(endpoint, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
example_md = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
return {
"status": "success",
"language": lang_name,
"topic_title": topic_title,
"example_markdown": example_md,
}
except Exception as e:
return {
"status": "error",
"message": f"Failed to generate W3Schools reference card via LLM: {e}",
}
handbook_service = HandbookService()
+95
View File
@@ -0,0 +1,95 @@
"""Service for linting user code across 10 programming languages."""
import sys
import os
import tempfile
import subprocess
from typing import Dict, Any
from src.core.registry import registry
class CodeLinter:
"""Lints user code for syntax and style issues for 10 languages."""
def lint(self, language: str, code: str) -> Dict[str, Any]:
config = registry.get_config(language)
if not config:
return {
"language": language,
"exit_code": 1,
"stdout": "",
"stderr": f"Language '{language}' is not supported.",
}
lang_key = registry.canonical_name(language)
ext = config.get("ext", ".txt")
with tempfile.TemporaryDirectory() as tmpdir:
if lang_key == "java":
temp_file_name = "Main.java"
elif lang_key == "cpp":
temp_file_name = "main.cpp"
elif lang_key == "rust":
temp_file_name = "main.rs"
elif lang_key == "csharp":
temp_file_name = "Program.cs"
else:
temp_file_name = f"main{ext}"
temp_path = os.path.join(tmpdir, temp_file_name)
with open(temp_path, "w", encoding="utf-8") as f:
f.write(code)
try:
if lang_key == "python":
cmd = [sys.executable, "-m", "flake8", temp_path]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "javascript":
result = subprocess.run(["node", "--check", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "typescript":
result = subprocess.run(["npx", "tsc", "--noEmit", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "lua":
result = subprocess.run(["luac", "-p", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "go":
result = subprocess.run(["go", "vet", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "cpp":
result = subprocess.run(["g++", "-fsyntax-only", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "rust":
result = subprocess.run(["rustc", "--emit=metadata", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "java":
result = subprocess.run(["javac", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
elif lang_key == "html":
result = subprocess.run(["python3", "-c", f"import html.parser; html.parser.HTMLParser().feed(open('{temp_path}').read())"], capture_output=True, text=True, cwd=tmpdir, timeout=15)
else:
lint_cmd = config["lint"]
result = subprocess.run(f"{lint_cmd} {temp_path}", shell=True, capture_output=True, text=True, cwd=tmpdir, timeout=15)
return {
"language": language,
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
except FileNotFoundError as fnf:
return {
"language": language,
"exit_code": 0,
"stdout": f"✓ Syntax check skipped (linter tool not installed for {language}).",
"stderr": "",
}
except subprocess.TimeoutExpired:
return {
"language": language,
"exit_code": 124,
"stdout": "",
"stderr": "Linting timed out.",
}
except Exception as e:
return {
"language": language,
"exit_code": 1,
"stdout": "",
"stderr": str(e),
}
linter = CodeLinter()
+107
View File
@@ -0,0 +1,107 @@
import re
import os
from typing import List, Dict, Optional
from src.core.models import Challenge
class ChallengeLoader:
def __init__(self, challenges_dir: str = "src/challenges/"):
self.challenges_dir = challenges_dir
def load_all(self) -> Dict[str, Challenge]:
challenges = {}
if not os.path.exists(self.challenges_dir):
return challenges
for filename in os.listdir(self.challenges_dir):
if filename.endswith(".md"):
path = os.path.join(self.challenges_dir, filename)
challenge = self.parse_file(path)
if challenge:
key = filename.replace(".md", "")
challenges[key] = challenge
return challenges
def parse_file(self, path: str) -> Optional[Challenge]:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Parse YAML frontmatter if present
fm_data = {}
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
fm_raw = parts[1]
content = parts[2]
for line in fm_raw.splitlines():
if ":" in line:
k, v = line.split(":", 1)
fm_data[k.strip().lower()] = v.strip()
name = fm_data.get("name")
difficulty = fm_data.get("difficulty")
language = fm_data.get("language")
subject = fm_data.get("subject") or fm_data.get("topic")
# Regex extraction fallback for markdown headers
if not name:
name_match = re.search(r"# (?:Challenge: )?(.*)", content)
name = name_match.group(1).strip() if name_match else None
if not difficulty:
diff_match = re.search(r"\*\*Difficulty:\*\* (.*)", content)
difficulty = diff_match.group(1).strip() if diff_match else "Medium"
if not language:
lang_match = re.search(r"\*\*Language:\*\* (.*)", content)
language = lang_match.group(1).strip() if lang_match else "Python"
if not subject:
subj_match = re.search(r"\*\*(?:Subject|Topic):\*\* (.*)", content)
subject = subj_match.group(1).strip() if subj_match else "General Concepts"
desc_match = re.search(r"## Description\n(.*?)(?=\n##|$)", content, re.DOTALL)
req_match = re.search(r"## Requirements\n(.*?)(?=\n##|$)", content, re.DOTALL)
hint_match = re.search(r"## Hints\n(.*?)(?=\n##|$)", content, re.DOTALL)
valid_match = re.search(r"## Validation\n-\s*\*\*Check:\*\* (.*)", content)
exp_match = re.search(r"\*\*Expected Output:\*\* (.*)", content)
if not name:
return None
description = desc_match.group(1).strip() if desc_match else ""
requirements = (
[
r.strip("- ").strip()
for r in req_match.group(1).strip().split("\n")
if r.strip("- ")
]
if req_match
else []
)
hints = (
[
h.strip("- ").strip()
for h in hint_match.group(1).strip().split("\n")
if h.strip("- ")
]
if hint_match
else []
)
validation_check = valid_match.group(1).strip() if valid_match else ""
expected_output = exp_match.group(1).strip() if exp_match else ""
return Challenge(
name=name,
difficulty=difficulty,
language=language,
subject=subject,
description=description,
requirements=requirements,
hints=hints,
validation_check=validation_check,
expected_output=expected_output,
)
+120
View File
@@ -0,0 +1,120 @@
"""Mentor LLM client communicating with llama.cpp server (OpenAI API compatible)."""
import httpx
from typing import Dict, Any, Optional
from src.core.config import config
from src.core.prompts import get_mentor_prompt, get_socratic_prompt
def get_chat_completions_url(base_url: str) -> str:
url = base_url.strip().rstrip("/")
if url.endswith("/chat/completions"):
return url
if url.endswith("/v1"):
return f"{url}/chat/completions"
return f"{url}/v1/chat/completions"
class MentorClient:
"""LLM client for software engineering tutoring via OpenAI-compatible endpoints."""
def __init__(self) -> None:
self.config = config
async def get_guidance_async(
self, challenge: object, user_code: str, user_question: str = ""
) -> Dict[str, Any]:
"""Fetch mentor guidance asynchronously from the configured LLM endpoint."""
prompt = get_mentor_prompt(challenge, user_code, user_question=user_question)
endpoint = get_chat_completions_url(self.config.llm_base_url)
user_content = (
f"Question for Mentor: {user_question}\n\nPlease evaluate my code or answer my question for this challenge."
if user_question.strip()
else "Please review my code structure and provide guidance for my current step."
)
payload = {
"model": self.config.llm_model,
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": user_content},
],
"temperature": self.config.llm_temperature,
"max_tokens": self.config.llm_max_tokens,
}
headers = {"Content-Type": "application/json"}
if self.config.llm_api_key and self.config.llm_api_key != "not-needed":
headers["Authorization"] = f"Bearer {self.config.llm_api_key}"
try:
async with httpx.AsyncClient(timeout=self.config.llm_timeout, follow_redirects=True) as client:
response = await client.post(endpoint, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
if content:
return {
"status": "success",
"mentor_response": content,
"offline": False,
}
else:
return {
"status": "error",
"mentor_response": "Received empty response from LLM server.",
"offline": False,
}
except (httpx.ConnectError, httpx.TimeoutException, httpx.TransportError):
return {
"status": "offline",
"mentor_response": (
f"⚠️ LLM Server offline or unreachable at {self.config.llm_base_url}.\n"
"Start your llama.cpp server with:\n"
" ./llama-server -m <your-model.gguf> --port 8080\n\n"
"💡 Offline Hint:\n"
"How is your code structuring the initial state before entering your main logic loop?"
),
"offline": True,
}
except Exception as e:
return {
"status": "error",
"mentor_response": f"⚠️ Error consulting LLM mentor: {e}",
"offline": False,
}
def get_guidance(
self, challenge: object, user_code: str, user_question: str = ""
) -> Dict[str, Any]:
"""Synchronous wrapper for fetching mentor guidance."""
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import nest_asyncio
nest_asyncio.apply()
return loop.run_until_complete(
self.get_guidance_async(challenge, user_code, user_question=user_question)
)
else:
return asyncio.run(
self.get_guidance_async(challenge, user_code, user_question=user_question)
)
except Exception:
return asyncio.run(
self.get_guidance_async(challenge, user_code, user_question=user_question)
)
SocraticMentor = MentorClient
mentor = MentorClient()
+43
View File
@@ -0,0 +1,43 @@
from typing import List, Dict, Any
class Challenge:
def __init__(
self,
name: str,
difficulty: str,
language: str,
subject: str,
description: str,
requirements: List[str],
hints: List[str],
validation_check: str,
expected_output: str,
):
self.name = name
self.difficulty = difficulty
self.language = language
self.subject = subject if subject else "General Software Engineering"
self.description = description
self.requirements = requirements
self.hints = hints
self.validation_check = validation_check
self.expected_output = expected_output
def __repr__(self) -> str:
return f"<Challenge: {self.name} [{self.subject}] ({self.language})>"
def to_dict(self, challenge_id: str = "") -> Dict[str, Any]:
return {
"id": challenge_id,
"challenge_id": challenge_id,
"name": self.name,
"difficulty": self.difficulty,
"language": self.language,
"subject": self.subject,
"description": self.description,
"requirements": self.requirements,
"hints": self.hints,
"validation_check": self.validation_check,
"expected_output": self.expected_output,
}
+67
View File
@@ -0,0 +1,67 @@
"""Mentor system prompt and prompt generation utilities."""
SYSTEM_PROMPT_MENTOR = """You are a Software Engineering Mentor.
Your ultimate mission is to build the user's confidence in designing and constructing complex software programs from scratch.
### YOUR CORE RESPONSIBILITIES:
1. **Upfront Program Structure:** Guide the user on how to structure their software cleanly from the start — decomposing complex problems into small modular functions, defining clear data flow, and choosing appropriate data structures.
2. **Roadblock Breaking & Solution Evaluation:** When the user asks a question, encounters bugs, or asks to check their answer, evaluate their code against all challenge requirements, illuminate root causes, and provide clear constructive feedback.
3. **Illustrative Code Examples:** You MAY provide abstract code templates, conceptual code snippets, and syntax examples (e.g. showing how to define a function `def function_name(param):`, how to use `try/except`, or how to structure a loop) to help the user learn programming constructs.
### STRICT GUARDRAILS (NEVER BREAK THESE):
- **NEVER** write or provide the specific solution code, completed logic, or full answer for the active challenge.
- **NEVER** solve the challenge's core problem for the user.
- Abstract syntax snippets and structural templates ARE allowed as long as they demonstrate general programming concepts rather than giving away the specific challenge solution.
- If the user explicitly asks for the answer to the challenge, politely decline in-character, provide a generic syntax example if applicable, and ask a question to guide their next step.
### CONTEXT FOR THIS SESSION:
- Challenge Title: {challenge_name}
- Language: {language}
- Difficulty: {difficulty}
- Description: {challenge_description}
- Requirements:
{requirements}
- Hints:
{hints}
### USER'S CURRENT CODE:
```
{user_code}
```
### USER'S QUESTION FOR THE MENTOR:
{user_question}
### RESPONSE FORMAT:
1. **Direct Answer & Feedback:** Address the user's question or solution evaluation directly (using illustrative syntax/code templates where helpful).
2. **Structural & Logic Guidance:** Provide structural or architectural suggestions on how to break down the program cleanly.
3. **Probing Question:** Ask a focused question to guide their next implementation or debugging step.
"""
SYSTEM_PROMPT_SOCRATIC = SYSTEM_PROMPT_MENTOR
def get_mentor_prompt(challenge: object, user_code: str, user_question: str = "") -> str:
"""Returns the formatted system prompt for the LLM mentor."""
reqs = "\n".join(f"- {r}" for r in getattr(challenge, "requirements", []))
hints = "\n".join(f"- {h}" for h in getattr(challenge, "hints", []))
q_text = (
user_question.strip()
if user_question.strip()
else "How should I structure my program and overcome my current blocker for this challenge?"
)
return SYSTEM_PROMPT_MENTOR.format(
challenge_name=getattr(challenge, "name", "Coding Challenge"),
language=getattr(challenge, "language", "Python"),
difficulty=getattr(challenge, "difficulty", "Medium"),
challenge_description=getattr(challenge, "description", ""),
requirements=reqs if reqs else "- Follow standard problem specifications",
hints=hints if hints else "- Think through edge cases",
user_code=user_code if user_code.strip() else "# No code written yet",
user_question=q_text,
)
get_socratic_prompt = get_mentor_prompt
+97
View File
@@ -0,0 +1,97 @@
from typing import Dict, Optional, List
class LanguageRegistry:
"""Registry mapping programming languages to execution, linting, and extension settings."""
def __init__(self) -> None:
self._languages: Dict[str, Dict[str, str]] = {
"python": {
"name": "Python",
"ext": ".py",
"exec": "python3",
"lint": "flake8",
},
"csharp": {
"name": "C#",
"ext": ".cs",
"exec": "dotnet run",
"lint": "dotnet build",
},
"cpp": {
"name": "C++",
"ext": ".cpp",
"exec": "g++ -O2 -o main main.cpp && ./main",
"lint": "g++ -fsyntax-only main.cpp",
},
"java": {
"name": "Java",
"ext": ".java",
"exec": "javac Main.java && java Main",
"lint": "javac Main.java",
},
"javascript": {
"name": "JavaScript",
"ext": ".js",
"exec": "node",
"lint": "node --check",
},
"typescript": {
"name": "TypeScript",
"ext": ".ts",
"exec": "npx ts-node",
"lint": "npx tsc --noEmit",
},
"rust": {
"name": "Rust",
"ext": ".rs",
"exec": "rustc main.rs -o main && ./main",
"lint": "rustc --emit=metadata main.rs",
},
"lua": {
"name": "Lua",
"ext": ".lua",
"exec": "lua",
"lint": "luac -p",
},
"html": {
"name": "HTML",
"ext": ".html",
"exec": "cat",
"lint": "tidy -e -q",
},
"go": {
"name": "Go",
"ext": ".go",
"exec": "go run",
"lint": "go vet",
},
}
# Language aliases mapping
self._aliases: Dict[str, str] = {
"py": "python",
"c#": "csharp",
"cs": "csharp",
"c++": "cpp",
"cxx": "cpp",
"js": "javascript",
"node": "javascript",
"ts": "typescript",
"rs": "rust",
"golang": "go",
}
def canonical_name(self, language: str) -> str:
lang_lower = language.lower().strip()
return self._aliases.get(lang_lower, lang_lower)
def get_config(self, language: str) -> Optional[Dict[str, str]]:
key = self.canonical_name(language)
return self._languages.get(key)
def list_languages(self) -> List[str]:
return list(self._languages.keys())
registry = LanguageRegistry()
+18
View File
@@ -0,0 +1,18 @@
# Challenge: [Name]
**Difficulty:** [Easy/Med/Hard]
**Language:** [Language]
## Description
[Description of the problem]
## Requirements
- [Requirement 1]
- [Requirement 2]
## Hints
- [Hint 1]
- [Hint 2]
## Validation
- **Check:** [Command to run, e.g., pytest or eslint]
- **Expected Output:** [Description]
+845
View File
@@ -0,0 +1,845 @@
"""TactiTerm TUI — Coding Tutor Terminal User Interface with W3Schools-style Handbook & Expandable Output Terminal."""
import argparse
import logging
from typing import List, Dict, Any
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, VerticalScroll
from textual.widgets import Header, Footer, Static, OptionList, Button, Markdown, TextArea, Input
from textual.widgets.option_list import Option
from textual.binding import Binding
from src.core.loader import ChallengeLoader
from src.core.executor import executor
from src.core.linter import linter
from src.core.mentor import mentor
from src.core.handbook import handbook_service
from src.core.prompts import get_mentor_prompt
from src.tui.widgets import CodeEditor, MentorInputTextArea, StdinInput, TUI_COMPLETIONS
class TactiTermTUI(App):
"""A coding tutor TUI with direct core service integration, Mentor, Handbook, and Expandable Stdin Output Terminal."""
TITLE = "TactiTerm"
CSS = """
Screen {
layout: vertical;
}
#main-container {
layout: horizontal;
height: 1fr;
width: 100%;
}
/* Left column: challenge list & details */
#challenge-panel {
width: 32%;
min-width: 25;
height: 100%;
border-right: heavy $primary;
padding: 0 1;
}
/* Middle column: code editor & execution output */
#editor-panel {
width: 68%;
height: 100%;
padding: 0 1;
}
/* Right columns: Mentor & Handbook sidebars (hidden by default) */
#mentor-sidebar, #handbook-sidebar {
width: 32%;
min-width: 26;
height: 100%;
padding: 0 1;
display: none;
}
/* Adjusted column widths when a sidebar is toggled open */
#main-container.sidebar-open #challenge-panel {
width: 26%;
}
#main-container.sidebar-open #editor-panel {
width: 44%;
border-right: heavy $primary;
}
#main-container.sidebar-mentor-open #mentor-sidebar {
display: block;
width: 30%;
}
#main-container.sidebar-handbook-open #handbook-sidebar {
display: block;
width: 30%;
}
#challenge-header, #mentor-header, #handbook-header {
height: 3;
background: $primary;
color: white;
content-align: center middle;
text-style: bold;
margin-bottom: 1;
}
#challenge_list {
height: 8;
border: solid $secondary;
margin-bottom: 1;
}
#handbook-search-input {
margin-bottom: 1;
}
#btn-handbook-mode {
width: 100%;
margin-bottom: 1;
}
#handbook_topic_list {
height: 8;
border: solid $secondary;
margin-bottom: 1;
}
#challenge-details-container {
height: 1fr;
border: solid $primary-darken-2;
padding: 1;
background: $surface;
overflow-y: auto;
}
/* Sleek compact action bar with reduced height */
#action-bar {
height: 1;
margin-bottom: 1;
}
#action-bar Button {
height: 1;
min-width: 7;
padding: 0 1;
margin-right: 1;
border: none;
}
/* Editor & Auto-Complete Popup Overlay styling */
CodeEditor {
height: 1fr;
min-height: 8;
border: solid $accent;
margin-bottom: 1;
}
CodeEditor:focus {
border: heavy $primary;
}
#completion-popup {
display: none;
height: 8;
background: $surface-darken-1;
border: heavy $warning;
margin-bottom: 1;
padding: 0 1;
}
#main-container.show-completion #completion-popup {
display: block;
}
#completion-title {
height: 1;
text-style: bold;
color: $warning;
}
#completion_list_popup {
height: 5;
border: solid $warning-darken-2;
}
/* Output Console Header & Expandable Terminal */
#output-header-bar {
height: 1;
margin-bottom: 1;
}
#output-title {
height: 1;
text-style: bold;
color: $accent;
width: 1fr;
}
#btn-expand-output {
height: 1;
min-width: 16;
padding: 0 1;
border: none;
}
#output-scroll-container {
height: 7;
background: $surface;
border: solid $accent;
padding: 1;
overflow-y: auto;
margin-bottom: 1;
}
#editor-panel.output-expanded #output-scroll-container {
height: 15;
}
#expanded-input-bar {
display: none;
height: 3;
layout: horizontal;
margin-bottom: 1;
}
#editor-panel.output-expanded #expanded-input-bar {
display: block;
}
#program-stdin-input {
width: 1fr;
margin-right: 1;
}
#btn-send-stdin {
width: 18;
}
/* Mentor & Handbook Sidebar Elements */
#mentor-response-scroll, #handbook-example-scroll {
height: 1fr;
background: $surface;
border: solid $warning-darken-1;
padding: 1;
margin-bottom: 1;
overflow-y: auto;
}
#mentor-input-title {
height: 1;
text-style: bold;
color: $warning;
}
#mentor-question-input {
height: 4;
border: solid $warning;
margin-bottom: 1;
}
#btn-send-mentor {
width: 100%;
margin-bottom: 1;
}
/* Status bar */
#status-bar {
height: 1;
background: $primary;
color: white;
padding: 0 1;
text-style: bold;
}
"""
BINDINGS = [
Binding("ctrl+r", "run_code", "Run"),
Binding("ctrl+l", "lint_code", "Lint"),
Binding("ctrl+e,f4", "toggle_expand_output", "Expand Output (Ctrl+E / F4)"),
Binding("f2", "check_answer", "Check", show=False),
Binding("f1", "toggle_mentor_sidebar", "Mentor", show=False),
Binding("f3", "toggle_handbook_sidebar", "Handbook", show=False),
Binding("ctrl+f", "refresh_challenges", "Refresh"),
Binding("alt+w", "scroll_output_up", "Output Up", show=False),
Binding("alt+s", "scroll_output_down", "Output Down", show=False),
Binding("ctrl+up", "scroll_mentor_up", "Sidebar Up", show=False),
Binding("ctrl+down", "scroll_mentor_down", "Sidebar Down", show=False),
Binding("alt+up", "scroll_details_up", "Details Up", show=False),
Binding("alt+down", "scroll_details_down", "Details Down", show=False),
Binding("escape", "unfocus_editor", "Exit Focus"),
Binding("f6", "toggle_panel_focus", "Switch Focus"),
Binding("ctrl+q", "quit", "Quit"),
]
def __init__(self, debug: bool = False) -> None:
super().__init__()
self.loader = ChallengeLoader()
self.active_challenge_id = None
self.challenges_dict = {}
self.catalog_functions: List[Dict[str, str]] = []
self.catalog_subjects: List[Dict[str, str]] = []
self.active_handbook_mode = "all"
self.active_completion_prefix = ""
self.debug_mode = debug
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="main-container"):
# Left column: Challenges & Details
with Container(id="challenge-panel"):
yield Static("TactiTerm Workspace", id="challenge-header")
yield OptionList(id="challenge_list")
with VerticalScroll(id="challenge-details-container"):
yield Markdown("Select a challenge from the list above to view details...", id="challenge-details")
# Middle column: Code Editor, Completion Dropdown Popup & Expandable Output Terminal
with Container(id="editor-panel"):
with Horizontal(id="action-bar"):
yield Button("▶ Run (Ctrl+R)", id="btn-run", variant="success")
yield Button("🔍 Lint (Ctrl+L)", id="btn-lint", variant="primary")
yield Button("✅ Check (F2)", id="btn-check", variant="success")
yield Button("💡 Mentor (F1)", id="btn-guide", variant="warning")
yield Button("📖 Handbook (F3)", id="btn-handbook", variant="primary")
yield Button("🔄 Refresh", id="btn-refresh", variant="default")
yield CodeEditor(id="editor")
# Auto-Completion Dropdown Popup Overlay
with Container(id="completion-popup"):
yield Static("💡 Auto-Complete Suggestions (↑/↓ to navigate, Enter/Tab to insert):", id="completion-title")
yield OptionList(id="completion_list_popup")
with Horizontal(id="output-header-bar"):
yield Static("Output Console (Alt+W/S: Scroll)", id="output-title")
yield Button("⤢ Expand / Input (Ctrl+E)", id="btn-expand-output", variant="primary")
with VerticalScroll(id="output-scroll-container"):
yield Static("Execution and lint output will appear here...", id="output-container")
with Horizontal(id="expanded-input-bar"):
yield StdinInput(placeholder="Type stdin input here and press Enter to send to program...", id="program-stdin-input")
yield Button("Send Input", id="btn-send-stdin", variant="success")
# Right column 1: Mentor Sidebar
with Container(id="mentor-sidebar"):
yield Static("💡 Mentor", id="mentor-header")
with VerticalScroll(id="mentor-response-scroll"):
yield Markdown("Ask the mentor a question or click Check Answer...", id="mentor-response-display")
yield Static("Ask Question (Enter: Send | Shift+Enter: Newline):", id="mentor-input-title")
yield MentorInputTextArea(id="mentor-question-input")
yield Button("💡 Ask Mentor (Enter)", id="btn-send-mentor", variant="warning")
# Right column 2: W3Schools-Style Handbook Sidebar
with Container(id="handbook-sidebar"):
yield Static("📖 W3Schools Handbook", id="handbook-header")
yield Input(placeholder="🔍 Search built-ins or topics...", id="handbook-search-input")
yield Button("Filter: All Items (Click to Switch)", id="btn-handbook-mode", variant="default")
yield OptionList(id="handbook_topic_list")
with VerticalScroll(id="handbook-example-scroll"):
yield Markdown("Select a function or topic above to view W3Schools reference card...", id="handbook-example-display")
yield Static("Ready", id="status-bar")
yield Footer()
# ── Events & Lifecycle ────────────────────────────────────
def action_toggle_expand_output(self) -> None:
"""Keybinding handler for Ctrl+E and F4."""
self.toggle_expand_output()
def on_mount(self) -> None:
self.call_after_refresh(self.load_challenges)
def load_challenges(self) -> None:
self.challenges_dict = self.loader.load_all()
try:
option_list = self.query_one("#challenge_list", OptionList)
option_list.clear_options()
for cid, challenge in sorted(self.challenges_dict.items()):
option_list.add_option(
Option(f"{challenge.name} ({challenge.difficulty})", id=cid)
)
count = len(self.challenges_dict)
if count > 0:
option_list.highlighted = 0
first_opt = option_list.get_option_at_index(0)
self.on_option_list_option_highlighted(
OptionList.OptionHighlighted(option_list, first_opt, 0)
)
self.query_one("#status-bar", Static).update(
f"Loaded {count} challenges — Hovering '{first_opt.prompt}' (Hit Enter to edit)"
)
else:
self.query_one("#status-bar", Static).update("No challenges found in src/challenges/.")
except Exception as e:
if self.debug_mode:
print(f"DEBUG: Exception in load_challenges: {e}")
def load_handbook_catalog(self, language: str) -> None:
catalog = handbook_service.get_catalog(language)
self.catalog_functions = catalog.get("functions", [])
self.catalog_subjects = catalog.get("subjects", [])
self.filter_handbook_list()
def filter_handbook_list(self) -> None:
search_term = ""
try:
search_input = self.query_one("#handbook-search-input", Input)
search_term = search_input.value.strip().lower()
except Exception:
pass
items: List[Dict[str, str]] = []
if self.active_handbook_mode == "functions":
items = [dict(t, icon="") for t in self.catalog_functions]
elif self.active_handbook_mode == "subjects":
items = [dict(t, icon="📚") for t in self.catalog_subjects]
else:
items = [dict(t, icon="") for t in self.catalog_functions] + [dict(t, icon="📚") for t in self.catalog_subjects]
if search_term:
items = [
i for i in items
if search_term in i["title"].lower() or search_term in i["desc"].lower()
]
topic_list = self.query_one("#handbook_topic_list", OptionList)
topic_list.clear_options()
for item in items:
topic_list.add_option(Option(f"{item['icon']} {item['title']}", id=item["id"]))
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "handbook-search-input":
self.filter_handbook_list()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "program-stdin-input":
self.send_program_stdin()
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
if button_id == "btn-run":
self.action_run_code()
elif button_id == "btn-lint":
self.action_lint_code()
elif button_id == "btn-check":
self.action_check_answer()
elif button_id == "btn-guide":
self.action_toggle_mentor_sidebar()
elif button_id == "btn-handbook":
self.action_toggle_handbook_sidebar()
elif button_id == "btn-handbook-mode":
self.toggle_handbook_mode()
elif button_id == "btn-expand-output":
self.toggle_expand_output()
elif button_id == "btn-send-stdin":
self.send_program_stdin()
elif button_id == "btn-send-mentor":
self.submit_mentor_question()
elif button_id == "btn-refresh":
self.action_refresh_challenges()
def toggle_expand_output(self) -> None:
"""Toggle output terminal expansion and stdin input bar."""
panel = self.query_one("#editor-panel")
btn = self.query_one("#btn-expand-output", Button)
if panel.has_class("output-expanded"):
panel.remove_class("output-expanded")
btn.label = "⤢ Expand / Input (Ctrl+E)"
self.query_one("#editor", CodeEditor).focus()
self.query_one("#status-bar", Static).update("Output console collapsed.")
else:
panel.add_class("output-expanded")
btn.label = "⤡ Collapse (Ctrl+E)"
self.query_one("#program-stdin-input", StdinInput).focus()
self.query_one("#status-bar", Static).update(
"Output console expanded. Type stdin input below and hit Enter to run program with input."
)
def send_program_stdin(self) -> None:
"""Send input text from #program-stdin-input to running code."""
stdin_input = self.query_one("#program-stdin-input", StdinInput)
user_stdin = stdin_input.value
stdin_input.value = ""
self.action_run_code(stdin=user_stdin)
def toggle_handbook_mode(self) -> None:
if self.active_handbook_mode == "all":
self.active_handbook_mode = "functions"
label = "Filter: ⚡ Built-in Functions"
elif self.active_handbook_mode == "functions":
self.active_handbook_mode = "subjects"
label = "Filter: 📚 Broad Subjects"
else:
self.active_handbook_mode = "all"
label = "Filter: All Items"
self.query_one("#btn-handbook-mode", Button).label = label
self.filter_handbook_list()
# ── Sidebar & Focus Management ───────────────────────────
def action_toggle_mentor_sidebar(self) -> None:
main_box = self.query_one("#main-container")
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
if main_box.has_class("sidebar-mentor-open"):
main_box.remove_class("sidebar-mentor-open")
main_box.remove_class("sidebar-open")
self.query_one("#editor", CodeEditor).focus()
self.query_one("#status-bar", Static).update("Mentor sidebar closed.")
else:
main_box.remove_class("sidebar-handbook-open")
main_box.add_class("sidebar-open")
main_box.add_class("sidebar-mentor-open")
q_input.focus()
self.query_one("#status-bar", Static).update("Mentor sidebar open.")
def action_toggle_handbook_sidebar(self) -> None:
main_box = self.query_one("#main-container")
if main_box.has_class("sidebar-handbook-open"):
main_box.remove_class("sidebar-handbook-open")
main_box.remove_class("sidebar-open")
self.query_one("#editor", CodeEditor).focus()
self.query_one("#status-bar", Static).update("Handbook sidebar closed.")
else:
main_box.remove_class("sidebar-mentor-open")
main_box.add_class("sidebar-open")
main_box.add_class("sidebar-handbook-open")
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
lang = challenge.language if challenge else "Python"
self.load_handbook_catalog(lang)
self.query_one("#handbook_topic_list", OptionList).focus()
self.query_one("#status-bar", Static).update(f"W3Schools Handbook open for {lang}.")
def on_mentor_input_text_area_submit_question(
self, event: MentorInputTextArea.SubmitQuestion
) -> None:
self.submit_mentor_question(event.question)
def submit_mentor_question(self, question: str | None = None) -> None:
if not self.active_challenge_id:
self.query_one("#status-bar", Static).update("Please select a challenge first.")
return
challenge = self.challenges_dict.get(self.active_challenge_id)
if not challenge:
return
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
user_question = q_input.text.strip() if question is None else question.strip()
code = self.query_one("#editor", CodeEditor).text
main_box = self.query_one("#main-container")
main_box.remove_class("sidebar-handbook-open")
main_box.add_class("sidebar-open")
main_box.add_class("sidebar-mentor-open")
q_input.text = ""
self.query_one("#mentor-response-display", Markdown).update(
"⏳ **Mentor is thinking and generating guidance...**\n\n*Analyzing your code, task requirements, and question...*"
)
self.query_one("#status-bar", Static).update(
f"⏳ Consulting Mentor at {mentor.config.llm_base_url}..."
)
self.run_worker(self._mentor_worker(challenge, code, user_question))
def action_check_answer(self) -> None:
if not self.active_challenge_id:
self.query_one("#status-bar", Static).update("Please select a challenge first.")
return
challenge = self.challenges_dict.get(self.active_challenge_id)
if not challenge:
return
code = self.query_one("#editor", CodeEditor).text
if not code.strip():
self.query_one("#status-bar", Static).update("Editor is empty — write code before checking answer.")
return
main_box = self.query_one("#main-container")
main_box.remove_class("sidebar-handbook-open")
main_box.add_class("sidebar-open")
main_box.add_class("sidebar-mentor-open")
check_question = (
"Please evaluate my code implementation against all the requirements of this challenge. "
"Check if my solution is complete and correct, point out any missing requirements or edge cases, "
"and give me feedback on my answer."
)
self.query_one("#mentor-response-display", Markdown).update(
"⏳ **Mentor is evaluating your solution against challenge requirements...**\n\n*Checking logic, requirements, and edge cases...*"
)
self.query_one("#status-bar", Static).update("⏳ Consulting Mentor to check answer...")
self.run_worker(self._mentor_worker(challenge, code, check_question))
def action_unfocus_editor(self) -> None:
main_box = self.query_one("#main-container")
if main_box.has_class("show-completion"):
main_box.remove_class("show-completion")
self.query_one("#editor", CodeEditor).focus()
return
self.query_one("#challenge_list", OptionList).focus()
self.query_one("#status-bar", Static).update("Focus moved to Challenge List.")
def action_toggle_panel_focus(self) -> None:
focused = self.focused
if focused and getattr(focused, "id", None) == "editor":
main_box = self.query_one("#main-container")
if main_box.has_class("sidebar-handbook-open"):
self.query_one("#handbook_topic_list", OptionList).focus()
else:
if not main_box.has_class("sidebar-mentor-open"):
main_box.add_class("sidebar-open")
main_box.add_class("sidebar-mentor-open")
self.query_one("#mentor-question-input", MentorInputTextArea).focus()
elif focused and getattr(focused, "id", None) in ("mentor-question-input", "handbook_topic_list"):
self.query_one("#challenge_list", OptionList).focus()
else:
self.query_one("#editor", CodeEditor).focus()
# ── Challenge selection & Handbook selection ─────────────
def _display_challenge(self, cid: str) -> None:
self.active_challenge_id = cid
challenge = self.challenges_dict.get(cid)
if not challenge:
return
req_str = "\n".join(f"- {r}" for r in challenge.requirements) if challenge.requirements else "*None*"
hint_str = "\n".join(f"- {h}" for h in challenge.hints) if challenge.hints else "*None*"
markdown_content = f"""# {challenge.name}
**Difficulty:** {challenge.difficulty} | **Language:** {challenge.language} | **Subject:** {challenge.subject}
## Description
{challenge.description}
## Requirements
{req_str}
## Hints
{hint_str}
"""
self.query_one("#challenge-details", Markdown).update(markdown_content)
editor = self.query_one("#editor", CodeEditor)
lang_key = challenge.language.lower()
try:
if lang_key in editor.available_languages:
editor.language = lang_key
else:
editor.language = None
except Exception:
editor.language = None
main_box = self.query_one("#main-container")
if main_box.has_class("sidebar-handbook-open"):
self.load_handbook_catalog(challenge.language)
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
if event.option_list.id == "challenge_list":
selected_option = event.option
if selected_option and selected_option.id:
self._display_challenge(selected_option.id)
self.query_one("#status-bar", Static).update(
f"Hovering Challenge: {selected_option.prompt} — Hit Enter to jump to Editor"
)
def on_option_list_option_selected(
self, event: OptionList.OptionSelected
) -> None:
if event.option_list.id == "challenge_list":
selected_option = event.option
if selected_option and selected_option.id:
self._display_challenge(selected_option.id)
self.query_one("#editor", CodeEditor).focus()
self.query_one("#status-bar", Static).update("Focused: Code Editor")
elif event.option_list.id == "completion_list_popup":
selected_option = event.option
if selected_option and selected_option.prompt:
chosen_text = str(selected_option.prompt)
editor = self.query_one("#editor", CodeEditor)
cursor_row, cursor_col = editor.cursor_location
suffix_len = len(self.active_completion_prefix)
start_col = max(0, cursor_col - suffix_len)
editor.delete((cursor_row, start_col), (cursor_row, cursor_col))
editor.insert(chosen_text)
main_box = self.query_one("#main-container")
main_box.remove_class("show-completion")
editor.focus()
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
elif event.option_list.id == "handbook_topic_list":
selected_option = event.option
if selected_option and selected_option.id:
all_items = self.catalog_functions + self.catalog_subjects
item = next((i for i in all_items if i["id"] == selected_option.id), None)
if item:
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
lang = challenge.language if challenge else "Python"
self.query_one("#handbook-example-display", Markdown).update(
f"⏳ **Generating W3Schools reference card for '{item['title']}' in {lang}...**"
)
self.run_worker(self._handbook_worker(lang, item["id"], item["title"]))
def show_completion_popup(self, prefix: str, matches: List[str]) -> None:
self.active_completion_prefix = prefix
popup_list = self.query_one("#completion_list_popup", OptionList)
popup_list.clear_options()
for match in matches:
popup_list.add_option(Option(match, id=match))
main_box = self.query_one("#main-container")
main_box.add_class("show-completion")
popup_list.focus()
if len(matches) > 0:
popup_list.highlighted = 0
# ── Scrolling Actions ────────────────────────────────────
def action_scroll_mentor_up(self) -> None:
main_box = self.query_one("#main-container")
if main_box.has_class("sidebar-handbook-open"):
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_up(animate=False)
else:
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_up(animate=False)
def action_scroll_mentor_down(self) -> None:
main_box = self.query_one("#main-container")
if main_box.has_class("sidebar-handbook-open"):
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_down(animate=False)
else:
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_down(animate=False)
def action_scroll_output_up(self) -> None:
self.query_one("#output-scroll-container", VerticalScroll).scroll_up(animate=False)
def action_scroll_output_down(self) -> None:
self.query_one("#output-scroll-container", VerticalScroll).scroll_down(animate=False)
def action_scroll_details_up(self) -> None:
self.query_one("#challenge-details-container", VerticalScroll).scroll_up(animate=False)
def action_scroll_details_down(self) -> None:
self.query_one("#challenge-details-container", VerticalScroll).scroll_down(animate=False)
# ── Core Workers ─────────────────────────────────────────
def action_refresh_challenges(self) -> None:
self.query_one("#status-bar", Static).update("Refreshing challenges...")
self.load_challenges()
async def _mentor_worker(self, challenge: object, code: str, user_question: str) -> None:
res = await mentor.get_guidance_async(challenge, code, user_question=user_question)
response_text = res.get("mentor_response", "No response received.")
q_header = f"### Question / Evaluation:\n> {user_question}\n\n---\n\n" if user_question else ""
full_md = f"{q_header}{response_text}"
self.query_one("#mentor-response-display", Markdown).update(full_md)
self.query_one("#status-bar", Static).update("Mentor response received — Press F1 to ask or F2 to check answer")
async def _handbook_worker(self, language: str, topic_id: str, topic_title: str) -> None:
res = await handbook_service.generate_example_async(language, topic_id, topic_title)
if res.get("status") == "success":
md_text = res.get("example_markdown", "No reference card generated.")
self.query_one("#handbook-example-display", Markdown).update(md_text)
self.query_one("#status-bar", Static).update(f"✓ Reference card loaded for '{topic_title}'")
else:
err_msg = res.get("message", "Error generating reference card.")
self.query_one("#handbook-example-display", Markdown).update(err_msg)
self.query_one("#status-bar", Static).update("✗ Failed to generate reference card")
def action_lint_code(self) -> None:
code = self.query_one("#editor", CodeEditor).text
if not code.strip():
self.query_one("#status-bar", Static).update("Editor is empty — nothing to lint.")
return
self.query_one("#status-bar", Static).update("Running syntax & style lint...")
challenge = self.challenges_dict.get(self.active_challenge_id)
lang = challenge.language.lower() if challenge else "python"
res = linter.lint(lang, code)
output_widget = self.query_one("#output-container", Static)
if res.get("exit_code") == 0:
output_widget.update("✓ Syntax & Style clean! No linting errors detected.")
else:
raw = (res.get("stdout") or "") + "\n" + (res.get("stderr") or "")
lines = [line.strip() for line in raw.strip().splitlines() if line.strip()]
cleaned = []
for line in lines:
if ":" in line:
parts = line.split(":", 3)
if len(parts) >= 3 and parts[1].isdigit():
cleaned.append(f"Line {parts[1]}, Col {parts[2]}:{parts[3]}")
else:
cleaned.append(line)
else:
cleaned.append(line)
msg = "\n".join(cleaned) if cleaned else "Syntax error detected."
output_widget.update(f"✗ Lint Error(s):\n{msg}")
self.query_one("#status-bar", Static).update("Lint complete")
def action_run_code(self, stdin: str = "") -> None:
code = self.query_one("#editor", CodeEditor).text
if not code.strip():
self.query_one("#status-bar", Static).update("Editor is empty — write code before running.")
return
self.query_one("#status-bar", Static).update("Executing code...")
challenge = self.challenges_dict.get(self.active_challenge_id)
lang = challenge.language.lower() if challenge else "python"
res = executor.run(lang, code, stdin=stdin)
output_widget = self.query_one("#output-container", Static)
if res.get("exit_code") == 0:
stdout = res.get("stdout", "").strip()
if stdout:
output_widget.update(f"Output:\n{stdout}")
else:
output_widget.update("✓ Code executed successfully (exit code 0, no stdout).")
else:
stderr = res.get("stderr", "").strip()
stdout = res.get("stdout", "").strip()
err_msg = stderr or stdout or "Unknown runtime error"
output_widget.update(f"✗ Runtime Error (exit code {res.get('exit_code')}):\n{err_msg}")
self.query_one("#status-bar", Static).update("Run complete")
TactTermTUI = TactiTermTUI
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="TactiTerm TUI")
parser.add_argument("--debug", action="store_true", help="Enable debug logging to tactiterm_tui.log")
args = parser.parse_args()
app = TactiTermTUI(debug=args.debug)
app.run()
+414
View File
@@ -0,0 +1,414 @@
"""GenTUI — Standalone TUI tool for generating and editing TactiTerm challenges with AI."""
import os
import argparse
import asyncio
import httpx
from textual.app import App, ComposeResult
from textual.screen import ModalScreen
from textual.containers import Container, Horizontal, VerticalScroll
from textual.widgets import Header, Footer, Static, Button, Markdown, TextArea, Input, Select
from textual.binding import Binding
from src.core.generator import generator
from src.core.config import config
from src.core.mentor import get_chat_completions_url
class GenHelpModal(ModalScreen):
"""Interactive AI Assistant Modal for helping user design challenges."""
DEFAULT_CSS = """
GenHelpModal {
align: center middle;
background: rgba(0, 0, 0, 0.7);
}
#help-dialog {
width: 75%;
height: 80%;
background: $surface;
border: thick $primary;
padding: 1 2;
layout: vertical;
}
#help-header {
text-align: center;
text-style: bold;
background: $primary;
color: white;
height: 1;
margin-bottom: 1;
}
#help-response-scroll {
height: 1fr;
border: solid $secondary;
padding: 1;
background: $background;
margin-bottom: 1;
overflow-y: auto;
}
#help-input-label {
height: 1;
text-style: bold;
color: $accent;
}
#help-input {
margin-bottom: 1;
}
#help-button-container {
height: 3;
layout: horizontal;
}
#btn-ask-help {
width: 1fr;
margin-right: 1;
}
#btn-close-help {
width: 1fr;
}
"""
BINDINGS = [
Binding("ctrl+up", "scroll_up", "Scroll Up (Ctrl+↑)"),
Binding("ctrl+down", "scroll_down", "Scroll Down (Ctrl+↓)"),
]
def action_scroll_up(self) -> None:
self.query_one("#help-response-scroll", VerticalScroll).scroll_up(animate=False)
def action_scroll_down(self) -> None:
self.query_one("#help-response-scroll", VerticalScroll).scroll_down(animate=False)
def compose(self) -> ComposeResult:
with Container(id="help-dialog"):
yield Static("💡 TactiTerm AI Challenge Design Assistant", id="help-header")
with VerticalScroll(id="help-response-scroll"):
yield Markdown(
"### How can I help you design your challenge?\n"
"Ask me anything if you're unsure about what language features, topics, requirements, or expected outputs to use!",
id="help-response-markdown",
)
yield Static("Ask Assistant a Question:", id="help-input-label")
yield Input(
placeholder="e.g. What are good topics for a Medium Rust challenge? Or what requirements should a Java OOP challenge have?",
id="help-input",
)
with Horizontal(id="help-button-container"):
yield Button("✨ Ask Assistant (Enter)", id="btn-ask-help", variant="warning")
yield Button("Close Window", id="btn-close-help", variant="error")
def on_mount(self) -> None:
self.query_one("#help-input", Input).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-ask-help":
self.action_ask_assistant()
elif event.button.id == "btn-close-help":
self.dismiss()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "help-input":
self.action_ask_assistant()
def action_ask_assistant(self) -> None:
question = self.query_one("#help-input", Input).value.strip()
if not question:
return
self.query_one("#help-response-markdown", Markdown).update("⏳ **Assistant is thinking...**\n\n*Consulting LLM backend for advice...*")
self.run_worker(self._ask_worker(question))
async def _ask_worker(self, question: str) -> None:
endpoint = get_chat_completions_url(config.llm_base_url)
payload = {
"model": config.llm_model,
"messages": [
{
"role": "system",
"content": (
"You are an expert Software Engineering Curriculum Designer assisting a teacher in creating coding challenges. "
"Give clear, concise, actionable advice for challenge topics, difficulties, programming languages, and requirements."
),
},
{"role": "user", "content": question},
],
"max_tokens": 1000,
}
headers = {"Content-Type": "application/json"}
if config.llm_api_key and config.llm_api_key != "not-needed":
headers["Authorization"] = f"Bearer {config.llm_api_key}"
try:
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
response = await client.post(endpoint, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
text = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "No response received.")
)
self.query_one("#help-response-markdown", Markdown).update(text)
except Exception as e:
self.query_one("#help-response-markdown", Markdown).update(f"⚠️ Error asking Assistant: {e}")
class GenTUIApp(App):
"""Standalone TUI Challenge Generator Application."""
TITLE = "TactiTerm GenTUI"
CSS = """
Screen {
layout: vertical;
}
#main-container {
layout: horizontal;
height: 1fr;
width: 100%;
}
/* Left Panel: Generator Controls */
#control-panel {
width: 32%;
min-width: 28;
height: 100%;
border-right: heavy $primary;
padding: 0 1;
}
/* Middle Panel: Raw Markdown Editor */
#editor-panel {
width: 36%;
height: 100%;
padding: 0 1;
border-right: heavy $primary;
}
/* Right Panel: Live Markdown Preview */
#preview-panel {
width: 32%;
height: 100%;
padding: 0 1;
}
.section-header {
height: 3;
background: $primary;
color: white;
content-align: center middle;
text-style: bold;
margin-bottom: 1;
}
.input-label {
height: 1;
text-style: bold;
color: $accent;
margin-top: 1;
}
Input, Select {
margin-bottom: 1;
}
TextArea {
height: 1fr;
border: solid $accent;
margin-bottom: 1;
}
#preview-scroll {
height: 1fr;
border: solid $secondary;
padding: 1;
background: $surface;
overflow-y: auto;
}
#btn-help-modal {
width: 100%;
margin-top: 1;
margin-bottom: 1;
}
#btn-generate, #btn-save {
width: 100%;
margin-top: 1;
margin-bottom: 1;
}
#status-bar {
height: 1;
background: $primary;
color: white;
padding: 0 1;
text-style: bold;
}
"""
BINDINGS = [
Binding("ctrl+h", "open_help_modal", "AI Helper"),
Binding("ctrl+g", "generate_ai", "Generate AI"),
Binding("ctrl+s", "save_challenge", "Save File"),
Binding("ctrl+q", "quit", "Quit"),
]
LANGUAGES = ["Python", "C#", "C++", "Java", "JavaScript", "TypeScript", "Rust", "Lua", "HTML", "Go"]
DIFFICULTIES = ["Easy", "Medium", "Hard", "Advanced"]
def __init__(self, debug: bool = False) -> None:
super().__init__()
self.debug_mode = debug
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="main-container"):
# Left Panel: Prompt Controls
with Container(id="control-panel"):
yield Static("1. Prompt AI Generator", classes="section-header")
yield Button("💡 Help Me Decide (AI Assist) [Ctrl+H]", id="btn-help-modal", variant="default")
yield Static("AI Challenge Prompt:", classes="input-label")
yield Input(
placeholder="e.g. Medium Rust challenge on Ownership & References",
id="input-prompt",
)
yield Static("Language (Dropdown):", classes="input-label")
yield Select.from_values(
self.LANGUAGES,
allow_blank=False,
value="Python",
id="select-language",
)
yield Static("Difficulty (Dropdown):", classes="input-label")
yield Select.from_values(
self.DIFFICULTIES,
allow_blank=False,
value="Medium",
id="select-difficulty",
)
yield Static("Subject / Topic:", classes="input-label")
yield Input(placeholder="e.g. Memory Management, Data Structures", id="input-subject", value="General Concepts")
yield Button("✨ Generate with AI (Ctrl+G)", id="btn-generate", variant="warning")
yield Static("Filename (.md):", classes="input-label")
yield Input(placeholder="new-challenge.md", id="input-filename")
yield Button("💾 Save Challenge to Disk (Ctrl+S)", id="btn-save", variant="success")
# Middle Panel: Markdown Code Editor
with Container(id="editor-panel"):
yield Static("2. Raw Markdown (.md) Editor", classes="section-header")
yield TextArea(id="markdown-editor")
# Right Panel: Live Preview
with Container(id="preview-panel"):
yield Static("3. Live Rendered Preview", classes="section-header")
with VerticalScroll(id="preview-scroll"):
yield Markdown("AI generated preview will appear here...", id="markdown-preview")
yield Static("Ready — Type your prompt and click Generate with AI", id="status-bar")
yield Footer()
def on_mount(self) -> None:
editor = self.query_one("#markdown-editor", TextArea)
if "markdown" in editor.available_languages:
editor.language = "markdown"
self.query_one("#input-prompt", Input).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-help-modal":
self.action_open_help_modal()
elif event.button.id == "btn-generate":
self.action_generate_ai()
elif event.button.id == "btn-save":
self.action_save_challenge()
def on_text_area_changed(self, event: TextArea.Changed) -> None:
"""Update live preview whenever markdown editor content changes."""
if event.text_area.id == "markdown-editor":
self.query_one("#markdown-preview", Markdown).update(event.text_area.text)
def action_open_help_modal(self) -> None:
"""Pop up the AI assistant help window."""
self.push_screen(GenHelpModal())
def action_generate_ai(self) -> None:
"""Generate challenge markdown using LLM service worker."""
prompt_text = self.query_one("#input-prompt", Input).value.strip()
if not prompt_text:
self.query_one("#status-bar", Static).update("Please enter an AI prompt description.")
return
lang_select = self.query_one("#select-language", Select)
diff_select = self.query_one("#select-difficulty", Select)
lang = str(lang_select.value) if lang_select.value != Select.BLANK else "Python"
diff = str(diff_select.value) if diff_select.value != Select.BLANK else "Medium"
subj = self.query_one("#input-subject", Input).value.strip() or "General"
self.query_one("#status-bar", Static).update("⏳ Generating challenge via LLM backend...")
self.run_worker(self._generate_worker(prompt_text, diff, lang, subj))
async def _generate_worker(self, prompt: str, difficulty: str, language: str, subject: str) -> None:
res = await generator.generate_challenge_async(prompt, difficulty=difficulty, language=language, subject=subject)
if res.get("status") == "success":
md_text = res.get("markdown", "")
filename = res.get("filename", "new-challenge.md")
editor = self.query_one("#markdown-editor", TextArea)
editor.text = md_text
self.query_one("#input-filename", Input).value = filename
self.query_one("#markdown-preview", Markdown).update(md_text)
self.query_one("#status-bar", Static).update(f"✓ Challenge generated: '{res.get('title')}' — Edit or click Save to disk")
else:
err_msg = res.get("message", "Unknown error")
self.query_one("#status-bar", Static).update(f"✗ Challenge Generation Failed: {err_msg}")
def action_save_challenge(self) -> None:
"""Save the markdown content to disk."""
filename = self.query_one("#input-filename", Input).value.strip()
md_text = self.query_one("#markdown-editor", TextArea).text.strip()
if not filename:
self.query_one("#status-bar", Static).update("Please specify a filename (.md) before saving.")
return
if not md_text:
self.query_one("#status-bar", Static).update("Markdown editor is empty — write or generate content first.")
return
res = generator.save_challenge(filename, md_text)
if res.get("status") == "success":
self.query_one("#status-bar", Static).update(f"✓ Saved challenge file to {res.get('path')}")
else:
self.query_one("#status-bar", Static).update(f"✗ Save Error: {res.get('message')}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
app = GenTUIApp(debug=args.debug)
app.run()
+190
View File
@@ -0,0 +1,190 @@
"""Custom widgets for the TactiTerm TUI with auto-completion engine and StdinInput."""
import asyncio
from typing import List, Dict
from textual import events
from textual.message import Message
from textual.widgets import TextArea, Input
# Comprehensive auto-completion syntax catalog per language for TUI
TUI_COMPLETIONS: Dict[str, List[str]] = {
"python": [
"int", "float", "bool", "str", "list", "dict", "set", "tuple", "bytes", "None",
"True", "False", "print()", "len()", "range()", "enumerate()", "zip()", "map()",
"filter()", "sorted()", "isinstance()", "type()", "open()", "input()", ".append()",
".split()", ".join()", ".get()", "def ", "class ", "return ", "import ", "from ",
"if ", "elif ", "else:", "try:", "except ", "finally:", "raise ", "with ", "as ",
"yield ", "async ", "await ", "lambda ", "pass", "break", "continue", "assert "
],
"rust": [
"bool", "i32", "i64", "u32", "u64", "usize", "f32", "f64", "char", "str", "String",
"Vec", "Option", "Result", "Some", "None", "Ok", "Err", "println!()", "print!()",
"format!()", "vec![]", "Vec::push()", "String::from()", "Option::unwrap()",
"Result::expect()", ".iter()", ".collect()", "Box::new()", "fn ", "let ", "let mut ",
"pub ", "struct ", "enum ", "impl ", "trait ", "match ", "if ", "else ", "loop ",
"while ", "for ", "in ", "return ", "use std::", "async ", "await "
],
"cpp": [
"int", "float", "double", "bool", "char", "void", "size_t", "std::string",
"std::vector<", "std::map<", "std::set<", "std::unique_ptr<", "std::shared_ptr<",
"std::cout << ", "std::cin >> ", "std::endl", ".push_back()", "std::sort()",
"std::make_unique<", "std::make_shared<", "class ", "struct ", "public:", "private:",
"protected:", "virtual ", "override", "const ", "constexpr ", "auto ", "template<typename T>",
"namespace ", "using namespace std;", "return ", "if ", "else ", "for ", "while "
],
"go": [
"int", "int64", "float64", "bool", "string", "byte", "rune", "error", "nil", "true",
"false", "fmt.Println()", "fmt.Printf()", "fmt.Sprintf()", "make()", "append()",
"len()", "cap()", "delete()", "strings.Split()", "os.Open()", "func ", "var ", "type ",
"struct ", "interface ", "package main", "import ", "return ", "if ", "else ", "for ",
"range ", "switch ", "select ", "chan ", "go func()"
],
"javascript": [
"number", "string", "boolean", "bool", "any", "void", "null", "undefined", "Array",
"Object", "Promise", "console.log()", "console.error()", "console.warn()", ".map()",
".filter()", ".reduce()", ".find()", ".push()", ".includes()", "Object.keys()",
"JSON.parse()", "fetch()", "const ", "let ", "var ", "function ", "class ", "return ",
"if ", "else ", "for ", "while ", "switch ", "try ", "catch ", "async ", "await ", "new "
],
"typescript": [
"number", "string", "boolean", "bool", "any", "void", "unknown", "never", "null",
"undefined", "Array<", "Record<", "Partial<", "Readonly<", "Pick<", "Omit<",
"Promise<", "console.log()", ".map()", ".filter()", "interface ", "type ", "const ",
"let ", "function ", "class ", "export ", "import ", "async ", "await "
],
"java": [
"int", "long", "double", "float", "boolean", "bool", "char", "byte", "short", "void",
"String", "List<", "ArrayList<", "Map<", "HashMap<", "Set<", "HashSet<",
"System.out.println()", ".add()", ".get()", ".size()", "public ", "private ",
"protected ", "static ", "final ", "class ", "interface ", "extends ", "implements ",
"new ", "return ", "if ", "else ", "for ", "while ", "try ", "catch "
],
"csharp": [
"int", "long", "double", "float", "bool", "char", "string", "object", "void", "var",
"List<", "Dictionary<", "HashSet<", "Task<", "Console.WriteLine()", "Console.ReadLine()",
".Add()", "Enumerable.Where()", "public ", "private ", "protected ", "internal ",
"static ", "class ", "struct ", "interface ", "record ", "namespace ", "using System;",
"new ", "return ", "if ", "else ", "foreach ", "while ", "async ", "await "
],
"lua": [
"number", "int", "string", "boolean", "bool", "table", "nil", "function", "print()",
"type()", "tostring()", "tonumber()", "table.insert()", "string.sub()", "local ",
"if ", "then", "else", "elseif ", "end", "for ", "while ", "do", "return ", "require()"
],
"html": [
"<!DOCTYPE html>", "<html>", "<head>", "<title>", "<body>", "<h1>", "<h2>", "<h3>",
"<p>", "<span>", "<div>", "<a href=\"\">", "<img src=\"\">", "<form>", "<input type=\"text\">",
"<button>", "<select>", "<option>", "<ul>", "<ol>", "<li>", "<table>", "<tr>", "<td>", "<th>",
"<script>", "<link rel=\"stylesheet\">"
],
}
class CodeEditor(TextArea):
"""A code editor with auto-completion dropdown trigger."""
DEFAULT_CSS = """
CodeEditor {
height: 1fr;
min-height: 8;
border: solid $accent;
}
CodeEditor:focus {
border: heavy $primary;
}
"""
def __init__(
self,
language: str = "python",
theme: str = "vscode_dark",
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(
language=language,
theme=theme,
soft_wrap=False,
show_line_numbers=True,
id=id,
classes=classes,
)
def on_mount(self) -> None:
self.tab_behavior = "indent"
self.cursor_style = "bold blue"
def get_code(self) -> str:
return self.text
def _on_key(self, event: events.Key) -> None:
"""Handle Tab completion and Ctrl+E toggle."""
if event.key == "ctrl+e":
event.prevent_default()
event.stop()
if hasattr(self.app, "action_toggle_expand_output"):
self.app.action_toggle_expand_output()
return
if event.key == "tab":
cursor_row, cursor_col = self.cursor_location
lines = self.text.split("\n")
if cursor_row < len(lines):
current_line = lines[cursor_row][:cursor_col]
words = current_line.replace("(", " ").replace(")", " ").split()
if words:
prefix = words[-1].lower()
lang = (self.language or "python").lower()
candidates = TUI_COMPLETIONS.get(lang, TUI_COMPLETIONS["python"])
matches = [c for c in candidates if c.lower().startswith(prefix) or prefix in c.lower()]
if matches:
event.prevent_default()
event.stop()
if hasattr(self.app, "show_completion_popup"):
self.app.show_completion_popup(words[-1], matches)
return
super()._on_key(event)
class StdinInput(Input):
"""Input control for stdin that forwards Ctrl+E to screen output toggler."""
def _on_key(self, event: events.Key) -> None:
if event.key == "ctrl+e":
event.prevent_default()
event.stop()
if hasattr(self.app, "action_toggle_expand_output"):
self.app.action_toggle_expand_output()
return
res = super()._on_key(event)
if asyncio.iscoroutine(res):
asyncio.create_task(res)
class MentorInputTextArea(TextArea):
"""Multi-line question input for Socratic Mentor. Submits on Enter, inserts newline on Shift+Enter."""
class SubmitQuestion(Message):
"""Event posted when Enter is pressed."""
def __init__(self, question: str) -> None:
self.question = question
super().__init__()
def _on_key(self, event: events.Key) -> None:
if event.key == "ctrl+e":
event.prevent_default()
event.stop()
if hasattr(self.app, "action_toggle_expand_output"):
self.app.action_toggle_expand_output()
return
elif event.key == "enter":
event.prevent_default()
event.stop()
self.post_message(self.SubmitQuestion(self.text))
elif event.key in ["shift+enter", "ctrl+j"]:
self.insert("\n")
event.prevent_default()
else:
super()._on_key(event)