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
+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)